Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/twelve-pears-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/cta-engine': minor
---

Added categories, colors, exclusive tagging, addon file attribution utils, and railway addon
245 changes: 245 additions & 0 deletions packages/cta-engine/src/attribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import type {
AddOn,
Framework,
Starter,
AttributedFile,
DependencyAttribution,
FileProvenance,
LineAttribution,
Integration,
IntegrationWithSource,
} from './types.js'

export interface AttributionInput {
framework: Framework
chosenAddOns: Array<AddOn>
starter?: Starter
files: Record<string, string>
}

export interface AttributionOutput {
attributedFiles: Record<string, AttributedFile>
dependencies: Array<DependencyAttribution>
}

type Source = { sourceId: string; sourceName: string }

// A pattern to search for in file content, with its source add-on
interface Injection {
matches: (line: string) => boolean
appliesTo: (filePath: string) => boolean
source: Source
}

function normalizePath(path: string): string {
let p = path.startsWith('./') ? path.slice(2) : path
p = p.replace(/\.ejs$/, '').replace(/_dot_/g, '.')
const match = p.match(/^(.+\/)?__([^_]+)__(.+)$/)
return match ? (match[1] || '') + match[3] : p
}

async function getFileProvenance(
filePath: string,
framework: Framework,
addOns: Array<AddOn>,
starter?: Starter,
): Promise<FileProvenance | null> {
const target = filePath.startsWith('./') ? filePath.slice(2) : filePath

if (starter) {
const files = await starter.getFiles()
if (files.some((f: string) => normalizePath(f) === target)) {
return {
source: 'starter',
sourceId: starter.id,
sourceName: starter.name,
}
}
}

// Order add-ons by type then phase (matches writeFiles order), check in reverse
const typeOrder = ['add-on', 'example', 'toolchain', 'deployment']
const phaseOrder = ['setup', 'add-on', 'example']
const ordered = typeOrder.flatMap((type) =>
phaseOrder.flatMap((phase) =>
addOns.filter((a) => a.phase === phase && a.type === type),
),
)

for (let i = ordered.length - 1; i >= 0; i--) {
const files = await ordered[i].getFiles()
if (files.some((f: string) => normalizePath(f) === target)) {
return {
source: 'add-on',
sourceId: ordered[i].id,
sourceName: ordered[i].name,
}
}
}

const frameworkFiles = await framework.getFiles()
if (frameworkFiles.some((f: string) => normalizePath(f) === target)) {
return {
source: 'framework',
sourceId: framework.id,
sourceName: framework.name,
}
}

return null
}

// Build injection patterns from integrations (for source files)
function integrationInjections(int: IntegrationWithSource): Array<Injection> {
const source = { sourceId: int._sourceId, sourceName: int._sourceName }
const injections: Array<Injection> = []

const appliesTo = (path: string) => {
if (int.type === 'vite-plugin') return path.includes('vite.config')
if (
int.type === 'provider' ||
int.type === 'root-provider' ||
int.type === 'devtools'
) {
return path.includes('__root') || path.includes('root.tsx')
}
return false
}

if (int.import) {
const prefix = int.import.split(' from ')[0]
injections.push({
matches: (line) => line.includes(prefix),
appliesTo,
source,
})
}

const code = int.code || int.jsName
if (code) {
injections.push({
matches: (line) => line.includes(code),
appliesTo,
source,
})
}

return injections
}

// Build injection pattern from a dependency (for package.json)
function dependencyInjection(dep: DependencyAttribution): Injection {
return {
matches: (line) => line.includes(`"${dep.name}"`),
appliesTo: (path) => path.endsWith('package.json'),
source: { sourceId: dep.sourceId, sourceName: dep.sourceName },
}
}

