From aa600a57b762486259ef8ec71593d43b7fc2c63a Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Fri, 18 Sep 2026 10:07:55 +0100 Subject: [PATCH 1/5] Generate schemas from every source in a single pass crd-generate emits one npm package per run: its root index re-exports every API group the run saw, and its _schemas directory is a single flat namespace. Both describe the whole run, so generating per source rewrites them for that source alone and the last run wins, leaving models on disk that nothing can import. Add GenerateFromMultipleSources, collect transitive package dependencies so a Configuration's providers contribute their CRDs, and skip the pass when every source is at its recorded version. Signed-off-by: Steven Borrelli Co-Authored-By: Claude Opus 5 --- internal/dependency/manager.go | 144 ++++++++++- internal/dependency/manager_test.go | 246 +++++++++++------- internal/project/build.go | 23 +- internal/schemas/manager/lock.go | 13 +- internal/schemas/manager/manager.go | 314 +++++++++++++++++++++++ internal/schemas/manager/manager_test.go | 173 ++++++++++++- 6 files changed, 810 insertions(+), 103 deletions(-) diff --git a/internal/dependency/manager.go b/internal/dependency/manager.go index 6e34c251..808dc2d0 100644 --- a/internal/dependency/manager.go +++ b/internal/dependency/manager.go @@ -21,6 +21,7 @@ package dependency import ( "context" "fmt" + "slices" "strings" "sync" @@ -138,7 +139,7 @@ func WithResolver(r *clixpkg.Resolver) ManagerOption { // in the user's config. func NewManager(proj *v1alpha1.Project, projFS afero.Fs, opts ...ManagerOption) *Manager { options := &managerOptions{ - projFile: clixpkg.ProjectFile, + projFile: "crossplane-project.yaml", schemaFS: afero.NewBasePathFs(projFS, proj.Spec.Paths.Schemas), schemaGenerators: generator.AllLanguages(), schemaRunner: runner.NewRealSchemaRunner( @@ -386,6 +387,147 @@ func (m *Manager) addDependencyNoWrite(ctx context.Context, dep *v1alpha1.Depend } } +// CollectSources returns all schema sources from the project's dependencies +// without generating schemas. This allows the caller to merge sources and +// generate schemas in a single pass. +func (m *Manager) CollectSources(ctx context.Context, ch async.EventChannel) ([]smanager.Source, error) { + eg, egCtx := errgroup.WithContext(ctx) + + // One slice per project dependency: a package contributes its own CRDs and + // those of everything its metadata depends on. Indexing by position keeps + // the flattened result in project-file order. + sourcesByIndex := make([][]smanager.Source, len(m.proj.Spec.Dependencies)) + + for i := range m.proj.Spec.Dependencies { + dep := &m.proj.Spec.Dependencies[i] + desc := "Updating dependency " + GetSourceDescription(*dep) + eg.Go(func() error { + ch.SendEvent(desc, async.EventStatusStarted) + srcs, err := m.collectSource(egCtx, dep) + if err != nil { + ch.SendEvent(desc, async.EventStatusFailure) + return err + } + ch.SendEvent(desc, async.EventStatusSuccess) + + sourcesByIndex[i] = srcs + return nil + }) + } + + if err := eg.Wait(); err != nil { + return nil, err + } + + var sources []smanager.Source + for _, srcs := range sourcesByIndex { + sources = append(sources, srcs...) + } + + // Sort by ID. Project dependencies are collected concurrently and a + // transitive dependency shared by two of them lands under whichever + // goroutine claimed it first, so the flattened order is otherwise a race. + // The merged filesystem prefixes each source by its position, so a stable + // order keeps that tree reproducible between runs. + slices.SortFunc(sources, func(a, b smanager.Source) int { + return strings.Compare(a.ID(), b.ID()) + }) + + return sources, nil +} + +// collectSource returns the schema source for a dependency without generating schemas. +func (m *Manager) collectSource(ctx context.Context, dep *v1alpha1.Dependency) ([]smanager.Source, error) { + desc := GetSourceDescription(*dep) + switch { + case dep.Type == v1alpha1.DependencyTypeXpkg: + if dep.Xpkg == nil || dep.Xpkg.Package == "" { + return nil, errors.Errorf("xpkg dependency %q is missing xpkg.package; set xpkg.package to a valid package reference", desc) + } + + // If the version is a digest, format the OCI ref as + // repo@digest. Otherwise, use repo:tag, where tag may be a semver + // constraint. + ref := dep.Xpkg.Package + if _, err := conregv1.NewHash(dep.Xpkg.Version); err == nil { + ref = fmt.Sprintf("%s@%s", ref, dep.Xpkg.Version) + } else if dep.Xpkg.Version != "" { + ref = fmt.Sprintf("%s:%s", ref, dep.Xpkg.Version) + } + + return m.collectPackageSource(ctx, ref) + case dep.Git != nil: + return []smanager.Source{smanager.NewGitSource(*dep, m.gitCloner, m.gitAuthProvider)}, nil + case dep.HTTP != nil: + return []smanager.Source{smanager.NewHTTPSource(*dep)}, nil + case dep.K8s != nil: + return []smanager.Source{smanager.NewK8sSource(*dep)}, nil + default: + return nil, errors.Errorf("dependency %q has no source configured; set exactly one of xpkg, git, http, or k8s", desc) + } +} + +// collectPackageSource fetches a package and returns its CRD source, followed +// by the sources of everything its metadata depends on. The merged schema pass +// generates from exactly what this returns, so a dependency missing here +// contributes no schemas. claim() gives cycle protection and dedupes diamonds. +func (m *Manager) collectPackageSource(ctx context.Context, ref string) ([]smanager.Source, error) { + if !m.claim(ref) { + // Already collected this invocation, directly or through another + // package's dependencies. + return nil, nil + } + + resolvedRef, version, err := m.resolver.Resolve(ctx, ref) + if err != nil { + return nil, errors.Wrapf(err, "cannot resolve package %q; check that the package exists and that the version or digest is valid", ref) + } + + // The claim above is on the reference as written, so one package named both + // by constraint and by exact version passes it twice and would be generated + // from twice. Claiming before the fetch also skips the dropped copy's + // download. + if !m.claim("resolved:" + resolvedRef.String()) { + return nil, nil + } + + pullPolicy := corev1.PullIfNotPresent + pkg, err := m.client.Get(ctx, resolvedRef.String(), runtimexpkg.WithPullPolicy(pullPolicy)) + if err != nil { + return nil, errors.Wrapf(err, "cannot download package %q; check registry access and credentials", ref) + } + + crdFS, err := clixpkg.CRDFilesystem(pkg.Package) + if err != nil { + return nil, errors.Wrapf(err, "cannot extract CRDs from package %q; check that it is a valid Crossplane package", ref) + } + + // Use the resolved version so constraint and exact-version inputs + // collapse to one schema-lock entry. + id := pkg.Source + "@" + pkg.Digest + if version != "" { + id = pkg.Source + ":" + version + } + + sources := []smanager.Source{smanager.NewXpkgSource(id, pkg.Digest, crdFS)} + + // Depth first, in metadata order, so the result is deterministic for a + // given dependency graph. + for _, dep := range pkg.GetDependencies() { + repo := dependencyRepo(dep) + if repo == "" { + continue + } + depSources, err := m.collectPackageSource(ctx, xpkgRef(repo, dep.Version)) + if err != nil { + return nil, errors.Wrapf(err, "cannot collect transitive dependency %s of %s", repo, pkg.Source) + } + sources = append(sources, depSources...) + } + + return sources, nil +} + // Clean removes all generated schemas. func (m *Manager) Clean() error { return m.projFS.RemoveAll(m.proj.Spec.Paths.Schemas) diff --git a/internal/dependency/manager_test.go b/internal/dependency/manager_test.go index 0d2339d5..800eabb9 100644 --- a/internal/dependency/manager_test.go +++ b/internal/dependency/manager_test.go @@ -71,46 +71,6 @@ spec: type: object ` -// configurationWithXRDPackageYAML is a Configuration package that bundles an -// XRD (rather than a raw CRD, like configurationPackageYAML above) - the -// shape that previously produced zero schemas. -const configurationWithXRDPackageYAML = `apiVersion: meta.pkg.crossplane.io/v1 -kind: Configuration -metadata: - name: example -spec: - crossplane: - version: ">=v1.14.0" ---- -apiVersion: apiextensions.crossplane.io/v1 -kind: CompositeResourceDefinition -metadata: - name: xdatabases.acme.example.com -spec: - group: acme.example.com - names: - kind: XDatabase - plural: xdatabases - singular: xdatabase - listKind: XDatabaseList - claimNames: - kind: Database - plural: databases - singular: database - listKind: DatabaseList - scope: LegacyCluster - versions: - - name: v1alpha1 - served: true - referenceable: true - schema: - openAPIV3Schema: - type: object - properties: - spec: - type: object -` - const providerPackageYAML = `apiVersion: meta.pkg.crossplane.io/v1 kind: Provider metadata: @@ -178,6 +138,13 @@ func parsedPackage(t *testing.T, body string) *parser.Package { return pkg } +// parsedTestPackage parses configurationPackageYAML once into a *parser.Package +// that the fake client can hand back from Get. +func parsedTestPackage(t *testing.T) *parser.Package { + t.Helper() + return parsedPackage(t, configurationPackageYAML) +} + // fakeClient is a fake xpkg.Client. Get returns a pre-canned Package per ref; // ListVersions returns the tags for the requested repo, falling back to a // fixed tag list, so a real Resolver can be wired on top. @@ -218,6 +185,16 @@ func (f *fakeClient) getCount(ref string) int { return f.gets[ref] } +func makePackage(t *testing.T, source, digest, version string) *runtimexpkg.Package { + t.Helper() + return &runtimexpkg.Package{ + Package: parsedTestPackage(t), + Source: source, + Digest: digest, + Version: version, + } +} + func makePackageWithBody(t *testing.T, source, digest, version, body string) *runtimexpkg.Package { t.Helper() return &runtimexpkg.Package{ @@ -228,7 +205,7 @@ func makePackageWithBody(t *testing.T, source, digest, version, body string) *ru } } -func newTestManager(t *testing.T, fc *fakeClient, generators ...generator.Interface) (*Manager, afero.Fs) { +func newTestManager(t *testing.T, fc *fakeClient) (*Manager, afero.Fs) { t.Helper() schemaFS := afero.NewMemMapFs() m := NewManager( @@ -239,7 +216,7 @@ func newTestManager(t *testing.T, fc *fakeClient, generators ...generator.Interf }, afero.NewMemMapFs(), WithSchemaFS(schemaFS), - WithSchemaGenerators(generators), + WithSchemaGenerators([]generator.Interface{}), WithXpkgClient(fc), WithResolver(clixpkg.NewResolver(fc)), ) @@ -568,25 +545,11 @@ func TestManager_AddDependency(t *testing.T) { } func TestManager_AddPackage(t *testing.T) { - const ( - cfgXRDPkg = "xpkg.crossplane.io/crossplane-contrib/configuration-xrd" - cfgXRDTag = "v0.1.0" - ) - tests := map[string]struct { ref string tags []string fetchAt string - // body is the package YAML served by the fake client; empty - // defaults to configurationPackageYAML. - body string - // generators are the schema generators wired into the manager; - // empty/nil means no schema files are actually rendered. - generators []generator.Interface - wantKey string - // wantSchemaGlob, when set, must match at least one file in - // schemaFS after AddPackage. - wantSchemaGlob string + wantKey string }{ "ConstraintCollapsesToResolvedVersion": { ref: "pkg.example/foo:>=v0.0.0", @@ -605,36 +568,17 @@ func TestManager_AddPackage(t *testing.T) { fetchAt: "pkg.example/foo@sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03", wantKey: "xpkg://pkg.example/foo@sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03", }, - // ConfigurationWithXRDGeneratesSchemas verifies that adding a - // Configuration dependency that bundles XRDs (rather than raw - // CRDs) generates real schema output, not just an empty - // successful pass. Before internal/xpkg.CRDFilesystem learned to - // convert XRDs to their derived CRD form, this produced a lock - // entry but zero schema content. - "ConfigurationWithXRDGeneratesSchemas": { - ref: cfgXRDPkg + ":" + cfgXRDTag, - tags: []string{cfgXRDTag}, - fetchAt: cfgXRDPkg + ":" + cfgXRDTag, - body: configurationWithXRDPackageYAML, - generators: generator.Filter(generator.AllLanguages(), []string{v1alpha1.SchemaLanguageJSON}), - wantKey: "xpkg://" + cfgXRDPkg + ":" + cfgXRDTag, - wantSchemaGlob: "json/*.schema.json", - }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { - body := tc.body - if body == "" { - body = configurationPackageYAML - } fc := &fakeClient{ packages: map[string]*runtimexpkg.Package{ - tc.fetchAt: makePackageWithBody(t, refRepo(tc.ref), "sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03", "", body), + tc.fetchAt: makePackage(t, "pkg.example/foo", "sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03", ""), }, tags: tc.tags, } - m, schemaFS := newTestManager(t, fc, tc.generators...) + m, schemaFS := newTestManager(t, fc) if _, err := m.AddPackage(context.Background(), tc.ref, false); err != nil { t.Fatalf("AddPackage: %v", err) @@ -656,16 +600,6 @@ func TestManager_AddPackage(t *testing.T) { if len(got.Packages) != 1 { t.Errorf("lock packages = %d, want 1; got %v", len(got.Packages), got.Packages) } - - if tc.wantSchemaGlob != "" { - files, err := afero.Glob(schemaFS, tc.wantSchemaGlob) - if err != nil { - t.Fatalf("glob generated schemas: %v", err) - } - if len(files) == 0 { - t.Errorf("no files matched %q; no schemas were generated", tc.wantSchemaGlob) - } - } }) } } @@ -871,6 +805,142 @@ func TestManager_AddPackage_TransitiveDeps(t *testing.T) { } } +func TestManager_CollectSources_Transitive(t *testing.T) { + // The merged schema pass generates from exactly what CollectSources + // returns, so a transitive dependency missing here contributes no schemas. + // provA and provB both depend on family, which must appear once. + const ( + provA = "xpkg.example/prov-a" + provB = "xpkg.example/prov-b" + family = "xpkg.example/family" + digest = "sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03" + ) + + fc := &fakeClient{ + packages: map[string]*runtimexpkg.Package{ + provA + ":v0.1.0": makePackageWithBody(t, provA, digest, "", fmt.Sprintf(providerDependsOnPackageYAML, family, "v1.0.0")), + provB + ":v0.2.0": makePackageWithBody(t, provB, digest, "", fmt.Sprintf(providerDependsOnPackageYAML, family, "v1.0.0")), + family + ":v1.0.0": makePackageWithBody(t, family, digest, "", providerPackageYAML), + }, + tagsByRepo: map[string][]string{ + provA: {"v0.1.0"}, + provB: {"v0.2.0"}, + family: {"v1.0.0"}, + }, + } + + m := NewManager( + &v1alpha1.Project{ + Spec: v1alpha1.ProjectSpec{ + Dependencies: []v1alpha1.Dependency{ + *xpkgDep(provA, "v0.1.0"), + *xpkgDep(provB, "v0.2.0"), + }, + Paths: &v1alpha1.ProjectPaths{Schemas: "schemas"}, + }, + }, + afero.NewMemMapFs(), + WithSchemaFS(afero.NewMemMapFs()), + WithSchemaGenerators([]generator.Interface{}), + WithXpkgClient(fc), + WithResolver(clixpkg.NewResolver(fc)), + ) + + var ch async.EventChannel // nil channel; SendEvent is a no-op. + sources, err := m.CollectSources(context.Background(), ch) + if err != nil { + t.Fatalf("CollectSources: %v", err) + } + + got := make([]string, 0, len(sources)) + for _, src := range sources { + got = append(got, src.ID()) + } + + // Sorted by ID, and family only once even though both providers depend on + // it. Sorting is what makes this assertable: the project dependencies are + // collected concurrently, so a shared transitive dependency would otherwise + // land under whichever goroutine claimed it first. + want := []string{ + "xpkg://" + family + ":v1.0.0", + "xpkg://" + provA + ":v0.1.0", + "xpkg://" + provB + ":v0.2.0", + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("source IDs (-want +got):\n%s", diff) + } + + if got := fc.getCount(family + ":v1.0.0"); got != 1 { + t.Errorf("fetch count for %s = %d, want 1", family+":v1.0.0", got) + } +} + +func TestManager_CollectSources_RangeAndExactCollapse(t *testing.T) { + // A package reached once through a constraint and once through an exact + // version is still one package. claim() records the reference as written, + // before Resolve canonicalizes it, so both spellings pass it and produce + // two sources with the same ID - which makes the merged pass generate from + // the same CRDs twice. + const ( + provA = "xpkg.example/prov-a" + family = "xpkg.example/family" + digest = "sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03" + ) + + fc := &fakeClient{ + packages: map[string]*runtimexpkg.Package{ + provA + ":v0.1.0": makePackageWithBody(t, provA, digest, "", fmt.Sprintf(providerDependsOnPackageYAML, family, "v1.0.0")), + family + ":v1.0.0": makePackageWithBody(t, family, digest, "", providerPackageYAML), + }, + tagsByRepo: map[string][]string{ + provA: {"v0.1.0"}, + family: {"v1.0.0"}, + }, + } + + m := NewManager( + &v1alpha1.Project{ + Spec: v1alpha1.ProjectSpec{ + Dependencies: []v1alpha1.Dependency{ + // Depends on family:v1.0.0 through its metadata. + *xpkgDep(provA, "v0.1.0"), + // And the project names the same package by constraint. + *xpkgDep(family, ">=v1.0.0"), + }, + Paths: &v1alpha1.ProjectPaths{Schemas: "schemas"}, + }, + }, + afero.NewMemMapFs(), + WithSchemaFS(afero.NewMemMapFs()), + WithSchemaGenerators([]generator.Interface{}), + WithXpkgClient(fc), + WithResolver(clixpkg.NewResolver(fc)), + ) + + var ch async.EventChannel // nil channel; SendEvent is a no-op. + sources, err := m.CollectSources(context.Background(), ch) + if err != nil { + t.Fatalf("CollectSources: %v", err) + } + + got := make([]string, 0, len(sources)) + for _, src := range sources { + got = append(got, src.ID()) + } + + want := []string{ + "xpkg://" + family + ":v1.0.0", + "xpkg://" + provA + ":v0.1.0", + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("source IDs (-want +got):\n%s", diff) + } + + if got := fc.getCount(family + ":v1.0.0"); got != 1 { + t.Errorf("fetch count for %s = %d, want 1", family+":v1.0.0", got) + } +} + func TestManager_AddAll_SharedTransitiveDep(t *testing.T) { const ( provA = "xpkg.example/prov-a" diff --git a/internal/project/build.go b/internal/project/build.go index 81de2ca7..29643786 100644 --- a/internal/project/build.go +++ b/internal/project/build.go @@ -262,19 +262,22 @@ func (b *Builder) Build(ctx context.Context, project *devv1alpha1.Project, proje } o.eventCh.SendEvent("Collecting resources", async.EventStatusSuccess) - // Generate schemas for declared dependencies. The dependency manager - // short-circuits sources whose recorded version still matches, so this is - // cheap on the steady-state path. - if b.dependencyManager != nil { - if err := b.dependencyManager.AddAll(ctx, o.eventCh); err != nil { - return nil, errors.Wrap(err, "failed to generate dependency schemas") + // Collect all schema sources (dependencies + local APIs) and generate + // schemas in a single pass, so the generated package's root index covers + // every group rather than only the source that ran last. + if b.schemaManager != nil { + var allSources []manager.Source + if b.dependencyManager != nil { + depSources, err := b.dependencyManager.CollectSources(ctx, o.eventCh) + if err != nil { + return nil, errors.Wrap(err, "cannot load schemas from project dependencies; check that each dependency is reachable and contains valid API definitions") + } + allSources = append(allSources, depSources...) } - } + allSources = append(allSources, manager.NewFSSource(project.Spec.Paths.APIs, apisSource)) - // Generate language-specific schemas from XRDs. - if b.schemaManager != nil { o.eventCh.SendEvent("Generating schemas", async.EventStatusStarted) - if _, err := b.schemaManager.Generate(ctx, manager.NewFSSource(project.Spec.Paths.APIs, apisSource)); err != nil { + if err := b.schemaManager.GenerateFromMultipleSources(ctx, allSources); err != nil { o.eventCh.SendEvent("Generating schemas", async.EventStatusFailure) return nil, errors.Wrap(err, "failed to generate schemas") } diff --git a/internal/schemas/manager/lock.go b/internal/schemas/manager/lock.go index e4387d97..b9eaaa89 100644 --- a/internal/schemas/manager/lock.go +++ b/internal/schemas/manager/lock.go @@ -19,8 +19,19 @@ package manager const lockFileName = ".lock.json" // lock tracks the versions of sources whose schemas are present in the -// manager. It is persisted to the manager's filesystem. +// manager, and the languages those schemas were generated for. type lock struct { + // Languages the schemas on disk were generated for, sorted. Adding a + // language leaves every source version untouched, so without this nothing + // would notice it had been added. + Languages []string `json:"languages,omitempty"` + + // FromMergedPass records that the language directories hold the output of a + // merged pass over exactly the sources in Packages. Set only by + // recordGeneration and cleared by any single-source write, which overwrites + // part of that tree while leaving every recorded version matching. + FromMergedPass bool `json:"fromMergedPass,omitempty"` + Packages map[string]string `json:"packages"` } diff --git a/internal/schemas/manager/manager.go b/internal/schemas/manager/manager.go index 3fe60e7c..551917cd 100644 --- a/internal/schemas/manager/manager.go +++ b/internal/schemas/manager/manager.go @@ -20,8 +20,11 @@ package manager import ( "context" "encoding/json" + "fmt" "io/fs" "path/filepath" + "slices" + "strings" "sync" "github.com/invopop/jsonschema" @@ -182,6 +185,14 @@ func jsonBuildIndexSchema(langFS afero.Fs) error { return afero.WriteFile(langFS, metaFile, bs, 0o644) } +// currentLock returns the persisted lock. +func (m *Manager) currentLock() (*lock, error) { + m.lockMu.RLock() + defer m.lockMu.RUnlock() + + return m.getLock() +} + func (m *Manager) currentVersion(id string) (string, error) { m.lockMu.RLock() defer m.lockMu.RUnlock() @@ -204,6 +215,10 @@ func (m *Manager) updateVersion(id, version string) error { } l.Packages[id] = version + // This pass wrote into the language directories from one source, so what is + // on disk is no longer the merged tree the versions in Packages describe. + // See lock.FromMergedPass. + l.FromMergedPass = false return m.updateLock(l) } @@ -246,6 +261,305 @@ func (m *Manager) updateLock(l *lock) error { return nil } +// GenerateFromMultipleSources generates schemas from multiple sources at once. +// TypeScript needs this: crd-generate emits one npm package per run, whose root +// index.js re-exports every API group it saw and whose _schemas directory is a +// single flat namespace. Both describe the whole run, so generating per source +// rewrites them for that source alone and the last run wins, leaving models on +// disk that nothing can import. +// Sources with the same SourceType are merged before generation. +func (m *Manager) GenerateFromMultipleSources(ctx context.Context, sources []Source) error { + if len(sources) == 0 { + return nil + } + + // Group sources by type + crdSources := make([]Source, 0) + openAPISources := make([]Source, 0) + for _, src := range sources { + switch src.Type() { + case SourceTypeCRD: + crdSources = append(crdSources, src) + case SourceTypeOpenAPI: + openAPISources = append(openAPISources, src) + default: + return errors.Errorf("cannot generate schemas for source %q: source type %q is not supported; use a CRD or OpenAPI source", src.ID(), src.Type()) + } + } + + // One freshness decision covering every source, not one per group. The + // language directories are cleared before generating, so a partial + // regeneration would delete models it is not going to rewrite. + fresh, versions, err := m.mergedSourcesFresh(ctx, sources) + if err != nil { + return err + } + if fresh { + return nil + } + + // Copying never removes, so clear first or a renamed kind leaves its model + // behind. The lock is only written on success, so a failure here regenerates. + if err := m.clearLanguageDirs(); err != nil { + return err + } + + // Generate from CRD sources (merged) + if len(crdSources) > 0 { + if err := m.generateFromMergedSources(ctx, crdSources, SourceTypeCRD); err != nil { + return errors.Wrap(err, "failed to generate schemas from CRD sources") + } + } + + // Generate from OpenAPI sources (merged) + if len(openAPISources) > 0 { + if err := m.generateFromMergedSources(ctx, openAPISources, SourceTypeOpenAPI); err != nil { + return errors.Wrap(err, "failed to generate schemas from OpenAPI sources") + } + } + + return m.recordGeneration(versions, m.languages()) +} + +// clearLanguageDirs removes the generated tree for each language this manager +// generates, so that the next generation writes a tree containing only what the +// current sources describe. +func (m *Manager) clearLanguageDirs() error { + langs := m.languages() + + // Also clear languages the lock records but this pass will not generate: a + // dropped language is absent from m.languages(), so nothing else removes it. + recorded, err := m.currentLock() + if err != nil { + return err + } + for _, lang := range recorded.Languages { + if !slices.Contains(langs, lang) { + langs = append(langs, lang) + } + } + + for _, lang := range langs { + if err := m.fs.RemoveAll(lang); err != nil { + return errors.Wrapf(err, "failed to clear generated %s schemas", lang) + } + } + return nil +} + +// generateFromMergedSources merges one group of same-typed sources and +// generates from them. Freshness, clearing and recording the result belong to +// GenerateFromMultipleSources, which owns the whole cycle. +func (m *Manager) generateFromMergedSources(ctx context.Context, sources []Source, sourceType SourceType) error { + mergedFS, err := m.collectSourceResources(ctx, sources) + if err != nil { + return err + } + + schemas, err := m.runGenerators(ctx, mergedFS, sourceType) + if err != nil { + return err + } + + return m.copyGeneratedSchemas(schemas) +} + +// collectSourceResources merges resources from all sources into a single +// filesystem. Version bookkeeping belongs to the caller, which has already +// computed each source's version to decide whether to generate at all. +func (m *Manager) collectSourceResources(ctx context.Context, sources []Source) (afero.Fs, error) { + mergedFS := afero.NewMemMapFs() + + for i, src := range sources { + srcFS, err := src.Resources(ctx) + if err != nil { + return nil, 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 := fmt.Sprintf("%04d_%s", i, sanitizeSourceID(src.ID())) + prefixedFS := afero.NewBasePathFs(mergedFS, prefix) + if err := filesystem.CopyFilesBetweenFs(srcFS, prefixedFS); err != nil { + return nil, errors.Wrapf(err, "failed to copy resources from source %s", src.ID()) + } + } + + return mergedFS, nil +} + +// mergedSourcesFresh reports whether the schemas on disk are correct for these +// sources, returning the versions it computed either way. Merged generation is +// all-or-nothing, so one stale source regenerates all of them. +func (m *Manager) mergedSourcesFresh(ctx context.Context, sources []Source) (bool, map[string]string, error) { + versions := make(map[string]string, len(sources)) + fresh := true + + for _, src := range sources { + version, err := src.Version(ctx) + if err != nil { + return false, nil, errors.Wrapf(err, "failed to get version for source %s", src.ID()) + } + versions[src.ID()] = version + + existing, err := m.currentVersion(src.ID()) + if err != nil { + return false, nil, err + } + if existing != version { + fresh = false + } + } + if !fresh { + return false, versions, nil + } + + recorded, err := m.currentLock() + if err != nil { + return false, nil, err + } + if !slices.Equal(recorded.Languages, m.languages()) { + return false, versions, nil + } + + // Every version matching is not enough: a single-source pass records its + // version in the same map while overwriting part of the merged tree, so the + // lock can describe these exact sources and still not describe what is on + // disk. Only a merged pass may be trusted to have produced it. + if !recorded.FromMergedPass { + return false, versions, nil + } + + // Every current source matched above, so the lock holding more entries than + // there are sources means one was removed from the project. Its models are + // still on disk and nothing else would notice, because what remains is all + // current. + if len(recorded.Packages) != len(versions) { + return false, versions, nil + } + + // The lock can outlive its output: a partly deleted schemas tree would + // otherwise read as fresh and leave the build with no models at all. + for _, lang := range m.languages() { + ok, err := afero.DirExists(m.fs, lang) + if err != nil { + return false, nil, err + } + if !ok { + return false, versions, nil + } + } + + return true, versions, nil +} + +// languages returns the sorted language identifiers this manager generates for. +func (m *Manager) languages() []string { + langs := make([]string, 0, len(m.generators)) + for _, g := range m.generators { + langs = append(langs, g.Language()) + } + slices.Sort(langs) + return langs +} + +// recordGeneration writes the source versions and language set in one lock +// update. versions must be the complete set, not a subset: it replaces what the +// lock held so a removed dependency stops being recorded. +func (m *Manager) recordGeneration(versions map[string]string, languages []string) error { + m.lockMu.Lock() + defer m.lockMu.Unlock() + + l, err := m.getLock() + if err != nil { + return err + } + l.Packages = versions + l.Languages = languages + l.FromMergedPass = true + + return m.updateLock(l) +} + +// runGenerators runs all generators on the merged filesystem and returns the generated schemas. +func (m *Manager) runGenerators(ctx context.Context, mergedFS afero.Fs, sourceType SourceType) (map[string]afero.Fs, error) { + schemas := make(map[string]afero.Fs) + var schemasMu sync.Mutex + eg, egCtx := errgroup.WithContext(ctx) + + for _, gen := range m.generators { + eg.Go(func() error { + schemaFS, err := m.runGenerator(egCtx, gen, mergedFS, sourceType) + if err != nil { + return err + } + if schemaFS != nil { + schemasMu.Lock() + schemas[gen.Language()] = schemaFS + schemasMu.Unlock() + } + return nil + }) + } + + if err := eg.Wait(); err != nil { + return nil, err + } + + return schemas, nil +} + +// runGenerator runs a single generator on the merged filesystem. +func (m *Manager) runGenerator(ctx context.Context, gen generator.Interface, mergedFS afero.Fs, sourceType SourceType) (afero.Fs, error) { + switch sourceType { + case SourceTypeCRD: + return gen.GenerateFromCRD(ctx, mergedFS, m.runner) + case SourceTypeOpenAPI: + return gen.GenerateFromOpenAPI(ctx, mergedFS, m.runner) + default: + return nil, errors.Errorf("unsupported source type %q", sourceType) + } +} + +// copyGeneratedSchemas copies generated schemas to the schema repository. +func (m *Manager) copyGeneratedSchemas(schemas map[string]afero.Fs) error { + for lang, genFS := range schemas { + langFS := afero.NewBasePathFs(m.fs, lang) + + // Try to copy from models/ subdirectory first (generators put output there) + modelsFS := afero.NewBasePathFs(genFS, "models") + hasModels := false + if fi, err := modelsFS.Stat("."); err == nil && fi.IsDir() { + hasModels = true + } + + if hasModels { + if err := filesystem.CopyFilesBetweenFs(modelsFS, langFS); err != nil { + return err + } + } else { + if err := filesystem.CopyFilesBetweenFs(genFS, langFS); err != nil { + return err + } + } + + if err := postProcessForLanguage(lang, langFS); err != nil { + return err + } + } + return nil +} + +// sanitizeSourceID converts a source ID to a safe directory name. +func sanitizeSourceID(id string) string { + // Replace characters that are problematic in filesystem paths + result := id + for _, c := range []string{"://", ":", "/", "@"} { + result = strings.ReplaceAll(result, c, "_") + } + return result +} + // New returns an initialized manager. func New(fs afero.Fs, gens []generator.Interface, r runner.SchemaRunner) *Manager { return &Manager{ diff --git a/internal/schemas/manager/manager_test.go b/internal/schemas/manager/manager_test.go index 62e1b18c..69a8f110 100644 --- a/internal/schemas/manager/manager_test.go +++ b/internal/schemas/manager/manager_test.go @@ -19,6 +19,8 @@ package manager import ( "context" "io/fs" + "slices" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -226,8 +228,9 @@ func (g *mockGenerator) GenerateFromOpenAPI(_ context.Context, _ afero.Fs, _ run } type mockSource struct { - id string - version string + id string + version string + resources map[string]string } func (s *mockSource) ID() string { @@ -239,9 +242,173 @@ func (s *mockSource) Version(_ context.Context) (string, error) { } func (s *mockSource) Resources(_ context.Context) (afero.Fs, error) { - return nil, nil + if s.resources == nil { + return nil, nil + } + fs := afero.NewMemMapFs() + for path, contents := range s.resources { + if err := afero.WriteFile(fs, path, []byte(contents), 0o600); err != nil { + return nil, err + } + } + return fs, nil } func (s *mockSource) Type() SourceType { return SourceTypeCRD } + +// indexingGenerator writes one index file naming every resource it was handed. +// That is what makes a merged pass distinguishable from a single-source one: the +// merged pass sees every source at once so its index names them all, while a +// single-source pass overwrites that same file with only its own. The real +// TypeScript generator has exactly this shape - a root index enumerating every +// group - which is why the bug below is visible there and not in JSON. +type indexingGenerator struct{ lang string } + +func (g *indexingGenerator) Language() string { + if g.lang == "" { + return "mock" + } + return g.lang +} + +func (g *indexingGenerator) GenerateFromCRD(_ context.Context, in afero.Fs, _ runner.SchemaRunner) (afero.Fs, error) { + var names []string + if in != nil { + err := afero.Walk(in, ".", func(_ string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + names = append(names, info.Name()) + } + return nil + }) + if err != nil { + return nil, err + } + } + slices.Sort(names) + + out := afero.NewMemMapFs() + if err := afero.WriteFile(out, "index", []byte(strings.Join(names, ",")), 0o600); err != nil { + return nil, err + } + return out, nil +} + +func (g *indexingGenerator) GenerateFromOpenAPI(_ context.Context, _ afero.Fs, _ runner.SchemaRunner) (afero.Fs, error) { + return nil, nil +} + +func readMockIndex(t *testing.T, testFS afero.Fs) string { + t.Helper() + + bs, err := afero.ReadFile(testFS, "mock/index") + if err != nil { + t.Fatalf("read generated index: %v", err) + } + return string(bs) +} + +// A single-source write must not leave a stale tree reading as fresh. +// +// lock.Packages serves both callers: the merged pass replaces the whole map, +// while Add writes one entry into it. So after a dependency is added, every +// recorded version matches its source while the tree on disk is the one the +// single-source pass overwrote. A freshness check that trusts Packages alone +// skips the next merged pass and leaves the user with the clobbered tree, with +// no way back short of deleting the lock by hand. +func TestMergedPassRegeneratesAfterSingleSourceWrite(t *testing.T) { + t.Parallel() + + ctx := t.Context() + testFS := afero.NewMemMapFs() + m := New(testFS, []generator.Interface{&indexingGenerator{}}, nil) + + a := &mockSource{id: "xpkg://a", version: "v1", resources: map[string]string{"a.yaml": "a"}} + b := &mockSource{id: "xpkg://b", version: "v1", resources: map[string]string{"b.yaml": "b"}} + c := &mockSource{id: "xpkg://c", version: "v1", resources: map[string]string{"c.yaml": "c"}} + + if err := m.GenerateFromMultipleSources(ctx, []Source{a, b}); err != nil { + t.Fatal(err) + } + if got, want := readMockIndex(t, testFS), "a.yaml,b.yaml"; got != want { + t.Fatalf("after the merged pass, index = %q, want %q", got, want) + } + + // What `crossplane dependency add` does: one source, straight through + // Generate, overwriting the merged index with only its own entry. + if err := m.Add(ctx, c); err != nil { + t.Fatal(err) + } + if got, want := readMockIndex(t, testFS), "c.yaml"; got != want { + t.Fatalf("after the single-source add, index = %q, want %q; this test's premise no longer holds", got, want) + } + + // The build that follows has to rebuild the index rather than trust the lock. + if err := m.GenerateFromMultipleSources(ctx, []Source{a, b, c}); err != nil { + t.Fatal(err) + } + if got, want := readMockIndex(t, testFS), "a.yaml,b.yaml,c.yaml"; got != want { + t.Errorf("after the merged pass that follows a single-source write, index = %q, want %q", got, want) + } +} + +// A language dropped from spec.schemas.languages must not leave its tree +// behind. Nothing else would ever remove it: it is gone from the generator set, +// so it is absent from m.languages(), which is what the clearing iterated. +// +// That is not cosmetic. The TypeScript function builder gates on whether the +// language directory exists, so an orphaned tree stays load-bearing - a project +// with a hand-added function keeps building against models no pass will update +// again, exit 0 and no warning. +func TestRemovedLanguageDirIsCleared(t *testing.T) { + t.Parallel() + + ctx := t.Context() + testFS := afero.NewMemMapFs() + src := &mockSource{id: "xpkg://a", version: "v1", resources: map[string]string{"a.yaml": "a"}} + + both := New(testFS, []generator.Interface{&indexingGenerator{}, &indexingGenerator{lang: "other"}}, nil) + if err := both.GenerateFromMultipleSources(ctx, []Source{src}); err != nil { + t.Fatal(err) + } + for _, lang := range []string{"mock", "other"} { + ok, err := afero.DirExists(testFS, lang) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatalf("%s schemas were not generated, so this test proves nothing", lang) + } + } + + // The same project with "other" removed from spec.schemas.languages. + one := New(testFS, []generator.Interface{&indexingGenerator{}}, nil) + if err := one.GenerateFromMultipleSources(ctx, []Source{src}); err != nil { + t.Fatal(err) + } + + orphaned, err := afero.DirExists(testFS, "other") + if err != nil { + t.Fatal(err) + } + if orphaned { + t.Error("schemas for the removed language are still on disk") + } + + // The language the project still generates for is intact. + if got, want := readMockIndex(t, testFS), "a.yaml"; got != want { + t.Errorf("index for the remaining language = %q, want %q", got, want) + } + + l, err := one.currentLock() + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff([]string{"mock"}, l.Languages); diff != "" { + t.Errorf("recorded languages (-want +got):\n%s", diff) + } +} From 918985d1a44ee5cb3cd23609c94d25351b8b31d8 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Fri, 18 Sep 2026 10:07:56 +0100 Subject: [PATCH 2/5] Add a TypeScript schema generator Generates TypeScript models from project XRDs and dependency CRDs via kubernetes-models/crd-generate. Excluded from the default language set because it requires Node.js, so both render call sites now filter generators by the project's languages rather than running all of them. Signed-off-by: Steven Borrelli Co-Authored-By: Claude Opus 5 --- apis/dev/v1alpha1/project_types.go | 14 +- cmd/crossplane/dependency/add.go | 41 +- cmd/crossplane/render/op/cmd.go | 9 +- cmd/crossplane/render/xr/cmd.go | 9 +- internal/schemas/generator/interface.go | 35 +- internal/schemas/generator/interface_test.go | 10 +- .../typescript-toolchain/package-lock.json | 1249 +++++++++++++++++ .../typescript-toolchain/package.json | 29 + internal/schemas/generator/typescript.go | 481 +++++++ 9 files changed, 1859 insertions(+), 18 deletions(-) create mode 100644 internal/schemas/generator/typescript-toolchain/package-lock.json create mode 100644 internal/schemas/generator/typescript-toolchain/package.json create mode 100644 internal/schemas/generator/typescript.go diff --git a/apis/dev/v1alpha1/project_types.go b/apis/dev/v1alpha1/project_types.go index c696e97a..2971728d 100644 --- a/apis/dev/v1alpha1/project_types.go +++ b/apis/dev/v1alpha1/project_types.go @@ -49,10 +49,11 @@ const ( // ProjectSchemas.Languages. Each corresponds to a schema generator in // internal/schemas/generator. const ( - SchemaLanguageGo = "go" - SchemaLanguageJSON = "json" - SchemaLanguageKCL = "kcl" - SchemaLanguagePython = "python" + SchemaLanguageGo = "go" + SchemaLanguageJSON = "json" + SchemaLanguageKCL = "kcl" + SchemaLanguagePython = "python" + SchemaLanguageTypescript = "typescript" ) // SupportedSchemaLanguages returns the set of language identifiers accepted @@ -63,6 +64,7 @@ func SupportedSchemaLanguages() []string { SchemaLanguageJSON, SchemaLanguageKCL, SchemaLanguagePython, + SchemaLanguageTypescript, } } @@ -133,8 +135,8 @@ type ProjectPackageMetadata struct { // produced both for the project's own XRDs and for its declared dependencies. type ProjectSchemas struct { // Languages restricts schema generation to the listed languages. - // Supported values are "go", "json", "kcl", and "python". If not - // specified, schemas are generated for all supported languages. + // If not specified, schemas are generated for all supported languages. + // +kubebuilder:validation:items:Enum=go;json;kcl;python;typescript Languages []string `json:"languages,omitempty"` } diff --git a/cmd/crossplane/dependency/add.go b/cmd/crossplane/dependency/add.go index 6424f473..06d49841 100644 --- a/cmd/crossplane/dependency/add.go +++ b/cmd/crossplane/dependency/add.go @@ -18,7 +18,9 @@ package dependency import ( "context" + "fmt" "path/filepath" + "slices" "strings" "github.com/google/go-containerregistry/pkg/name" @@ -108,9 +110,44 @@ func (c *addCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *con desc := dependency.GetSourceDescription(dep) logger.Debug("Adding dependency", "dependency", desc) - return sp.WrapWithSuccessSpinner("Adding "+desc, func() error { + if err := sp.WrapWithSuccessSpinner("Adding "+desc, func() error { return m.AddDependency(ctx, &dep) - }) + }); err != nil { + return err + } + + if note := schemaLanguageNote(dep, proj.Spec.Schemas.GetLanguages()); note != "" { + fmt.Println(note) //nolint:forbidigo // CLI output. + } + + return nil +} + +// schemaLanguageNote returns a note for a dependency that will not produce +// models in one of the languages the project asked for, or "" when there is +// nothing to say. +// +// A Kubernetes API dependency is described by an OpenAPI spec rather than by +// CRDs, and the TypeScript generator reads CRDs. It produces nothing for such a +// source and says nothing, so a user who has seen Python and Go generate +// bindings for the Kubernetes API reasonably expects the same and finds out +// otherwise when an import fails. This is the moment that expectation forms, +// which is why the note lives here rather than at build time. +// +// Nothing is wrong: TypeScript functions get typed built-ins from the +// kubernetes-models package, which the function scaffold already depends on, so +// generating them would duplicate it. +func schemaLanguageNote(dep v1alpha1.Dependency, langs []string) string { + if dep.Type != v1alpha1.DependencyTypeK8s { + return "" + } + if !slices.Contains(langs, v1alpha1.SchemaLanguageTypescript) { + return "" + } + + return "Note: TypeScript models are not generated from Kubernetes API dependencies. " + + "Import built-in types from the kubernetes-models package instead, for example " + + "`import { Deployment } from 'kubernetes-models/apps/v1'`." } func (c *addCmd) buildDependency() (v1alpha1.Dependency, error) { diff --git a/cmd/crossplane/render/op/cmd.go b/cmd/crossplane/render/op/cmd.go index 2274af1d..81ded6d1 100644 --- a/cmd/crossplane/render/op/cmd.go +++ b/cmd/crossplane/render/op/cmd.go @@ -357,9 +357,12 @@ func (c *Cmd) loadFunctions(ctx context.Context, log logging.Logger, sp terminal // Built here rather than alongside the schema manager below so the // dependency manager generates dependency schemas the same way. - generators := generator.AllLanguages( - generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors), - generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects), + generators := generator.Filter( + generator.AllLanguages( + generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors), + generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects), + ), + proj.Spec.Schemas.GetLanguages(), ) depMgr := dependency.NewManager(proj, projFS, diff --git a/cmd/crossplane/render/xr/cmd.go b/cmd/crossplane/render/xr/cmd.go index 0a41121d..26747473 100644 --- a/cmd/crossplane/render/xr/cmd.go +++ b/cmd/crossplane/render/xr/cmd.go @@ -469,9 +469,12 @@ func (c *Cmd) loadFunctionsFromProject(ctx context.Context, log logging.Logger, return nil, err } - generators := generator.AllLanguages( - generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors), - generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects), + generators := generator.Filter( + generator.AllLanguages( + generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors), + generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects), + ), + proj.Spec.Schemas.GetLanguages(), ) depMgr := dependency.NewManager(proj, projFS, diff --git a/internal/schemas/generator/interface.go b/internal/schemas/generator/interface.go index d26519d2..351a5cd5 100644 --- a/internal/schemas/generator/interface.go +++ b/internal/schemas/generator/interface.go @@ -24,9 +24,18 @@ import ( "github.com/spf13/afero" + devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" "github.com/crossplane/cli/v2/internal/schemas/runner" ) +// Constants used by the TypeScript generator. Shared here rather than inlined +// so the repeated literals stay under the goconst threshold. +const ( + workDir = "workdir" + extYAML = ".yaml" + extYML = ".yml" +) + // Interface generates schemas for a specific language. type Interface interface { Language() string @@ -71,15 +80,37 @@ func AllLanguages(opts ...Option) []Interface { &jsonGenerator{}, &kclGenerator{}, &pythonGenerator{}, + &typescriptGenerator{}, + } +} + +// DefaultLanguages returns the languages generated for when a project does not +// set spec.schemas.languages. Exported so callers can tell a user which +// languages they will actually get, rather than treating an unset list as +// permitting anything. +func DefaultLanguages() []string { + return defaultLanguages() +} + +// defaultLanguages returns the languages generated when none are requested +// explicitly. TypeScript is excluded because it requires Node.js and npm, +// which adds significant build time. Users can enable it by explicitly +// listing "typescript" in schemas.languages. +func defaultLanguages() []string { + return []string{ + devv1alpha1.SchemaLanguageGo, + devv1alpha1.SchemaLanguageJSON, + devv1alpha1.SchemaLanguageKCL, + devv1alpha1.SchemaLanguagePython, } } // Filter returns the subset of generators whose language identifier appears // in langs. The order of generators in the result matches the order of all. -// If langs is empty, all generators are returned unchanged. +// If langs is empty, the default generators are returned (excluding TypeScript). func Filter(all []Interface, langs []string) []Interface { if len(langs) == 0 { - return all + langs = defaultLanguages() } out := make([]Interface, 0, len(all)) for _, g := range all { diff --git a/internal/schemas/generator/interface_test.go b/internal/schemas/generator/interface_test.go index e48a353d..192dbbff 100644 --- a/internal/schemas/generator/interface_test.go +++ b/internal/schemas/generator/interface_test.go @@ -106,8 +106,14 @@ func TestFilter(t *testing.T) { want []string }{ "Empty": { - // An empty filter returns all languages unchanged. - want: devv1alpha1.SupportedSchemaLanguages(), + // An empty filter returns the default languages (excluding TypeScript, + // which requires explicit opt-in due to its Node.js dependency). + want: []string{ + devv1alpha1.SchemaLanguageGo, + devv1alpha1.SchemaLanguageJSON, + devv1alpha1.SchemaLanguageKCL, + devv1alpha1.SchemaLanguagePython, + }, }, "SingleLanguage": { langs: []string{devv1alpha1.SchemaLanguagePython}, diff --git a/internal/schemas/generator/typescript-toolchain/package-lock.json b/internal/schemas/generator/typescript-toolchain/package-lock.json new file mode 100644 index 00000000..cdd83a56 --- /dev/null +++ b/internal/schemas/generator/typescript-toolchain/package-lock.json @@ -0,0 +1,1249 @@ +{ + "name": "crossplane-models", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crossplane-models", + "version": "0.0.0", + "dependencies": { + "@kubernetes-models/apimachinery": "3.0.2", + "@kubernetes-models/base": "6.0.1" + }, + "devDependencies": { + "@kubernetes-models/crd-generate": "6.1.1", + "typescript": "5.9.3" + } + }, + "node_modules/@gar/promise-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", + "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@kubernetes-models/apimachinery": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@kubernetes-models/apimachinery/-/apimachinery-3.0.2.tgz", + "integrity": "sha512-6Vbzr/tinxPBGGdfYjXiV9WPj8PEhyiqGJiWhXrsihK7h6glPj4HKUxZ0YwYXH/5uucm/NVef/xY3vZ4Crj1Fg==", + "license": "MIT", + "dependencies": { + "@kubernetes-models/base": "^6.0.0", + "@kubernetes-models/validate": "^5.0.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@kubernetes-models/base": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@kubernetes-models/base/-/base-6.0.1.tgz", + "integrity": "sha512-pXKCFeSoL6RsMxoPDwaJ5JtijVjeuNCacgtYS9dImF7qFdzdc9lcQbem6y6Ug7/K6/bFOWdTu3qIX88IK81o0A==", + "license": "MIT", + "dependencies": { + "@kubernetes-models/validate": "^5.0.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@kubernetes-models/crd-generate": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@kubernetes-models/crd-generate/-/crd-generate-6.1.1.tgz", + "integrity": "sha512-0Jc+x7GjWNZGcLk+Pdp8aos/qyI/7WGTeLZClkuNbELiGHYFZkFMPLr4IYwB9tz4ujGxa2ARwXJLUkayko4nJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@kubernetes-models/generate": "^3.1.1", + "@kubernetes-models/read-input": "^4.0.1", + "@kubernetes-models/string-util": "^4.0.1", + "es-toolkit": "^1.46.0", + "yaml": "^2.2.2", + "yargs": "^18.0.0" + }, + "bin": { + "crd-generate": "bin/crd-generate.js" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@kubernetes-models/generate": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@kubernetes-models/generate/-/generate-3.2.0.tgz", + "integrity": "sha512-CMF0h0N/5VJ7zpkuiFa7+Hi94krFVf4idC+d1FkynUT/4p8X5JbDvEC+GzzbYhOg3gBCTU6NdDH+kEoWhCVfng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@kubernetes-models/string-util": "^4.0.1", + "@kubernetes-models/validate": "^5.0.2", + "ajv": "^8.12.0", + "es-toolkit": "^1.46.0", + "indent-string": "^5.0.0", + "ohash": "^2.0.11", + "p-map": "^7.0.4", + "re2-wasm": "^1.0.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@kubernetes-models/read-input": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@kubernetes-models/read-input/-/read-input-4.0.1.tgz", + "integrity": "sha512-Q92FPRmM6YSnLMXS+UGfbJ/j2qMJzx3cjNttNkqz3q7M4CudtepXmgB5ZS54OPCHY8PJ1lOa8OZ+PQhiqn2SMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-cache-directory": "^6.0.0", + "get-stdin": "^10.0.0", + "make-fetch-happen": "^15.0.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@kubernetes-models/string-util": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@kubernetes-models/string-util/-/string-util-4.0.1.tgz", + "integrity": "sha512-raOnQucFvVfilR35Ffw5atUwIIJ8DNKZ7U/maSfUB9kOlxTN5sb5NOhUB8198wHLTJASPnpNJmoxvq7gms6/WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@kubernetes-models/validate": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@kubernetes-models/validate/-/validate-5.0.2.tgz", + "integrity": "sha512-EzfxB8mu2VPlFYSwsMlZdgTeeqJGI2R58irUrZJWHxJqOqOhnau/oS9KNCZKXjdC2vFubU6v81m2CrIoU+/pxQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "ajv-formats-draft2019": "^1.6.1", + "ajv-i18n": "^4.2.0", + "is-cidr": "^6.0.4" + }, + "engines": { + "node": ">=22" + }, + "optionalDependencies": { + "re2-wasm": "^1.0.2" + } + }, + "node_modules/@npmcli/agent": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.2.tgz", + "integrity": "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats-draft2019": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ajv-formats-draft2019/-/ajv-formats-draft2019-1.6.1.tgz", + "integrity": "sha512-JQPvavpkWDvIsBp2Z33UkYCtXCSpW4HD3tAZ+oL4iEFOk9obQZffx0yANwECt6vzr6ET+7HN5czRyqXbnq/u0Q==", + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1", + "schemes": "^1.4.0", + "smtp-address-parser": "^1.0.3", + "uri-js": "^4.4.1" + }, + "peerDependencies": { + "ajv": "*" + } + }, + "node_modules/ajv-i18n": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ajv-i18n/-/ajv-i18n-4.2.0.tgz", + "integrity": "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.0-beta.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacache": { + "version": "20.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", + "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/cidr-regex": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/cidr-regex/-/cidr-regex-5.0.5.tgz", + "integrity": "sha512-59tdLZcC+BJXa4C5rOmVSuJTy/UneqfJJtCraqwdx5BDHTkGrBtKCUl3u2uiCFvXu+wk0kVuX8axX7yHCZOI9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "dev": true, + "license": "ISC" + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/find-cache-directory": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/find-cache-directory/-/find-cache-directory-6.0.0.tgz", + "integrity": "sha512-CvFd5ivA6HcSHbD+59P7CyzINHXzwhuQK8RY7CxJZtgDSAtRlHiCaQpZQ2lMR/WRyUIEmzUvL6G2AGurMfegZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "common-path-prefix": "^3.0.0", + "pkg-dir": "^8.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stdin": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-10.0.0.tgz", + "integrity": "sha512-eWSePJ4zXFdqz+/Lyfopob4rIcoF/U2XfE8nJc7iZV6lnebWc9k7DoQQpX+2a9jc0AOvBsXvbe5YkjXl/MHbpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-cidr": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/is-cidr/-/is-cidr-6.0.4.tgz", + "integrity": "sha512-tOIBU3QiXy0W4LvHbcKWAWSuQfGwDiEILphFCAZtDqj7C57uv3ClO6K8aNEGV4VTA7bWJlpQ0suKQkUe6Rd6ag==", + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^5.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-fetch-happen": { + "version": "15.0.6", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", + "integrity": "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "license": "BSD-3-Clause" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-map": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.7.tgz", + "integrity": "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pkg-dir": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-8.0.0.tgz", + "integrity": "sha512-4peoBq4Wks0riS0z8741NVv+/8IiTvqnZAr8QGgtdifrtpdXbNw/FxRS1l6NFqm4EMzuS0EDqNNx4XGaz8cuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "license": "CC0-1.0" + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "license": "MIT", + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/re2-wasm": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/re2-wasm/-/re2-wasm-1.0.2.tgz", + "integrity": "sha512-VXUdgSiUrE/WZXn6gUIVVIsg0+Hp6VPZPOaHCay+OuFKy6u/8ktmeNEf+U5qSA8jzGGFsg8jrDNu1BeHpz2pJA==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/schemes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/schemes/-/schemes-1.4.0.tgz", + "integrity": "sha512-ImFy9FbCsQlVgnE3TCWmLPCFnVzx0lHL/l+umHplDqAKd0dzFpnS6lFZIpagBlYhKwzVmlV36ec0Y1XTu8JBAQ==", + "license": "MIT", + "dependencies": { + "extend": "^3.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/smtp-address-parser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/smtp-address-parser/-/smtp-address-parser-1.1.0.tgz", + "integrity": "sha512-Gz11jbNU0plrReU9Sj7fmshSBxxJ9ShdD2q4ktHIHo/rpTH6lFyQoYHYKINPJtPe8aHFnsbtW46Ls0tCCBsIZg==", + "license": "MIT", + "dependencies": { + "nearley": "^2.20.1" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + } + } +} diff --git a/internal/schemas/generator/typescript-toolchain/package.json b/internal/schemas/generator/typescript-toolchain/package.json new file mode 100644 index 00000000..bd0c338f --- /dev/null +++ b/internal/schemas/generator/typescript-toolchain/package.json @@ -0,0 +1,29 @@ +{ + "name": "crossplane-models", + "version": "0.0.0", + "type": "module", + "main": "index.js", + "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + }, + "./*": { + "types": "./*/index.d.ts", + "default": "./*/index.js" + } + }, + "dependencies": { + "@kubernetes-models/apimachinery": "3.0.2", + "@kubernetes-models/base": "6.0.1" + }, + "devDependencies": { + "@kubernetes-models/crd-generate": "6.1.1", + "typescript": "5.9.3" + }, + "crd-generate": { + "input": ["./all-crds.yaml"], + "output": "./gen" + } +} diff --git a/internal/schemas/generator/typescript.go b/internal/schemas/generator/typescript.go new file mode 100644 index 00000000..7639caf2 --- /dev/null +++ b/internal/schemas/generator/typescript.go @@ -0,0 +1,481 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package generator + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io/fs" + "path" + "path/filepath" + "slices" + "strings" + + "github.com/spf13/afero" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + xpv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/crd" + "github.com/crossplane/cli/v2/internal/schemas/runner" + + _ "embed" +) + +const ( + typescriptModelsFolder = "models" + // typescriptImage is the Docker image used to run crd-generate. Pinned to + // an exact tag: the toolchain is installed from a lockfile, so a floating + // Node would leave generated output dependent on when it was generated. + typescriptImage = "docker.io/library/node:24.20.0-slim" +) + +// The toolchain that turns CRDs into TypeScript models is pinned by a +// committed package.json and package-lock.json rather than resolved at +// generation time, so the same CLI produces the same models. Renovate keeps +// the pair current; see the typescript-toolchain rule in renovate.json5. +// +//go:embed typescript-toolchain/package.json +var typescriptToolchainPackageJSON []byte + +//go:embed typescript-toolchain/package-lock.json +var typescriptToolchainPackageLock []byte + +type typescriptGenerator struct{} + +func (typescriptGenerator) Language() string { + return devv1alpha1.SchemaLanguageTypescript +} + +// GenerateFromCRD generates TypeScript schema files from the XRDs and CRDs in fromFS. +// It uses @kubernetes-models/crd-generate to produce proper TypeScript classes +// with constructors, interfaces, and runtime validation. +func (t typescriptGenerator) GenerateFromCRD(ctx context.Context, fromFS afero.Fs, r runner.SchemaRunner) (afero.Fs, error) { + // Collect all CRD YAML files into a working filesystem + workFS := afero.NewMemMapFs() + crdsDir := "crds" + + if err := workFS.MkdirAll(crdsDir, 0o755); err != nil { + return nil, errors.Wrap(err, "failed to create crds directory") + } + + crdCount, err := t.collectCRDs(fromFS, workFS, crdsDir) + if err != nil { + return nil, err + } + + if crdCount == 0 { + return nil, nil + } + + return t.generateFromCRDFiles(ctx, workFS, crdsDir, r) +} + +// GenerateFromOpenAPI is not supported for TypeScript - use GenerateFromCRD instead. +// The crd-generate tool requires CRD YAML files, not OpenAPI specs. +func (t typescriptGenerator) GenerateFromOpenAPI(_ context.Context, _ afero.Fs, _ runner.SchemaRunner) (afero.Fs, error) { + // crd-generate works with CRD YAML files, not OpenAPI specs. + // Return nil to indicate no schemas were generated. + return nil, nil +} + +// collectCRDs walks the input filesystem and collects all CRD YAML files into +// the working filesystem. XRDs are converted to CRDs using the crd package. +// Returns the number of CRDs collected. +func (t typescriptGenerator) collectCRDs(fromFS, workFS afero.Fs, crdsDir string) (int, error) { + // Temporary filesystem for XRD processing + xrdFS := afero.NewMemMapFs() + xrdBaseFolder := workDir + if err := xrdFS.MkdirAll(xrdBaseFolder, 0o755); err != nil { + return 0, errors.Wrap(err, "cannot prepare TypeScript schema generation workspace") + } + + crdCount := 0 + + err := afero.Walk(fromFS, "", func(path string, info fs.FileInfo, err error) error { + if err != nil { + return errors.Wrapf(err, "cannot read %q while collecting API definitions for TypeScript models", path) + } + + if info.IsDir() { + return nil + } + + // Only process YAML files + ext := filepath.Ext(path) + if ext != extYAML && ext != extYML { + return nil + } + + bs, err := afero.ReadFile(fromFS, path) + if err != nil { + return errors.Wrapf(err, "failed to read file %q", path) + } + + var u metav1.TypeMeta + if err := yaml.Unmarshal(bs, &u); err != nil { + return errors.Wrapf(err, "failed to parse file %q", path) + } + + switch u.GroupVersionKind().Kind { + case xpv1.CompositeResourceDefinitionKind: + n, err := t.processXRDFile(xrdFS, workFS, bs, path, xrdBaseFolder, crdsDir) + if err != nil { + return err + } + crdCount += n + + case "CustomResourceDefinition": + if err := t.processCRDFile(workFS, bs, path, crdsDir); err != nil { + return err + } + crdCount++ + } + + return nil + }) + + return crdCount, err +} + +// processXRDFile converts an XRD to CRDs and writes them to the working filesystem. +// Returns the number of CRDs written. +func (t typescriptGenerator) processXRDFile(xrdFS, workFS afero.Fs, bs []byte, path, xrdBaseFolder, crdsDir string) (int, error) { + xrPath, claimPath, err := crd.ProcessXRD(xrdFS, bs, path, xrdBaseFolder) + if err != nil { + return 0, errors.Wrapf(err, "cannot convert XRD %q to CRDs for TypeScript models; check that the XRD is valid", path) + } + + count := 0 + + if xrPath != "" { + if err := copyGeneratedCRD(xrdFS, workFS, xrPath, crdsDir, path, "xrd"); err != nil { + return 0, err + } + count++ + } + + if claimPath != "" { + if err := copyGeneratedCRD(xrdFS, workFS, claimPath, crdsDir, path, "claim"); err != nil { + return 0, err + } + count++ + } + + return count, nil +} + +// copyGeneratedCRD copies a generated CRD file from the XRD filesystem to the working filesystem. +func copyGeneratedCRD(xrdFS, workFS afero.Fs, srcPath, crdsDir, origPath, suffix string) error { + crdBS, err := afero.ReadFile(xrdFS, srcPath) + if err != nil { + return errors.Wrapf(err, "failed to read generated CRD %q", srcPath) + } + outPath := filepath.Join(crdsDir, stagedCRDPath(origPath, suffix)) + if err := afero.WriteFile(workFS, outPath, crdBS, 0o644); err != nil { + return errors.Wrapf(err, "failed to write CRD %q", outPath) + } + return nil +} + +// processCRDFile validates and writes a CRD file to the working filesystem. +func (t typescriptGenerator) processCRDFile(workFS afero.Fs, bs []byte, path, crdsDir string) error { + // Validate it's a proper CRD before copying + var c extv1.CustomResourceDefinition + if err := yaml.Unmarshal(bs, &c); err != nil { + return errors.Wrapf(err, "failed to unmarshal CRD file %q", path) + } + + // Write the CRD to the crds directory + outPath := filepath.Join(crdsDir, stagedCRDPath(path, "")) + if err := afero.WriteFile(workFS, outPath, bs, 0o644); err != nil { + return errors.Wrapf(err, "failed to write CRD %q", outPath) + } + return nil +} + +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 +} + +// generateFromCRDFiles runs crd-generate on the collected CRD files and +// produces TypeScript models with proper classes and validation. +func (t typescriptGenerator) generateFromCRDFiles(ctx context.Context, workFS afero.Fs, crdsDir string, r runner.SchemaRunner) (afero.Fs, error) { + // Concatenate all CRD files into a single YAML file. + // The npm published version of @kubernetes-models/read-input only supports + // individual files, not directories. + allCRDsFile := "all-crds.yaml" + var allCRDs []byte + err := afero.Walk(workFS, crdsDir, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + ext := filepath.Ext(path) + if ext != extYAML && ext != extYML { + return nil + } + content, err := afero.ReadFile(workFS, path) + if err != nil { + return errors.Wrapf(err, "failed to read CRD file %q", path) + } + if len(allCRDs) > 0 { + allCRDs = append(allCRDs, []byte("\n---\n")...) + } + allCRDs = append(allCRDs, content...) + return nil + }) + if err != nil { + return nil, errors.Wrap(err, "failed to collect CRD files") + } + if err := afero.WriteFile(workFS, allCRDsFile, allCRDs, 0o644); err != nil { + return nil, errors.Wrap(err, "failed to write combined CRD file") + } + + // Stage the pinned toolchain manifest and lockfile so the container can + // install with npm ci rather than resolving version ranges at runtime. + if err := afero.WriteFile(workFS, "package.json", typescriptToolchainPackageJSON, 0o644); err != nil { + return nil, errors.Wrap(err, "failed to write toolchain package.json") + } + if err := afero.WriteFile(workFS, "package-lock.json", typescriptToolchainPackageLock, 0o644); err != nil { + return nil, errors.Wrap(err, "failed to write toolchain package-lock.json") + } + + // Run crd-generate in a container. + // The script: + // 1. Installs the pinned toolchain from the staged lockfile + // 2. Runs crd-generate to produce TypeScript source + // 3. Compiles TypeScript to JavaScript + if err := r.Generate( + ctx, + workFS, + ".", + "", + typescriptImage, + []string{ + "sh", "-c", + `set -eu + +# Install the pinned toolchain. package.json and package-lock.json are staged +# by the generator, so npm ci installs exactly the locked tree and fails if the +# two ever disagree. +npm ci --no-audit --no-fund + +# Run crd-generate (reads config from package.json) +npx crd-generate + +# Create tsconfig.json for compilation. We deliberately don't emit sourceMap or +# declarationMap: only dist/ ships in the models package, so every map would +# point at a gen/*.ts source that isn't there, and tools that read maps (test +# runners, bundlers) would warn once per generated type. +cat > tsconfig.json << 'TSEOF' +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "rootDir": "gen", + "outDir": "dist" + }, + "include": ["gen/**/*.ts"] +} +TSEOF + +# Compile TypeScript to JavaScript +npx tsc + +# Copy generated files to models directory for output +mkdir -p models +cp -r dist/* models/ + +# crd-generate emits _schemas/ as pre-compiled JS (not TypeScript), so tsc does +# not process it and it never appears in dist/. Copy it directly from gen/. +if [ -d gen/_schemas ]; then + cp -r gen/_schemas models/ +fi + +# Update package.json for distribution (remove devDependencies and crd-generate config) +cat > models/package.json << 'DISTEOF' +{ + "name": "crossplane-models", + "version": "0.0.0", + "type": "module", + "main": "index.js", + "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + }, + "./*": { + "types": "./*/index.d.ts", + "default": "./*/index.js" + } + }, + "dependencies": { + "@kubernetes-models/apimachinery": "^3.0.2", + "@kubernetes-models/base": "^6.0.1" + } +} +DISTEOF +`, + }, + ); err != nil { + return nil, errors.Wrap(err, "failed to install npm dependencies and generate TypeScript schemas; see npm output above for details") + } + + // Create output filesystem and copy the models directory + schemaFS := afero.NewMemMapFs() + + // Check if models directory was created + exists, err := afero.DirExists(workFS, typescriptModelsFolder) + if err != nil { + return nil, errors.Wrap(err, "failed to check models directory") + } + if !exists { + // No TypeScript files were generated + return schemaFS, nil + } + + // Copy all files from models/ to the output filesystem + err = afero.Walk(workFS, typescriptModelsFolder, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return schemaFS.MkdirAll(path, 0o755) + } + + content, err := afero.ReadFile(workFS, path) + if err != nil { + return errors.Wrapf(err, "failed to read %s", path) + } + + return afero.WriteFile(schemaFS, path, content, 0o644) + }) + if err != nil { + return nil, errors.Wrap(err, "failed to copy generated TypeScript files") + } + + if err := stampModelsVersion(schemaFS); err != nil { + return nil, err + } + + return schemaFS, nil +} + +// stampModelsVersion replaces the generated package's placeholder version with +// one derived from the content of the generated files. +// +// The scaffold sets install-links=true, so models are copied into node_modules +// rather than symlinked, and npm treats a file: dependency as satisfied while +// its spec is unchanged. A content-derived version is what lets `npm update` +// pick up regenerated models; with a constant 0.0.0 it does not. +func stampModelsVersion(fsys afero.Fs) error { + pkgPath := path.Join(typescriptModelsFolder, "package.json") + + digest, err := hashModels(fsys, pkgPath) + if err != nil { + return err + } + + bs, err := afero.ReadFile(fsys, pkgPath) + if err != nil { + // No manifest means nothing was generated, so there is nothing to stamp. + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return errors.Wrap(err, "failed to read generated models package.json") + } + + var pkg map[string]any + if err := json.Unmarshal(bs, &pkg); err != nil { + return errors.Wrap(err, "generated models package.json is not valid JSON") + } + + // A semver prerelease identifier, so the value stays a valid version that + // npm will compare and order. + pkg["version"] = "0.0.0-" + digest + + out, err := json.MarshalIndent(pkg, "", " ") + if err != nil { + return errors.Wrap(err, "failed to serialize generated models package.json") + } + + return errors.Wrap(afero.WriteFile(fsys, pkgPath, append(out, '\n'), 0o644), "failed to write generated models package.json") +} + +// hashModels returns a digest over every generated file except the manifest, +// which is excluded because its own content depends on the result. +func hashModels(fsys afero.Fs, skip string) (string, error) { + var paths []string + if err := afero.Walk(fsys, typescriptModelsFolder, func(p string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() || p == skip { + return nil + } + paths = append(paths, p) + return nil + }); err != nil { + return "", errors.Wrap(err, "failed to walk generated TypeScript files") + } + + // Walk order is not guaranteed across filesystem implementations, and the + // digest has to be stable for identical content. + slices.Sort(paths) + + h := sha256.New() + for _, p := range paths { + content, err := afero.ReadFile(fsys, p) + if err != nil { + return "", errors.Wrapf(err, "failed to read %s", p) + } + // Include the path so that moving content between files changes the + // digest. + _, _ = h.Write([]byte(p)) + _, _ = h.Write(content) + } + + return hex.EncodeToString(h.Sum(nil))[:12], nil +} From 548880ba70b18097337a27ce0c98828b9f2d89b9 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Fri, 18 Sep 2026 10:07:56 +0100 Subject: [PATCH 3/5] Build TypeScript composition functions Runs npm install and npm run build in a Node.js build container, then copies dist/ and node_modules/ onto a distroless Node.js base running as nonroot. Runtime dependencies are installed once per target architecture so packages shipping per-platform binaries resolve for the image they ship in. Signed-off-by: Steven Borrelli Co-Authored-By: Claude Opus 5 --- internal/project/functions/build.go | 2 + internal/project/functions/build_test.go | 7 + internal/project/functions/typescript.go | 320 +++++++++++++++++++++++ 3 files changed, 329 insertions(+) create mode 100644 internal/project/functions/typescript.go diff --git a/internal/project/functions/build.go b/internal/project/functions/build.go index cd2795db..784171d7 100644 --- a/internal/project/functions/build.go +++ b/internal/project/functions/build.go @@ -53,6 +53,8 @@ func (realIdentifier) Identify(fromFS afero.Fs, imageConfigs []pkgv1beta1.ImageC newPythonBuilder(imageConfigs), newGoBuilder(imageConfigs), newGoTemplatingBuilder(imageConfigs), + // TypeScript is checked last since package.json can appear in other project types. + newTypescriptBuilder(imageConfigs), } for _, b := range builders { ok, err := b.match(fromFS) diff --git a/internal/project/functions/build_test.go b/internal/project/functions/build_test.go index 4c90a9fc..0117a185 100644 --- a/internal/project/functions/build_test.go +++ b/internal/project/functions/build_test.go @@ -92,6 +92,13 @@ func TestIdentify(t *testing.T) { }, expectedBuilder: &goTemplatingBuilder{}, }, + "TypeScript": { + files: map[string]string{ + "package.json": "{}", + "tsconfig.json": "{}", + }, + expectedBuilder: &typescriptBuilder{}, + }, "GoTemplatingInvalidFiles": { files: map[string]string{ "template1.gotmpl": "", diff --git a/internal/project/functions/typescript.go b/internal/project/functions/typescript.go new file mode 100644 index 00000000..0a47d1f3 --- /dev/null +++ b/internal/project/functions/typescript.go @@ -0,0 +1,320 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package functions + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "path" + "path/filepath" + "strings" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/spf13/afero" + "golang.org/x/sync/errgroup" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + + "github.com/crossplane/cli/v2/internal/docker" + "github.com/crossplane/cli/v2/internal/filesystem" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +const ( + // typescriptBuildImage is the image in which we build the function. + typescriptBuildImage = "docker.io/library/node:24.20.0-slim" + // typescriptRuntimeImage is the distroless base used at runtime. The + // :nonroot variant, so the built function does not serve gRPC as root. + typescriptRuntimeImage = "gcr.io/distroless/nodejs24-debian13:nonroot" + // typescriptBuildScript runs in the build container. SCHEMAS_PATH is the + // generated schemas path or empty; ARCHS is the target architectures in + // npm's naming (see npmArchitecture). + typescriptBuildScript = `set -eu +# Install the schemas package first so TypeScript can resolve the base types. +if [ -n "$SCHEMAS_PATH" ] && [ -d "$SCHEMAS_PATH" ] && [ -f "$SCHEMAS_PATH/package.json" ]; then + cd "$SCHEMAS_PATH" && npm install --no-fund + cd - +fi +# Build tree, installed for the build container's own architecture because the +# TypeScript 7 compiler is a native binary. Throwaway, so --legacy-peer-deps +# tolerates lint and test tooling that still caps its typescript peer below 7. +npm install --no-fund --legacy-peer-deps +npm run build +# Drop devDependencies outright: --omit=dev still resolves them when building the +# ideal tree, so an unsatisfiable peer range would fail the runtime install. +node -e 'const f="package.json",p=require("./"+f);delete p.devDependencies;require("fs").writeFileSync(f,JSON.stringify(p,null,2)+"\n")' + +# Reinstall runtime dependencies per architecture, in place so file: +# dependencies keep resolving relative to the function directory. --cpu/--os +# steer optional-dependency selection only; they do not cross-compile. +for arch in $ARCHS ; do + rm -rf node_modules + npm install --omit=dev --no-fund --cpu=$arch --os=linux + mkdir -p /fn_$arch + # -L so file: dependencies are copied as files, not links that break at runtime. + cp -rL node_modules /fn_$arch/ + cp -r dist /fn_$arch/ + # package.json ships for its "type": "module", minus file: dependencies, whose + # paths do not exist in the image. + node -e 'const p=require("./package.json");const d=p.dependencies||{};for(const k of Object.keys(d))if(String(d[k]).startsWith("file:"))delete d[k];process.stdout.write(JSON.stringify(p,null,2)+"\n")' > /fn_$arch/package.json +done +` +) + +// typescriptBuilder builds TypeScript composition functions. It runs npm +// install and npm run build in a Node.js build container, then copies dist/ and +// node_modules/ onto a distroless Node.js base, installing the runtime +// dependencies once per target architecture. +type typescriptBuilder struct { + buildImage string + runtimeImage string + transport http.RoundTripper + configStore xpkg.ConfigStore +} + +func (b *typescriptBuilder) Name() string { + return "typescript" +} + +func (b *typescriptBuilder) match(fromFS afero.Fs) (bool, error) { + hasPackageJSON, err := afero.Exists(fromFS, "package.json") + if err != nil { + return false, err + } + hasTSConfig, err := afero.Exists(fromFS, "tsconfig.json") + if err != nil { + return false, err + } + return hasPackageJSON && hasTSConfig, nil +} + +func (b *typescriptBuilder) Build(ctx context.Context, c BuildContext) ([]v1.Image, error) { + if err := docker.Check(ctx); err != nil { + return nil, errors.Wrap(err, "cannot build the TypeScript function because Docker is unavailable; start or install Docker, then retry") + } + + functionTars, err := b.buildFunction(ctx, c) + if err != nil { + return nil, err + } + + runtimeImage := b.runtimeImage + _, rewritten, err := b.configStore.RewritePath(ctx, b.runtimeImage) + if err != nil { + return nil, errors.Wrap(err, "failed to rewrite runtime image") + } + if rewritten != "" { + runtimeImage = rewritten + } + + runtimeRef, err := name.ParseReference(runtimeImage) + if err != nil { + return nil, errors.Wrap(err, "failed to parse typescript runtime base image") + } + + images := make([]v1.Image, len(c.Architectures)) + eg, _ := errgroup.WithContext(ctx) + for i, arch := range c.Architectures { + eg.Go(func() error { + baseImg, err := baseImageForArch(runtimeRef, arch, b.transport, c.BaseImageCacheDir) + if err != nil { + return errors.Wrap(err, "failed to fetch typescript runtime base image") + } + + functionLayer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(functionTars[arch])), nil + }) + if err != nil { + return errors.Wrap(err, "failed to create function layer") + } + + img, err := mutate.AppendLayers(baseImg, functionLayer) + if err != nil { + return errors.Wrap(err, "failed to append function layer") + } + + img, err = configureTypescriptImage(img, arch) + if err != nil { + return errors.Wrap(err, "failed to configure typescript image") + } + + images[i] = img + return nil + }) + } + + return images, eg.Wait() +} + +// buildFunction runs the build container against the function source and +// returns tars of /fn_ for each architecture, suitable for use as image +// layers. +// +// The function source is staged at / and, if a typescript schemas +// tree exists, //typescript/models/ — preserving the project's +// relative layout so that npm resolves the schemas path-dep from package.json. +// +//nolint:contextcheck // The defer uses context.Background() intentionally for cleanup. +func (b *typescriptBuilder) buildFunction(ctx context.Context, c BuildContext) (map[string][]byte, error) { + fnFS := c.FunctionFS() + // Exclude node_modules the user might have created locally. + // Use the function path as the tar prefix so files end up at / in the container. + fnTar, err := filesystem.FSToTar(fnFS, c.FunctionPath, filesystem.WithExcludePrefix("node_modules")) + if err != nil { + return nil, errors.Wrap(err, "failed to tar function source") + } + + // Check if TypeScript schemas exist and tar them if so. + // The schemas are placed at //typescript/ to match + // the relative path in package.json (e.g., "file:../../schemas/typescript"). + tsSchemasRel := path.Join(c.SchemasPath, "typescript") + tsSchemasFS := afero.NewBasePathFs(c.ProjectFS, tsSchemasRel) + hasTSSchemas, err := afero.DirExists(tsSchemasFS, ".") + if err != nil { + return nil, errors.Wrapf(err, "cannot check for TypeScript schemas at %q", tsSchemasRel) + } + 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 + } + + // The build runs in the function's original path so that relative deps + // resolve, and leaves one /fn_ tree per target architecture. + fnPath := "/" + filepath.ToSlash(c.FunctionPath) + var tsSchemasPath string + if hasTSSchemas { + tsSchemasPath = "/" + filepath.ToSlash(tsSchemasRel) + } + + npmArchitectures := make([]string, len(c.Architectures)) + for i, a := range c.Architectures { + npmArchitectures[i], err = npmArchitecture(a) + if err != nil { + return nil, err + } + } + + opts := []docker.StartContainerOption{ + docker.StartWithCopyFiles(fnTar, "/"), + docker.StartWithEnv( + "ARCHS="+strings.Join(npmArchitectures, " "), + "SCHEMAS_PATH="+tsSchemasPath, + ), + docker.StartWithCommand([]string{"sh", "-c", typescriptBuildScript}), + docker.StartWithWorkingDirectory(fnPath), + } + if schemasTar != nil { + opts = append(opts, docker.StartWithCopyFiles(schemasTar, "/")) + } + + cid, err := docker.StartContainer(ctx, "", buildImage, opts...) + if err != nil { + return nil, errors.Wrap(err, "failed to start typescript build container") + } + defer func() { + // Use context.Background() so container cleanup happens even if ctx is cancelled. + _ = docker.StopContainerByID(context.Background(), cid) + }() + + if err := docker.WaitForContainerByID(ctx, cid); err != nil { + return nil, errors.Wrap(err, "typescript build container failed") + } + + ret := make(map[string][]byte, len(c.Architectures)) + for _, arch := range c.Architectures { + npmArch, _ := npmArchitecture(arch) // Ignore the error since we already did this once. + ret[arch], err = docker.TarFromContainer(ctx, cid, fmt.Sprintf("/fn_%s", npmArch)) + if err != nil { + return nil, errors.Wrapf(err, "failed to retrieve built function for architecture %s", arch) + } + } + + return ret, nil +} + +// npmArchitecture maps an OCI architecture to the name npm expects for its +// --cpu flag, which follows Node's process.arch naming. +func npmArchitecture(a string) (string, error) { + switch a { + case "amd64": + return "x64", nil + case "arm64": + return "arm64", nil + default: + return "", errors.Errorf("unable to determine npm architecture for architecture %s", a) + } +} + +// configureTypescriptImage sets the runtime configuration on the final image: +// the user, the function entrypoint and the gRPC port. The working directory is +// the architecture's own /fn_ tree, so that Node resolves the node_modules +// built for this architecture. +func configureTypescriptImage(img v1.Image, arch string) (v1.Image, error) { + cfgFile, err := img.ConfigFile() + if err != nil { + return nil, errors.Wrap(err, "failed to get config file") + } + cfg := cfgFile.Config + + npmArch, err := npmArchitecture(arch) + if err != nil { + return nil, err + } + cfg.Entrypoint = []string{"/nodejs/bin/node", "dist/main.js"} + cfg.Cmd = nil + cfg.WorkingDir = fmt.Sprintf("/fn_%s", npmArch) + // Set explicitly as well as selecting the :nonroot base, so an image + // rewritten through spec.imageConfigs cannot quietly reintroduce root. + // Matches the python builder. + cfg.User = "nonroot:nonroot" + if cfg.ExposedPorts == nil { + cfg.ExposedPorts = map[string]struct{}{} + } + cfg.ExposedPorts["9443/tcp"] = struct{}{} + + return mutate.Config(img, cfg) +} + +func newTypescriptBuilder(imageConfigs []pkgv1beta1.ImageConfig) *typescriptBuilder { + return &typescriptBuilder{ + buildImage: typescriptBuildImage, + runtimeImage: typescriptRuntimeImage, + transport: http.DefaultTransport, + configStore: clixpkg.NewStaticImageConfigStore(imageConfigs), + } +} From 4a8798c4ca153e0bbda9111b3789274e24199900 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Fri, 18 Sep 2026 10:07:56 +0100 Subject: [PATCH 4/5] Scaffold TypeScript functions crossplane function generate --language typescript writes a function-sdk-typescript project that builds, tests and lints with no edits. Generating for a language the project does not produce schemas for is an error rather than a broken scaffold. Signed-off-by: Steven Borrelli Co-Authored-By: Claude Opus 5 --- .github/renovate.json5 | 30 +++++ cmd/crossplane/function/generate.go | 92 +++++++++++-- cmd/crossplane/function/generate_test.go | 122 ++++++++++++++++++ cmd/crossplane/function/help/generate.md | 1 + .../function/templates/typescript/.npmrc | 9 ++ .../function/templates/typescript/README.md | 113 ++++++++++++++++ .../templates/typescript/eslint.config.js | 26 ++++ .../templates/typescript/package.json.tmpl | 33 +++++ .../templates/typescript/src/function.test.ts | 27 ++++ .../templates/typescript/src/function.ts | 45 +++++++ .../function/templates/typescript/src/main.ts | 6 + .../templates/typescript/tsconfig.eslint.json | 5 + .../templates/typescript/tsconfig.json | 21 +++ 13 files changed, 519 insertions(+), 11 deletions(-) create mode 100644 cmd/crossplane/function/templates/typescript/.npmrc create mode 100644 cmd/crossplane/function/templates/typescript/README.md create mode 100644 cmd/crossplane/function/templates/typescript/eslint.config.js create mode 100644 cmd/crossplane/function/templates/typescript/package.json.tmpl create mode 100644 cmd/crossplane/function/templates/typescript/src/function.test.ts create mode 100644 cmd/crossplane/function/templates/typescript/src/function.ts create mode 100644 cmd/crossplane/function/templates/typescript/src/main.ts create mode 100644 cmd/crossplane/function/templates/typescript/tsconfig.eslint.json create mode 100644 cmd/crossplane/function/templates/typescript/tsconfig.json diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 2d87e28b..570994ca 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -45,6 +45,22 @@ schedule: [], }, customManagers: [ + { + customType: 'regex', + description: 'Bump the container images the schema generators run in', + // Scoped to the generator package on purpose. The function builders in + // internal/project/functions also declare image constants, but those are + // deliberately floating bases for user function images (for example + // distroless nodejs24-debian13, whose tag is not a version), so bumping + // them automatically would be wrong. + managerFilePatterns: [ + '/^internal/schemas/generator/.*\\.go$/', + ], + matchStrings: [ + 'Image\\s*=\\s*"(?[^":]+):(?[^"]+)"', + ], + datasourceTemplate: 'docker', + }, { customType: 'regex', description: 'Bump the Renovate version used by the config validator and the bot', @@ -148,6 +164,20 @@ ], enabled: false, }, + { + // The TypeScript schema generator installs this tree with npm ci inside a + // container, so package.json and package-lock.json have to move together + // or the install fails. Grouping keeps them in one reviewable PR, and + // each bump changes generated model output, so these are worth reading. + description: 'Group updates to the pinned TypeScript schema generator toolchain', + matchManagers: [ + 'npm', + ], + matchFileNames: [ + 'internal/schemas/generator/typescript-toolchain/package.json', + ], + groupName: 'typescript schema generator toolchain', + }, { description: 'Group all go version updates', matchDatasources: [ diff --git a/cmd/crossplane/function/generate.go b/cmd/crossplane/function/generate.go index 3d1fc20a..386a0099 100644 --- a/cmd/crossplane/function/generate.go +++ b/cmd/crossplane/function/generate.go @@ -49,6 +49,12 @@ import ( "github.com/crossplane/cli/v2/internal/terminal" ) +// Function language constants. +const ( + langGoTemplating = "go-templating" + langPython = "python" +) + //go:embed help/generate.md var generateHelp string @@ -59,6 +65,8 @@ var ( pythonTemplates embed.FS //go:embed templates/go-templating/* goTemplatingTemplates embed.FS + //go:embed all:templates/typescript + typescriptTemplates embed.FS // The go template contains a go.mod, so we can't embed it as an // embed.FS. Instead we have to embed it as a tar archive and extract it @@ -70,7 +78,7 @@ var ( type generateCmd struct { Name string `arg:"" help:"Name of the function to generate. Must be a valid DNS-1035 label."` PipelinePath string `arg:"" help:"Path to a Composition YAML file to add a pipeline step to." optional:""` - Language string `default:"go-templating" enum:"go,go-templating,kcl,python" help:"Language to use for the function." short:"l"` + Language string `default:"go-templating" enum:"go,go-templating,kcl,python,typescript" help:"Language to use for the function." short:"l"` ProjectFile string `default:"${project_file}" help:"Path to project definition file." short:"f"` projFS afero.Fs @@ -120,14 +128,22 @@ func (c *generateCmd) AfterApply() error { // validateLanguageAgainstSchemas refuses to generate a function in a language // whose schemas the project doesn't generate. Such a function would have no // models to import, which is surprising, so we fail up front rather than -// scaffolding a function that can't compile. An empty schemaLangs means the -// project generates all languages (matching generator.Filter), so any function -// language is fine. +// scaffolding a function that can't compile. func validateLanguageAgainstSchemas(functionLang string, schemaLangs []string) error { + required := functionSchemaLanguage(functionLang) + + // An unset list is not permission for anything: it selects a default set, + // which does not include every supported language. Validating against the + // defaults is what stops `function generate --language typescript` on a + // freshly initialised project from scaffolding a function whose models are + // never generated. if len(schemaLangs) == 0 { - return nil + if slices.Contains(generator.DefaultLanguages(), required) { + return nil + } + return errors.Errorf("cannot generate a %q function: this project does not set spec.schemas.languages, so it generates %v schemas and not %q; add %q to spec.schemas.languages", functionLang, generator.DefaultLanguages(), required, required) } - required := functionSchemaLanguage(functionLang) + if !slices.Contains(schemaLangs, required) { return errors.Errorf("cannot generate a %q function: the project only generates %v schemas; add %q to spec.schemas.languages or choose a different language", functionLang, schemaLangs, required) } @@ -138,7 +154,7 @@ func validateLanguageAgainstSchemas(functionLang string, schemaLangs []string) e // the given function language consumes. Most function languages map to a // like-named schema language; go-templating consumes the JSON schema. func functionSchemaLanguage(functionLang string) string { - if functionLang == "go-templating" { + if functionLang == langGoTemplating { return v1alpha1.SchemaLanguageJSON } return functionLang @@ -176,10 +192,11 @@ func (c *generateCmd) Run(sp terminal.SpinnerPrinter, cfg *config.Config) error type generatorFunc func(afero.Fs) error generators := map[string]generatorFunc{ - "go": c.generateGoFiles, - "go-templating": c.generateGoTemplatingFiles, - "kcl": c.generateKCLFiles, - "python": c.generatePythonFiles, + "go": c.generateGoFiles, + langGoTemplating: c.generateGoTemplatingFiles, + "kcl": c.generateKCLFiles, + langPython: c.generatePythonFiles, + "typescript": c.generateTypescriptFiles, } generator, ok := generators[c.Language] @@ -420,6 +437,59 @@ func (c *generateCmd) generateGoTemplatingFiles(fs afero.Fs) error { return renderTemplates(fs, tmpls, tmplData) } +type typescriptTemplateData struct { + Name string + HasSchemas bool + SchemasPath string +} + +func (c *generateCmd) generateTypescriptFiles(targetFS afero.Fs) error { + 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 + } + + // Compute the relative path from the function dir to schemas/typescript/. + fnDir := filepath.Join("/", c.proj.Spec.Paths.Functions, c.Name) + relRoot, err := filepath.Rel(fnDir, "/") + if err != nil { + return errors.Wrap(err, "cannot determine path to schemas directory") + } + schemasPath := filepath.ToSlash(filepath.Join(relRoot, c.proj.Spec.Paths.Schemas, "typescript")) + + data := typescriptTemplateData{ + Name: c.Name, + HasSchemas: hasSchemas, + SchemasPath: schemasPath, + } + + // Parse top-level templates + tmpls, err := template.ParseFS(typescriptTemplates, "templates/typescript/*.*") + if err != nil { + return errors.Wrap(err, "cannot parse top-level TypeScript templates") + } + if err := renderTemplates(targetFS, tmpls, data); err != nil { + return err + } + + // Create src directory and parse src templates + if err := targetFS.Mkdir("src", 0o755); err != nil { + return errors.Wrap(err, "cannot create src directory") + } + tmpls, err = template.ParseFS(typescriptTemplates, "templates/typescript/src/*.*") + if err != nil { + return errors.Wrap(err, "cannot parse TypeScript source templates") + } + return renderTemplates(afero.NewBasePathFs(targetFS, "src"), tmpls, data) +} + func renderTemplates(targetFS afero.Fs, tmpls *template.Template, data any) error { for _, tmpl := range tmpls.Templates() { fname := tmpl.Name() diff --git a/cmd/crossplane/function/generate_test.go b/cmd/crossplane/function/generate_test.go index 94e3f18c..71ceb624 100644 --- a/cmd/crossplane/function/generate_test.go +++ b/cmd/crossplane/function/generate_test.go @@ -18,6 +18,7 @@ package function import ( "bytes" + "encoding/json" "io" "strings" "testing" @@ -233,6 +234,127 @@ func TestGeneratePythonFiles(t *testing.T) { } } +func TestGenerateTypescriptFiles(t *testing.T) { + cases := map[string]struct { + seedSchemas map[string][]byte + wantFiles []string + wantContains map[string][]byte + wantNotContains map[string][]byte + }{ + "NoSchemas": { + wantFiles: []string{ + ".npmrc", + "README.md", + "eslint.config.js", + "package.json", + "tsconfig.json", + "tsconfig.eslint.json", + "src/main.ts", + "src/function.ts", + "src/function.test.ts", + }, + wantContains: map[string][]byte{ + // install-links=true is load-bearing: without it npm symlinks + // the file: models dependency, Node resolves the symlink to a + // path outside node_modules, and every generated import fails + // at runtime. The file reaches the scaffold only because it + // happens to match the templates/typescript/*.* glob, so this + // asserts that it still does — renaming it to npmrc, or + // widening the glob to *, would otherwise drop it silently. + ".npmrc": []byte("install-links=true"), + // The function name is templated into the entrypoint. + "src/main.ts": []byte("serve(compose, { name: 'my-func' })"), + }, + wantNotContains: map[string][]byte{ + "package.json": []byte("crossplane-models"), + }, + }, + "WithSchemas": { + seedSchemas: map[string][]byte{ + "typescript/index.d.ts": nil, + }, + wantContains: map[string][]byte{ + "package.json": []byte(`"crossplane-models": "file:../../schemas/typescript"`), + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c := &generateCmd{ + Name: "my-func", + schemasFS: seedFS(t, tc.seedSchemas), + proj: testProject(), + } + fs := afero.NewMemMapFs() + if err := c.generateTypescriptFiles(fs); err != nil { + t.Fatal(err) + } + assertFiles(t, fs, tc.wantFiles) + assertContains(t, fs, tc.wantContains, tc.wantNotContains) + }) + } +} + +// TestGenerateTypescriptPackageJSON checks that the scaffolded manifest parses +// in both HasSchemas states. The crossplane-models entry sits inside a +// {{- if }} block whose whitespace trimming decides whether the preceding +// comma is still valid, so a template that emits correct JSON in one state can +// emit a trailing comma in the other. A substring assertion would not notice. +func TestGenerateTypescriptPackageJSON(t *testing.T) { + cases := map[string]struct { + seedSchemas map[string][]byte + wantModels bool + }{ + "NoSchemas": {wantModels: false}, + "WithSchemas": {seedSchemas: map[string][]byte{"typescript/index.d.ts": nil}, wantModels: true}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c := &generateCmd{ + Name: "my-func", + schemasFS: seedFS(t, tc.seedSchemas), + proj: testProject(), + } + fs := afero.NewMemMapFs() + if err := c.generateTypescriptFiles(fs); err != nil { + t.Fatal(err) + } + + data, err := afero.ReadFile(fs, "package.json") + if err != nil { + t.Fatal(err) + } + + var pkg struct { + Type string `json:"type"` + Dependencies map[string]string `json:"dependencies"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + t.Fatalf("package.json is not valid JSON: %v\ngot:\n%s", err, data) + } + + // Node needs this to load the compiled output as ESM. + if pkg.Type != "module" { + t.Errorf(`type: got %q, want "module"`, pkg.Type) + } + + if _, ok := pkg.Dependencies["crossplane-models"]; ok != tc.wantModels { + t.Errorf("crossplane-models present: got %v, want %v", ok, tc.wantModels) + } + + // @types/node must track the Node major in the build and runtime + // images (see typescriptBuildImage and typescriptRuntimeImage). + // Types ahead of the runtime let a newer API compile and then fail + // inside the image. Update both together. + if got, want := pkg.Dependencies["@types/node"], "^24.0.0"; got != want { + t.Errorf("@types/node: got %q, want %q", got, want) + } + }) + } +} + func TestGenerateGoFiles(t *testing.T) { cases := map[string]struct { seedSchemas map[string][]byte diff --git a/cmd/crossplane/function/help/generate.md b/cmd/crossplane/function/help/generate.md index 2925b6c5..65bd9cd8 100644 --- a/cmd/crossplane/function/help/generate.md +++ b/cmd/crossplane/function/help/generate.md @@ -11,6 +11,7 @@ The following are valid arguments to the `--language` / `-l` flag: - `go` - `kcl` - `python` +- `typescript` ## Examples diff --git a/cmd/crossplane/function/templates/typescript/.npmrc b/cmd/crossplane/function/templates/typescript/.npmrc new file mode 100644 index 00000000..b93261e3 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/.npmrc @@ -0,0 +1,9 @@ +# The generated crossplane-models package is a file: dependency. By default npm +# symlinks such dependencies, and Node resolves the symlink to its real path — +# which lives outside this function's node_modules, so the models package +# cannot find its own dependencies and importing it fails at runtime with +# "Cannot find package '@kubernetes-models/base'". +# +# install-links copies file: dependencies into node_modules instead, which also +# matches how the function is laid out inside its runtime image. +install-links=true diff --git a/cmd/crossplane/function/templates/typescript/README.md b/cmd/crossplane/function/templates/typescript/README.md new file mode 100644 index 00000000..74156ae9 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/README.md @@ -0,0 +1,113 @@ +# Crossplane Composition Function + +This is a [Crossplane](https://crossplane.io) composition function written in TypeScript. + +## How it works + +`src/function.ts` exports a `compose` function, and `src/main.ts` hands it to the SDK's +`serve`: + +```ts +serve(compose, { name: 'my-function' }); +``` + +`serve` parses the standard function flags, builds a logger from `--debug`, starts the +gRPC server, and shuts down cleanly on `SIGINT` and `SIGTERM`, so the entrypoint needs +nothing else. + +Your `compose` receives the request and a response already built from it, so there is no +`to(req)` call to make. The response type narrows `desired` to non-optional, which means +composed resources are written straight to `rsp.desired.resources` with no `!`: + +```ts +export const compose: ComposeFunction = async (req, rsp, logger) => { + rsp.desired.resources['my-resource'] = /* ... */; + return rsp; +}; +``` + +Returning the response is required, so forgetting it is a compile error rather than an +empty response at runtime. + +To add a composed resource, build a `kubernetes-models` object — including the +`crossplane-models` classes generated from your XRDs — and convert it with `fromModel`: + +```ts +import { VPC } from 'crossplane-models/ec2.aws.m.upbound.io/v1beta1'; + +const vpc = new VPC({ spec: { forProvider: { region: 'us-west-2' } } }); +vpc.validate(); +rsp.desired.resources['my-resource'] = fromModel(vpc); +``` + +## Development + +Install dependencies: + +```shell +npm install +``` + +Build the function: + +```shell +npm run build +``` + +Run locally (for testing): + +```shell +npm run local +``` + +## Testing + +Unit tests run with [Vitest](https://vitest.dev), alongside the code in `src/`: + +```shell +npm test +``` + +Use `fromCompose` to wrap `compose` into a handler the test can call directly: + +```ts +const func = fromCompose(compose); +const rsp = await func.RunFunction(req); +``` + +End to end, render the composition against an example XR: + +```shell +crossplane composition render xr.yaml composition.yaml +``` + +## Linting + +```shell +npm run lint +``` + +## Why there are two TypeScript compilers + +TypeScript 7's native compiler no longer exposes the JavaScript compiler API that +`typescript-eslint` is built on, so the two cannot share one install. `package.json` +therefore aliases both: + +```json +"@typescript/native": "npm:typescript@^7.0.0", +"typescript": "npm:@typescript/typescript6@^6.0.2" +``` + +TypeScript 7 provides the `tsc` binary that `npm run build` uses. TypeScript 6 keeps the +`typescript` package *name*, which is what `typescript-eslint` imports to get the compiler +API — and exposes its own binary as `tsc6`, so the two never collide. `npm run +typecheck:legacy` runs the TypeScript 6 check, which is worth doing once when upgrading +because TypeScript 7 drops deprecated compiler options. + +Remove the alias and go back to a plain `typescript` devDependency once TypeScript 7.1 ships +its programmatic API and `typescript-eslint` adopts it. + +## Learn More + +- [Composition Functions documentation](https://docs.crossplane.io/latest/concepts/composition-functions/) +- [TypeScript Function SDK](https://github.com/crossplane/function-sdk-typescript) diff --git a/cmd/crossplane/function/templates/typescript/eslint.config.js b/cmd/crossplane/function/templates/typescript/eslint.config.js new file mode 100644 index 00000000..77557fb6 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/eslint.config.js @@ -0,0 +1,26 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + + { + languageOptions: { + parserOptions: { + // tsconfig.json excludes tests so they stay out of dist/, but the type + // aware rules still need them in a program, so lint against a config + // that includes everything. + project: './tsconfig.eslint.json', + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + // RunFunction is async because FunctionHandler requires a Promise, not + // because it necessarily awaits anything. + '@typescript-eslint/require-await': 'off', + }, + }, + + { ignores: ['dist/**', 'node_modules/**', '*.config.js'] } +); diff --git a/cmd/crossplane/function/templates/typescript/package.json.tmpl b/cmd/crossplane/function/templates/typescript/package.json.tmpl new file mode 100644 index 00000000..320b9ade --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/package.json.tmpl @@ -0,0 +1,33 @@ +{ + "name": "function", + "version": "0.1.0", + "description": "A Crossplane composition function.", + "license": "Apache-2.0", + "type": "module", + "main": "dist/main.js", + "scripts": { + "build": "tsc", + "typecheck:legacy": "tsc6 --noEmit", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test": "vitest run", + "test:watch": "vitest", + "local": "node dist/main.js --insecure --debug" + }, + "dependencies": { + "@crossplane-org/function-sdk-typescript": "^0.7.0", + "@types/node": "^24.0.0", +{{- if .HasSchemas }} + "crossplane-models": "file:{{ .SchemasPath }}", +{{- end }} + "kubernetes-models": "^5.0.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@typescript/native": "npm:typescript@^7.0.0", + "eslint": "^10.9.1", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "typescript-eslint": "^8.68.0", + "vitest": "^4.1.11" + } +} diff --git a/cmd/crossplane/function/templates/typescript/src/function.test.ts b/cmd/crossplane/function/templates/typescript/src/function.test.ts new file mode 100644 index 00000000..42b6e972 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/src/function.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { fromCompose, RunFunctionRequest } from '@crossplane-org/function-sdk-typescript'; +import { compose } from './function.js'; + +describe('compose', () => { + const func = fromCompose(compose); + + it('composes a response from an observed composite resource', async () => { + const req = RunFunctionRequest.fromJSON({ + observed: { + composite: { + resource: { + apiVersion: 'example.crossplane.io/v1alpha1', + kind: 'Example', + metadata: { name: 'example' }, + spec: {}, + }, + }, + }, + }); + + const rsp = await func.RunFunction(req); + + expect(rsp.desired).toBeDefined(); + expect(rsp.results.map((r) => r.message)).toContain('Function completed successfully'); + }); +}); diff --git a/cmd/crossplane/function/templates/typescript/src/function.ts b/cmd/crossplane/function/templates/typescript/src/function.ts new file mode 100644 index 00000000..bd0a17e2 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/src/function.ts @@ -0,0 +1,45 @@ +import { + type ComposeFunction, + fatal, + getObservedCompositeResource, + normal, +} from '@crossplane-org/function-sdk-typescript'; + +/** + * compose is a Crossplane composition function. + * + * serve() hands us a response already built from the request, so there is no + * to(req) here, and rsp.desired is guaranteed to be present. + */ +export const compose: ComposeFunction = async (req, rsp, logger) => { + try { + // Get the observed composite resource (XR). + const observedComposite = getObservedCompositeResource(req); + logger?.debug({ observedComposite }, 'Observed composite resource'); + + // TODO: Add your function logic here. + // + // Write composed resources straight onto the response. ComposeResponse + // narrows desired to non-optional, so there is no need for rsp.desired!. + // fromModel converts a kubernetes-models object — such as one of the + // classes generated from your XRDs — into a Resource: + // + // import { fromModel } from '@crossplane-org/function-sdk-typescript'; + // import { VPC } from 'crossplane-models/ec2.aws.m.upbound.io/v1beta1'; + // + // const vpc = new VPC({ spec: { forProvider: { region: 'us-west-2' } } }); + // vpc.validate(); + // rsp.desired.resources['my-resource'] = fromModel(vpc); + + normal(rsp, 'Function completed successfully'); + return rsp; + } catch (error) { + logger?.error( + { error: error instanceof Error ? error.message : String(error) }, + 'Function invocation failed' + ); + + fatal(rsp, error instanceof Error ? error.message : String(error)); + return rsp; + } +}; diff --git a/cmd/crossplane/function/templates/typescript/src/main.ts b/cmd/crossplane/function/templates/typescript/src/main.ts new file mode 100644 index 00000000..78bb5b34 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/src/main.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +import { serve } from '@crossplane-org/function-sdk-typescript'; +import { compose } from './function.js'; + +serve(compose, { name: '{{ .Name }}' }); diff --git a/cmd/crossplane/function/templates/typescript/tsconfig.eslint.json b/cmd/crossplane/function/templates/typescript/tsconfig.eslint.json new file mode 100644 index 00000000..09192443 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/cmd/crossplane/function/templates/typescript/tsconfig.json b/cmd/crossplane/function/templates/typescript/tsconfig.json new file mode 100644 index 00000000..0c845587 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/tsconfig.json @@ -0,0 +1,21 @@ +{ + "exclude": ["node_modules", "dist", "**/*.test.ts"], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "module": "nodenext", + "target": "esnext", + "types": ["node"], + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "strict": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true + } +} From d4d3c4c7fda53df9e32414ebaa5c6946b6ec3ac8 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Fri, 18 Sep 2026 10:07:56 +0100 Subject: [PATCH 5/5] Add a TypeScript testing guide Signed-off-by: Steven Borrelli Co-Authored-By: Claude Opus 5 --- docs/typescript-testing-guide.md | 955 +++++++++++++++++++++++++++++++ 1 file changed, 955 insertions(+) create mode 100644 docs/typescript-testing-guide.md diff --git a/docs/typescript-testing-guide.md b/docs/typescript-testing-guide.md new file mode 100644 index 00000000..6075cff2 --- /dev/null +++ b/docs/typescript-testing-guide.md @@ -0,0 +1,955 @@ +# Testing TypeScript Support in Crossplane CLI + +This guide walks through testing the TypeScript support added in PR #170. We'll create a complete +Crossplane configuration project with a TypeScript composition function. + +An example Crossplane project is located at . + +## Prerequisites + +- Go 1.25+ +- The GitHub CLI +- Docker, or an engine that supports `DOCKER_HOST` +- Node.js 24+ (for local development) +- A Kubernetes cluster with Crossplane installed +- Access to push packages to a registry (e.g., `xpkg.upbound.io`) +- (optional) AWS Credentials + +## Step 1: Build the CLI from this PR + +```bash +# Clone the CLI repository +git clone https://github.com/crossplane/cli.git +cd cli + +# Checkout PR #170 +gh pr checkout 170 + +# Build the CLI +go build -o crossplane ./cmd/crossplane + +# Verify the build +./crossplane version +``` + +All subsequent commands should use this locally-compiled version of crossplane. + +## Step 2: Create a New Project + +```bash +# Initialize the project (this creates the directory) +crossplane project init configuration-aws-network-ts \ + --registry xpkg.upbound.io/your-org + +cd configuration-aws-network-ts +``` + +**Porting an existing repository?** `project init` refuses to write into a directory that +isn't empty, including with `-d .`. Scaffold into a scratch directory and copy +`crossplane-project.yaml` plus the `apis/`, `functions/`, `examples/`, `tests/`, and +`operations/` directories over, or just write `crossplane-project.yaml` by hand. + +## Step 3: Configure the Project + +Edit `crossplane-project.yaml` to enable TypeScript schema generation and add dependencies: + +```yaml +apiVersion: dev.crossplane.io/v1alpha1 +kind: Project +metadata: + name: configuration-aws-network-ts +spec: + maintainer: Your Name + repository: xpkg.upbound.io/your-org/configuration-aws-network-ts + # Enable TypeScript schema generation (opt-in) + schemas: + languages: + - typescript + dependencies: + - type: xpkg + xpkg: + apiVersion: pkg.crossplane.io/v1 + kind: Provider + package: xpkg.upbound.io/upbound/provider-aws-ec2 + version: ">=v2.6.0" + - type: xpkg + xpkg: + apiVersion: pkg.crossplane.io/v1 + kind: Function + package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready + version: ">=v0.7.0" +``` + +`apiVersion` and `kind` are required on each `xpkg` dependency. Leaving them out fails +validation on every subsequent command: + +```text +crossplane: error: invalid project file: [dependency 0: xpkg: [apiVersion must not be +empty, kind must not be empty]] +``` + +If you would rather not write them by hand, skip this block and let `crossplane dependency add` +in Step 4 fill the whole section in for you. + +## Step 4: Add Dependencies + +When a dependency is added to a Crossplane project: + +- The package is resolved and cached locally, under `--cache-dir` + (`$CROSSPLANE_XPKG_CACHE`, defaulting to a per-user directory) +- The CLI generates schemas from any CRDs the package contains +- The dependency is recorded in `crossplane-project.yaml` + +No cluster is involved. `dependency add` works before any control plane exists; the dependency is +installed on a control plane later, by `project run` or by applying the built Configuration. + +You can add dependencies using `crossplane dependency add` or by +modifying `crossplane-project.yaml`. + +```bash +# Add the AWS EC2 provider dependency +crossplane dependency add xpkg.upbound.io/upbound/provider-aws-ec2:v2.6.0 + +# Add the auto-ready function +crossplane dependency add xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.7.0 +``` + +### Kubernetes built-in types + +A `k8s` dependency generates no TypeScript models. Those types are described by +an OpenAPI spec rather than by CRDs, and the TypeScript generator reads CRDs — so +unlike Python and Go, adding one produces nothing for TypeScript. `dependency +add` prints a note when you do it. + +That is deliberate rather than a gap. TypeScript functions get typed Kubernetes +built-ins from +[kubernetes-models](https://www.npmjs.com/package/kubernetes-models), which the +function scaffold already depends on: + +```typescript +import { Deployment } from 'kubernetes-models/apps/v1'; +import { Service } from 'kubernetes-models/v1'; +``` + +Generating them would duplicate that package. Provider CRDs, and CRDs fetched +over HTTP or from git, do generate TypeScript models as normal — only the +Kubernetes API itself is affected. + +## Step 5: Create an Example Manifest and the API + +First, create an example XR file that defines your custom resource: + +```bash +mkdir -p examples/network +cat > examples/network/example.yaml << 'EOF' +apiVersion: aws.platform.upbound.io/v1alpha1 +kind: Network +metadata: + name: example-network + namespace: network-team +spec: + region: us-west-2 + cidrBlock: "10.0.0.0/16" +EOF +``` + +Then generate the XRD from the example. This will be our Platform API: + +```bash +# Generate an XRD from the example XR +crossplane xrd generate examples/network/example.yaml +``` + +This writes `apis/networks/definition.yaml` — note the plural directory name. The CLI emits an +`apiextensions.crossplane.io/v2` XRD with no `claimNames`; claims are a v1 concept, and in v2 you +use the XR directly. + +**The scope is inferred from the example.** Because the XR above carries +`metadata.namespace`, the generated XRD gets `scope: Namespaced`. Drop the namespace from the +example and you get `scope: Cluster` instead. This walkthrough is namespaced throughout, which is +the usual choice for a platform API a team consumes inside its own namespace, and it is what +[configuration-aws-network-ts](https://github.com/upbound/configuration-aws-network-ts) does. + +Edit `apis/networks/definition.yaml` to add descriptions, defaults and status fields: + +```yaml +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: networks.aws.platform.upbound.io +spec: + group: aws.platform.upbound.io + names: + categories: + - crossplane + kind: Network + plural: networks + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + region: + type: string + description: AWS region for the network + default: us-west-2 + cidrBlock: + type: string + description: CIDR block for the VPC + default: "10.0.0.0/16" + required: + - region + status: + type: object + properties: + vpcId: + type: string + description: The ID of the created VPC +``` + +### Scope determines which types you import + +The XRD's scope decides which generated types your function must use, and getting it wrong fails +only at apply time. + +- `scope: Namespaced` — what this guide uses — composes **namespaced** managed resources. Import + from the mirrored `.m.` group: `crossplane-models/ec2.aws.m.upbound.io/v1beta1`. +- `scope: Cluster` composes **cluster-scoped** managed resources. Import from + `crossplane-models/ec2.aws.upbound.io/v1beta1`. + +Mixing them gets you `cannot apply cluster scoped composed resource for a namespaced composite +resource` on the cluster. Note that `crossplane composition render` renders the mismatched +combination without complaint, so this does not surface until you deploy. + +A namespaced XR also means the composed resources belong in the XR's namespace. Crossplane does +not infer that for you — the function has to set it, which Step 7 does. + +## Step 6: Create a TypeScript Function + +```bash +# Generate a TypeScript function scaffold +crossplane function generate network --language typescript +``` + +**Note**: You can also generate a function and add it to a composition pipeline in one step: + +```bash +crossplane function generate network apis/networks/composition.yaml --language typescript +``` + +This creates `functions/network/` with: + +- `package.json` - Dependencies including `@crossplane-org/function-sdk-typescript` +- `tsconfig.json` - TypeScript configuration +- `src/main.ts` - Entry point +- `src/function.ts` - Function implementation template +- `src/function.test.ts` - Starter Vitest test +- `.npmrc` - Sets `install-links=true` (see Step 9) +- `eslint.config.js` and `tsconfig.eslint.json` - Type-aware linting (see Troubleshooting) + +## Step 7: Implement the Function + +The generated `functions/network/src/function.ts` contains a template implementation. A full example +is available at +[function.ts](https://github.com/upbound/configuration-aws-network-ts/blob/main/functions/network/src/function.ts). + +Edit the `function.ts` to create a VPC: + +```typescript +import { + type ComposeFunction, + fatal, + fromModel, + getObservedCompositeResource, + normal, +} from '@crossplane-org/function-sdk-typescript'; + +// Import the generated types from crossplane-models. This is the mirrored `.m.` +// group, matching the `scope: Namespaced` XRD from Step 5. For a cluster-scoped +// XRD, import from 'crossplane-models/ec2.aws.upbound.io/v1beta1' instead. +import { VPC } from 'crossplane-models/ec2.aws.m.upbound.io/v1beta1'; + +/** + * compose is a Crossplane composition function that creates a VPC. + * + * serve() hands us a response already built from the request, so there is no + * to(req) here, and rsp.desired is guaranteed to be present. + */ +export const compose: ComposeFunction = async (req, rsp, logger) => { + // Get the observed composite resource (XR). + const observedComposite = getObservedCompositeResource(req); + if (!observedComposite) { + fatal(rsp, 'No composite resource found'); + return rsp; + } + logger?.debug({ observedComposite }, 'Observed composite resource'); + + // Extract spec values from the XR + const spec = observedComposite.resource?.spec as { region?: string; cidrBlock?: string }; + const region = spec?.region || 'us-west-2'; + const cidrBlock = spec?.cidrBlock || '10.0.0.0/16'; + const xrName = observedComposite.resource?.metadata?.name || 'unknown'; + + // A namespaced XR composes namespaced managed resources, and Crossplane does + // not place them for you — carry the XR's namespace onto everything composed. + const namespace = observedComposite.resource?.metadata?.namespace; + + // Create a VPC using the generated TypeScript class. + // + // Do NOT set crossplane.io/external-name here. For a VPC the external name + // is the AWS-assigned ID, which the provider writes back after creation. + // Setting it yourself makes the provider look for a VPC by that name + // forever, so the resource stays Ready=False/Creating even though the VPC + // exists in AWS. Only set it when you genuinely control the external + // identifier, and use tags for human-readable names. + const vpc = new VPC({ + metadata: { + name: `${xrName}-vpc`, + ...(namespace && { namespace: namespace }), + }, + spec: { + forProvider: { + region: region, + cidrBlock: cidrBlock, + enableDnsHostnames: true, + enableDnsSupport: true, + tags: { + Name: `${xrName}-vpc`, + 'managed-by': 'crossplane', + }, + }, + }, + }); + + // Validate the model against the CRD schema before composing it. + vpc.validate(); + + // Write the VPC straight onto the response. ComposeResponse narrows desired + // to non-optional, so there is no need for rsp.desired!. The map holds + // protobuf Resource values rather than kubernetes-models objects, so convert + // with fromModel — assigning the model directly fails to compile with TS2739. + rsp.desired.resources['vpc'] = fromModel(vpc); + + normal(rsp, 'Successfully composed VPC resource'); + return rsp; +}; +``` + +The generated `src/main.ts` hands this to the SDK and needs no edits: + +```typescript +serve(compose, { name: 'network' }); +``` + +`serve` parses the standard function flags, builds a logger from `--debug`, starts the gRPC +server, and shuts down cleanly on `SIGINT` and `SIGTERM`. + +The generated `package.json` already includes the `crossplane-models` dependency when TypeScript +schemas are enabled. It will look like: + +```json +{ + "name": "function", + "version": "0.1.0", + "description": "A Crossplane composition function.", + "license": "Apache-2.0", + "type": "module", + "main": "dist/main.js", + "scripts": { + "build": "tsc", + "typecheck:legacy": "tsc6 --noEmit", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test": "vitest run", + "test:watch": "vitest", + "local": "node dist/main.js --insecure --debug" + }, + "dependencies": { + "@crossplane-org/function-sdk-typescript": "^0.7.0", + "@types/node": "^24.0.0", + "crossplane-models": "file:../../schemas/typescript", + "kubernetes-models": "^5.0.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@typescript/native": "npm:typescript@^7.0.0", + "eslint": "^10.9.1", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "typescript-eslint": "^8.68.0", + "vitest": "^4.1.11" + } +} +``` + +## Step 8: Generate Schemas + +Before building, generate the TypeScript schemas from the dependencies: + +```bash +# This happens automatically during build, but you can trigger it manually +crossplane project build +``` + +The models are generated as TypeScript, then compiled — so `schemas/typescript/` holds JavaScript +plus declarations, not `.ts` sources: + +- `ec2.aws.m.upbound.io/v1beta1/VPC.js` and `VPC.d.ts` - namespaced VPC class with full type + definitions. The cluster-scoped `ec2.aws.upbound.io/` tree is generated alongside it; a + namespaced XRD uses the `.m.` one. +- `aws.platform.upbound.io/v1alpha1/Network.js` and `Network.d.ts` - Your XRD's types + +Schema generation runs once per dependency and is not cheap: adding a function package that +contributes no CRDs to the composition will add time +to the generation step. + +## Step 9: Local Development (Optional) + +For local development and IDE support: + +```bash +cd functions/network + +# Install dependencies (including the local schemas package) +npm install + +# Build locally to check for TypeScript errors +npm run build + +# Run the unit tests +npm test +``` + +This relies on the `.npmrc` in the generated function directory, which sets: + +```ini +install-links=true +``` + +Without it, npm symlinks `crossplane-models` to `../../schemas/typescript`. Node resolves the +symlink to its real path, which sits outside the function's `node_modules`, so the schemas +package cannot reach its own dependencies and any import of a generated model fails at runtime: + +```text +Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@kubernetes-models/base' +imported from .../schemas/typescript/ec2.aws.m.upbound.io/v1beta1/VPC.js +``` + +`install-links=true` copies the package into `node_modules` instead, which also matches the +layout inside the built function image. If you are working in a project scaffolded before this +setting existed, add the `.npmrc` yourself or run `npm install --install-links`. + +## Step 10: Create a Composition + +A Composition contains a pipeline of functions that are executed +in sequence to create resources. + +```bash +# Generate a composition from the XRD +crossplane composition generate apis/networks/definition.yaml +``` + +This generates a basic composition with `function-auto-ready`. You need to add your embedded +function to the pipeline. + +The functionRef name is derived from the project repository and the function name. The CLI builds +the embedded function's image repository as `_`, then converts it to a +DNS label — which drops the underscore rather than replacing it. So for repository +`xpkg.upbound.io/your-org/configuration-aws-network-ts` and function `network`, the functionRef name +is `your-org-configuration-aws-network-tsnetwork`, with no separator before `network`. + +Edit `apis/networks/composition.yaml` to add your function before `function-auto-ready`: + +```yaml +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: networks.aws.platform.upbound.io +spec: + compositeTypeRef: + apiVersion: aws.platform.upbound.io/v1alpha1 + kind: Network + mode: Pipeline + pipeline: + - step: network + functionRef: + # Ensure this name matches your org + name: your-org-configuration-aws-network-tsnetwork + - step: crossplane-contrib-function-auto-ready + functionRef: + name: crossplane-contrib-function-auto-ready +``` + +**Tip**: `function generate` can create the function *and* wire it into the pipeline in one go, +which saves the hand-edit above: + +```bash +crossplane function generate network apis/networks/composition.yaml --language typescript +``` + +Use this **instead of Step 6**, not after it. `function generate` refuses to write into a function +directory that already exists, so having followed Step 6 this fails with `function directory +"network" already exists and is not empty`, and the hand-edit above is the way in. + +The step is inserted at the front of the pipeline, so your function runs before +`function-auto-ready` sees the resources it composes. If the Composition already has a step with +that name pointing at a different function — which happens when porting an existing configuration — +the command fails rather than creating two steps with the same name, and you edit the pipeline by +hand. + +### Activating Managed Resources + +Crossplane v2 supports +[`ManagedResourceActivationPolicy`](https://docs.crossplane.io/latest/managed-resources/managed-resource-activation-policies/), +which limits how many CRDs a provider installs onto a cluster. + +The Crossplane Helm chart sets `provider.defaultActivations` to `["*"]` by default, which installs +a wildcard activation policy. That activates every CRD a provider ships, which can have a +significant performance impact on the Kubernetes API server. + +On a development control plane created by `crossplane project run`, disable the default policy and +install only the CRDs your Composition uses. A Crossplane cluster supports multiple +`ManagedResourceActivationPolicy` resources, so defining one per Composition is good practice. + +In summary: + +- Pass `crossplane project run --no-default-mrap`, which suppresses the wildcard policy. +- Add a `ManagedResourceActivationPolicy` manifest that only activates the CRDs you need. + +In our example, we need to support creation of a VPC. Save this file +as `apis/network/mrap.yaml` and it will automatically be applied to the Cluster when your project is +installed: + +```yaml +apiVersion: apiextensions.crossplane.io/v1alpha1 +kind: ManagedResourceActivationPolicy +metadata: + name: configuration-aws-network-ts +spec: + activate: + - vpcs.ec2.aws.m.upbound.io + - subnets.ec2.aws.m.upbound.io +``` + +Without a CRD activated, no resources can be created on the Cluster. +`crossplane composition render` does not need the +policy, so this only shows up once you deploy to a cluster. + +Testing with `--no-default-mrap` is worth doing before you ship: it is the cheapest way to find +out that an activation policy is incomplete, and the failure is far easier to read locally than in +a production control plane. + +## Step 11: Build the Project + +```bash +# Build the complete project (configuration + embedded functions) +crossplane project build +``` + +This will: + +1. Generate TypeScript schemas from all dependencies (provider-aws-ec2, your XRD) +2. Build the TypeScript function in a Node.js container +3. Package everything into a Crossplane configuration package + +The output will be in `_output/configuration-aws-network-ts.xpkg`. + +## Step 12: Test with Composition Render + +Before deploying to a cluster, you can test your composition function locally using +`crossplane composition render`. This renders the composition pipeline and shows you what resources +would be created without needing a Kubernetes cluster. + +```bash +# Render the composition with a 5 minute timeout (recommended for TypeScript builds) +crossplane composition render \ + examples/network/example.yaml \ + apis/networks/composition.yaml \ + --timeout=5m +``` + +The first run may take several minutes as it: + +1. Pulls the Node.js build image +2. Runs `npm install` to fetch dependencies +3. Compiles the TypeScript function +4. Executes the function pipeline + +Docker and npm caching help on subsequent runs, but not dramatically — the function is rebuilt +in a container every time, so expect a warm render to still take minutes rather than seconds. +Keep `--timeout` generous even once things are cached. + +The output shows the rendered XR and all composed resources as YAML: + +```bash +# Include function results (informational messages) +crossplane composition render \ + examples/network/example.yaml \ + apis/networks/composition.yaml \ + --timeout=5m \ + --include-function-results + +# Include the full XR with spec and metadata +crossplane composition render \ + examples/network/example.yaml \ + apis/networks/composition.yaml \ + --timeout=5m \ + --include-full-xr +``` + +This is useful for: + +- Validating your function logic before deployment +- Debugging composition issues +- Testing changes quickly without a cluster + +## Step 13: Test with a Local Dev Cluster + +For quick local testing, use `crossplane project run` to spin up a local Kubernetes cluster with +Crossplane and your configuration automatically deployed: + +```bash +# Start a local dev cluster and deploy the project +crossplane project run +``` + +Add `--no-default-mrap` to suppress the wildcard activation policy the Crossplane chart installs, +so the control plane behaves like production and the `ManagedResourceActivationPolicy` from Step 10 +is what activates your CRDs: + +```bash +crossplane project run --no-default-mrap +``` + +The flag only takes effect when the control plane is **created**. If a Control Plane already exists +it keeps +whatever the `ManagedResourceActivationPolicy` was built with, so run `crossplane project stop` +first. + +Verify it applied: + +```bash +kubectl -n crossplane-system get deploy crossplane \ + -o jsonpath='{.spec.template.spec.containers[0].args}' +``` + +With the flag, that shows no `--activation` argument. Without it you get `--activation "*"`. + +This will: + +1. Create a local Kind cluster +2. Install Crossplane +3. Build and deploy your configuration package +4. Install all provider dependencies + +Once the cluster is running, configure AWS credentials for the provider: + +The XRD from Step 5 is namespaced, so the ProviderConfig belongs in the XR's namespace and comes +from the mirrored `.m.` group. The secret it points at can live elsewhere: + +```bash +kubectl create ns network-team + +# Create AWS credentials secret (creds.conf should contain your AWS credentials) +# Format: [default] +# aws_access_key_id = YOUR_ACCESS_KEY +# aws_secret_access_key = YOUR_SECRET_KEY +kubectl create secret generic aws-creds -n network-team --from-file=creds=creds.conf + +# Create a ProviderConfig to use the credentials +kubectl apply -f - <_` — so a project +at `xpkg.upbound.io/your-org/configuration-aws-network-ts` with a function named `network` +pushes to `xpkg.upbound.io/your-org/configuration-aws-network-ts_network`. Functions are pushed +first, so if that repository is missing the configuration never gets uploaded at all. + +```bash +# Install on a cluster +kubectl apply -f - <_` has to be created before the +first release of a project with an embedded function. This bites when converting an existing +configuration in particular, because the function's repository name changes: a function that +used to ship as `configuration-aws-network-ts-function` becomes +`configuration-aws-network-ts_network`, which has never existed. + +Since functions are pushed before the configuration, this fails the whole push and leaves +nothing published — the tag exists with no artifact behind it. Create the repository and re-run +`crossplane project push`; there is no need to re-tag. + +### Build timeout during render + +If you see an error like: + +```text +crossplane: error: cannot build embedded functions: failed to build function "network": failed to build runtime images: typescript build container failed: timed out waiting for the container to finish; re-run with a longer --timeout: context deadline exceeded +``` + +the TypeScript build — `npm install` and `npm run build`, in a container — did not finish inside +the timeout, which defaults to 1 minute. + +A warm render normally fits: on the example project a repeat render takes around 28 seconds with no +`--timeout` flag at all. A first render is the one that overruns, because the Docker images, the npm +packages and the schema models all have to be fetched before anything compiles. + +Increase the timeout for that first run using the `--timeout` flag: + +```bash +# Use a 5 minute timeout +crossplane composition render examples/network/example.yaml apis/networks/composition.yaml --timeout=5m + +# Or for larger projects with many dependencies +crossplane composition render examples/network/example.yaml apis/networks/composition.yaml --timeout=10m +``` + +Subsequent builds are faster as Docker images and npm packages are cached, but the function is +still recompiled in a container on every render, so they are not instant. + +## Reference Projects + +For a complete working example built from scratch, see: + + +```bash +crossplane xpkg install configuration \ + xpkg.upbound.io/upbound/configuration-aws-network-ts:v0.3.0 +``` + +Note that this Configuration's CI builds the CLI from this PR's branch rather than installing a +release, since +the feature has not shipped yet — so v0.3.0 was itself built from an unreleased CLI. That is +marked temporary in `.github/actions/crossplane-cli` and comes out once a release includes the +feature. +