export async function computeAttribution(
input: AttributionInput,
): Promise<AttributionOutput> {
const { framework, chosenAddOns, starter, files } = input

// Collect integrations tagged with source
const integrations: Array<IntegrationWithSource> = chosenAddOns.flatMap(
(addOn) =>
(addOn.integrations || []).map((int: Integration) => ({
...int,
_sourceId: addOn.id,
_sourceName: addOn.name,
})),
)

// Collect dependencies from add-ons (from packageAdditions or packageTemplate)
const dependencies: Array<DependencyAttribution> = chosenAddOns.flatMap(
(addOn) => {
const result: Array<DependencyAttribution> = []
const source = { sourceId: addOn.id, sourceName: addOn.name }

const addDeps = (
deps: Record<string, unknown> | undefined,
type: 'dependency' | 'devDependency',
) => {
if (!deps) return
for (const [name, version] of Object.entries(deps)) {
if (typeof version === 'string') {
result.push({ name, version, type, ...source })
}
}
}

// From static package.json
addDeps(addOn.packageAdditions?.dependencies, 'dependency')
addDeps(addOn.packageAdditions?.devDependencies, 'devDependency')

// From package.json.ejs template (strip EJS tags and parse)
if (addOn.packageTemplate) {
try {
const tmpl = JSON.parse(
addOn.packageTemplate.replace(/"[^"]*<%[^%]*%>[^"]*"/g, '""'),
)
addDeps(tmpl.dependencies, 'dependency')
addDeps(tmpl.devDependencies, 'devDependency')
} catch {}
}

return result
},
)

// Build unified injection patterns from both integrations and dependencies
const injections: Array<Injection> = [
...integrations.flatMap(integrationInjections),
...dependencies.map(dependencyInjection),
]

const attributedFiles: Record<string, AttributedFile> = {}

for (const [filePath, content] of Object.entries(files)) {
const provenance = await getFileProvenance(
filePath,
framework,
chosenAddOns,
starter,
)
if (!provenance) continue

const lines = content.split('\n')
const relevant = injections.filter((inj) => inj.appliesTo(filePath))

// Find injected lines
const injectedLines = new Map<number, Source>()
for (const inj of relevant) {
lines.forEach((line, i) => {
if (inj.matches(line) && !injectedLines.has(i + 1)) {
injectedLines.set(i + 1, inj.source)
}
})
}

attributedFiles[filePath] = {
content,
provenance,
lineAttributions: lines.map((_, i): LineAttribution => {
const lineNum = i + 1
const inj = injectedLines.get(lineNum)
return inj
? {
line: lineNum,
sourceId: inj.sourceId,
sourceName: inj.sourceName,
type: 'injected',
}
: {
line: lineNum,
sourceId: provenance.sourceId,
sourceName: provenance.sourceName,
type: 'original',
}
}),
}
}

return { attributedFiles, dependencies }
}
22 changes: 13 additions & 9 deletions packages/cta-engine/src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ import {

import type { Environment } from './types.js'

export interface MemoryEnvironmentOutput {
files: Record<string, string>
deletedFiles: Array<string>
commands: Array<{ command: string; args: Array<string> }>
}

export function createDefaultEnvironment(): Environment {
let errors: Array<string> = []
return {
Expand All @@ -46,7 +52,12 @@ export function createDefaultEnvironment(): Environment {
await mkdir(dirname(path), { recursive: true })
return writeFile(path, getBinaryFile(base64Contents) as string)
},
execute: async (command: string, args: Array<string>, cwd: string, options?: { inherit?: boolean }) => {
execute: async (
command: string,
args: Array<string>,
cwd: string,
options?: { inherit?: boolean },
) => {
try {
if (options?.inherit) {
// For commands that should show output directly to the user
Expand Down Expand Up @@ -106,14 +117,7 @@ export function createDefaultEnvironment(): Environment {
export function createMemoryEnvironment(returnPathsRelativeTo: string = '') {
const environment = createDefaultEnvironment()

const output: {
files: Record<string, string>
deletedFiles: Array<string>
commands: Array<{
command: string
args: Array<string>
}>
} = {
const output: MemoryEnvironmentOutput = {
files: {},
commands: [],
deletedFiles: [],
Expand Down
13 changes: 12 additions & 1 deletion packages/cta-engine/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
export { createApp } from './create-app.js'
export { computeAttribution } from './attribution.js'
export { addToApp } from './add-to-app.js'

export { finalizeAddOns, getAllAddOns, populateAddOnOptionsDefaults } from './add-ons.js'
export {
finalizeAddOns,
getAllAddOns,
populateAddOnOptionsDefaults,
} from './add-ons.js'

export { loadRemoteAddOn } from './custom-add-ons/add-on.js'
export { loadStarter } from './custom-add-ons/starter.js'
Expand Down Expand Up @@ -85,6 +90,12 @@ export type {
SerializedOptions,
Starter,
StarterCompiled,
LineAttribution,
FileProvenance,
AttributedFile,
DependencyAttribution,
} from './types.js'
export type { AttributionInput, AttributionOutput } from './attribution.js'
export type { MemoryEnvironmentOutput } from './environment.js'
export type { PersistedOptions } from './config-file.js'
export type { PackageManager } from './package-manager.js'
17 changes: 14 additions & 3 deletions packages/cta-engine/src/template-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import {
} from './package-manager.js'
import { relativePath } from './file-helpers.js'

import type { AddOn, Environment, Integration, Options } from './types.js'
import type {
AddOn,
Environment,
Integration,
IntegrationWithSource,
Options,
} from './types.js'

function convertDotFilesAndPaths(path: string) {
return path
Expand Down Expand Up @@ -50,11 +56,16 @@ export function createTemplateFile(environment: Environment, options: Options) {
}
}

const integrations: Array<Required<AddOn>['integrations'][number]> = []
// Collect integrations and tag them with source add-on for attribution
const integrations: Array<IntegrationWithSource> = []
for (const addOn of options.chosenAddOns) {
if (addOn.integrations) {
for (const integration of addOn.integrations) {
integrations.push(integration)
integrations.push({
...integration,
_sourceId: addOn.id,
_sourceName: addOn.name,
})
}
}
}
Expand Down
34 changes: 34 additions & 0 deletions packages/cta-engine/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,37 @@ type UIEnvironment = {
}

export type Environment = ProjectEnvironment & FileEnvironment & UIEnvironment

// Attribution tracking types for file provenance
export interface LineAttribution {
line: number
sourceId: string
sourceName: string
type: 'original' | 'injected'
}

export interface FileProvenance {
source: 'framework' | 'add-on' | 'starter'
sourceId: string
sourceName: string
}

export interface AttributedFile {
content: string
provenance: FileProvenance
lineAttributions: Array<LineAttribution>
}

export interface DependencyAttribution {
name: string
version: string
type: 'dependency' | 'devDependency'
sourceId: string
sourceName: string
}

// Integration with source add-on tracking (used in templates and attribution)
export type IntegrationWithSource = Integration & {
_sourceId: string
_sourceName: string
}