diff --git a/.changeset/fair-partner-placement.md b/.changeset/fair-partner-placement.md new file mode 100644 index 00000000..cf2f5cee --- /dev/null +++ b/.changeset/fair-partner-placement.md @@ -0,0 +1,6 @@ +--- +'@tanstack/cli': patch +'@tanstack/create': patch +--- + +Order partner integrations by sponsorship tier and rotate integrations within each tier for every CLI run. diff --git a/packages/cli/src/partner-placement.ts b/packages/cli/src/partner-placement.ts new file mode 100644 index 00000000..6deed302 --- /dev/null +++ b/packages/cli/src/partner-placement.ts @@ -0,0 +1,74 @@ +import { randomUUID } from 'node:crypto' + +import type { AddOn } from '@tanstack/create' + +type PartnerTier = NonNullable['tier'] + +const partnerTierOrder = { + gold: 0, + silver: 1, + bronze: 2, +} satisfies Record + +const cliSessionSeed = randomUUID() + +function hashString(value: string) { + let hash = 2166136261 + + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + + return hash >>> 0 +} + +function compareIdentity(left: AddOn, right: AddOn) { + const nameComparison = left.name.localeCompare(right.name) + return nameComparison || left.id.localeCompare(right.id) +} + +function compareSeededPartnerOrder(left: AddOn, right: AddOn, seed: string) { + const leftWeight = left.partner!.placementWeight ?? 1 + const rightWeight = right.partner!.placementWeight ?? 1 + const leftRandom = + (hashString(`${seed}:${left.partner!.id}`) + 1) / 4294967297 + const rightRandom = + (hashString(`${seed}:${right.partner!.id}`) + 1) / 4294967297 + const seededComparison = + -Math.log(leftRandom) / leftWeight + Math.log(rightRandom) / rightWeight + + return seededComparison || compareIdentity(left, right) +} + +export function orderAddOnsForPartnerPlacement( + addOns: Array, + surface: string, + sessionSeed = cliSessionSeed, +) { + const rotationSeed = `${surface}:${sessionSeed}` + const partners = addOns + .filter((addOn) => addOn.partner) + .sort((left, right) => { + const tierComparison = + partnerTierOrder[left.partner!.tier] - + partnerTierOrder[right.partner!.tier] + + if (tierComparison !== 0) { + return tierComparison + } + + if (surface === 'deployment' && left.partner!.id === 'cloudflare') { + return -1 + } + + if (surface === 'deployment' && right.partner!.id === 'cloudflare') { + return 1 + } + + return compareSeededPartnerOrder(left, right, rotationSeed) + }) + const nonPartners = addOns.filter((addOn) => !addOn.partner) + + return [...partners, ...nonPartners] +} diff --git a/packages/cli/src/ui-prompts.ts b/packages/cli/src/ui-prompts.ts index e62af4ce..396adba8 100644 --- a/packages/cli/src/ui-prompts.ts +++ b/packages/cli/src/ui-prompts.ts @@ -19,6 +19,7 @@ import { isCurrentDirectoryProjectNameInput, validateProjectName, } from './utils.js' +import { orderAddOnsForPartnerPlacement } from './partner-placement.js' import type { AddOn, PackageManager } from '@tanstack/create' import type { Framework } from '@tanstack/create/dist/types/types.js' @@ -154,8 +155,9 @@ export async function selectAddOns( } if (allowMultiple) { - const selectableAddOns = addOns.filter( - (addOn) => !forcedAddOns.includes(addOn.id), + const selectableAddOns = orderAddOnsForPartnerPlacement( + addOns.filter((addOn) => !forcedAddOns.includes(addOn.id)), + 'add-ons', ) if (selectableAddOns.length === 0) { @@ -381,19 +383,21 @@ export async function selectDeployment( ): Promise { const deployments = new Set() let initialValue: string | undefined = undefined - for (const addOn of framework - .getAddOns() - .sort((a, b) => a.name.localeCompare(b.name))) { - if (addOn.type === 'deployment') { - deployments.add(addOn) - if (deployment && addOn.id === deployment) { - return deployment - } - if (forcedDeployment && addOn.id === forcedDeployment) { - initialValue = addOn.id - } else if (!initialValue && addOn.default) { - initialValue = addOn.id - } + for (const addOn of orderAddOnsForPartnerPlacement( + framework + .getAddOns() + .filter((addOn) => addOn.type === 'deployment') + .sort((a, b) => a.name.localeCompare(b.name)), + 'deployment', + )) { + deployments.add(addOn) + if (deployment && addOn.id === deployment) { + return deployment + } + if (forcedDeployment && addOn.id === forcedDeployment) { + initialValue = addOn.id + } else if (!initialValue && addOn.default) { + initialValue = addOn.id } } diff --git a/packages/cli/tests/partner-placement.test.ts b/packages/cli/tests/partner-placement.test.ts new file mode 100644 index 00000000..e48ae742 --- /dev/null +++ b/packages/cli/tests/partner-placement.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' + +import { orderAddOnsForPartnerPlacement } from '../src/partner-placement' + +import type { AddOn } from '@tanstack/create' + +function addOn( + id: string, + partner?: { + id: string + tier: 'gold' | 'silver' | 'bronze' + placementWeight?: number + }, +) { + return { id, name: id, partner } as AddOn +} + +describe('partner placement', () => { + it('orders partners by tier ahead of non-partners', () => { + const ordered = orderAddOnsForPartnerPlacement( + [ + addOn('other'), + addOn('bronze', { id: 'bronze', tier: 'bronze' }), + addOn('gold', { id: 'gold', tier: 'gold' }), + addOn('silver', { id: 'silver', tier: 'silver' }), + ], + 'add-ons', + 'test', + ) + + expect(ordered.map((item) => item.id)).toEqual([ + 'gold', + 'silver', + 'bronze', + 'other', + ]) + }) + + it('rotates partners within a tier from the CLI session seed', () => { + const partners = [ + addOn('clerk', { id: 'clerk', tier: 'silver' }), + addOn('workos', { id: 'workos', tier: 'silver' }), + ] + + expect( + orderAddOnsForPartnerPlacement(partners, 'add-ons', '0').map( + (item) => item.id, + ), + ).toEqual(['clerk', 'workos']) + expect( + orderAddOnsForPartnerPlacement(partners, 'add-ons', '4').map( + (item) => item.id, + ), + ).toEqual(['workos', 'clerk']) + }) + + it('reserves Cloudflare as the first deployment partner', () => { + const ordered = orderAddOnsForPartnerPlacement( + [ + addOn('netlify', { id: 'netlify', tier: 'gold' }), + addOn('cloudflare', { id: 'cloudflare', tier: 'gold' }), + addOn('railway', { id: 'railway', tier: 'gold' }), + ], + 'deployment', + 'test', + ) + + expect(ordered[0]?.id).toBe('cloudflare') + expect( + ordered + .slice(1) + .map((item) => item.id) + .sort(), + ).toEqual(['netlify', 'railway']) + }) +}) diff --git a/packages/create/src/frameworks/react/add-ons/clerk/info.json b/packages/create/src/frameworks/react/add-ons/clerk/info.json index f3d8f41a..08087eeb 100644 --- a/packages/create/src/frameworks/react/add-ons/clerk/info.json +++ b/packages/create/src/frameworks/react/add-ons/clerk/info.json @@ -8,6 +8,7 @@ "exclusive": ["auth"], "color": "#6C47FF", "priority": 150, + "partner": { "id": "clerk", "tier": "silver" }, "link": "https://clerk.com", "routes": [ { diff --git a/packages/create/src/frameworks/react/add-ons/prisma/info.json b/packages/create/src/frameworks/react/add-ons/prisma/info.json index 8e495a6d..4fb7770c 100644 --- a/packages/create/src/frameworks/react/add-ons/prisma/info.json +++ b/packages/create/src/frameworks/react/add-ons/prisma/info.json @@ -7,9 +7,10 @@ "exclusive": ["orm"], "color": "#2D3748", "priority": 120, + "partner": { "id": "prisma", "tier": "bronze" }, "link": "https://www.prisma.io/", "modes": ["file-router"], - + "postInitSpecialSteps": ["post-init-script"], "options": { "database": { diff --git a/packages/create/src/frameworks/react/add-ons/sentry/info.json b/packages/create/src/frameworks/react/add-ons/sentry/info.json index cdf21782..19c213df 100644 --- a/packages/create/src/frameworks/react/add-ons/sentry/info.json +++ b/packages/create/src/frameworks/react/add-ons/sentry/info.json @@ -8,6 +8,7 @@ "category": "monitoring", "color": "#362D59", "priority": 130, + "partner": { "id": "sentry", "tier": "bronze" }, "routes": [ { "url": "/demo/sentry/testing", diff --git a/packages/create/src/frameworks/react/add-ons/workos/info.json b/packages/create/src/frameworks/react/add-ons/workos/info.json index f051f7ee..94fa0538 100644 --- a/packages/create/src/frameworks/react/add-ons/workos/info.json +++ b/packages/create/src/frameworks/react/add-ons/workos/info.json @@ -8,6 +8,7 @@ "exclusive": ["auth"], "color": "#6363F1", "priority": 160, + "partner": { "id": "workos", "tier": "silver" }, "link": "https://workos.com", "routes": [ { diff --git a/packages/create/src/frameworks/react/hosts/cloudflare/info.json b/packages/create/src/frameworks/react/hosts/cloudflare/info.json index fcd27332..60defee9 100644 --- a/packages/create/src/frameworks/react/hosts/cloudflare/info.json +++ b/packages/create/src/frameworks/react/hosts/cloudflare/info.json @@ -9,6 +9,7 @@ "exclusive": ["deploy"], "color": "#F38020", "priority": 200, + "partner": { "id": "cloudflare", "tier": "gold" }, "integrations": [ { "type": "vite-plugin", diff --git a/packages/create/src/frameworks/react/hosts/netlify/info.json b/packages/create/src/frameworks/react/hosts/netlify/info.json index 8da171fd..611ce371 100644 --- a/packages/create/src/frameworks/react/hosts/netlify/info.json +++ b/packages/create/src/frameworks/react/hosts/netlify/info.json @@ -9,6 +9,7 @@ "exclusive": ["deploy"], "color": "#00C7B7", "priority": 180, + "partner": { "id": "netlify", "tier": "gold" }, "integrations": [ { "type": "vite-plugin", diff --git a/packages/create/src/frameworks/react/hosts/railway/info.json b/packages/create/src/frameworks/react/hosts/railway/info.json index 5eeaff0a..700c7508 100644 --- a/packages/create/src/frameworks/react/hosts/railway/info.json +++ b/packages/create/src/frameworks/react/hosts/railway/info.json @@ -9,6 +9,7 @@ "exclusive": ["deploy"], "color": "#9B4DCA", "priority": 160, + "partner": { "id": "railway", "tier": "gold" }, "integrations": [ { "type": "vite-plugin", diff --git a/packages/create/src/frameworks/solid/add-ons/sentry/info.json b/packages/create/src/frameworks/solid/add-ons/sentry/info.json index dc4a68ba..a8bc81d4 100644 --- a/packages/create/src/frameworks/solid/add-ons/sentry/info.json +++ b/packages/create/src/frameworks/solid/add-ons/sentry/info.json @@ -7,6 +7,7 @@ "type": "add-on", "category": "monitoring", "color": "#362D59", + "partner": { "id": "sentry", "tier": "bronze" }, "routes": [ { "url": "/demo/sentry/bad-event-handler", diff --git a/packages/create/src/frameworks/solid/hosts/cloudflare/info.json b/packages/create/src/frameworks/solid/hosts/cloudflare/info.json index fcd27332..60defee9 100644 --- a/packages/create/src/frameworks/solid/hosts/cloudflare/info.json +++ b/packages/create/src/frameworks/solid/hosts/cloudflare/info.json @@ -9,6 +9,7 @@ "exclusive": ["deploy"], "color": "#F38020", "priority": 200, + "partner": { "id": "cloudflare", "tier": "gold" }, "integrations": [ { "type": "vite-plugin", diff --git a/packages/create/src/frameworks/solid/hosts/netlify/info.json b/packages/create/src/frameworks/solid/hosts/netlify/info.json index 8da171fd..611ce371 100644 --- a/packages/create/src/frameworks/solid/hosts/netlify/info.json +++ b/packages/create/src/frameworks/solid/hosts/netlify/info.json @@ -9,6 +9,7 @@ "exclusive": ["deploy"], "color": "#00C7B7", "priority": 180, + "partner": { "id": "netlify", "tier": "gold" }, "integrations": [ { "type": "vite-plugin", diff --git a/packages/create/src/frameworks/solid/hosts/railway/info.json b/packages/create/src/frameworks/solid/hosts/railway/info.json index df437f53..ea150ba1 100644 --- a/packages/create/src/frameworks/solid/hosts/railway/info.json +++ b/packages/create/src/frameworks/solid/hosts/railway/info.json @@ -9,6 +9,7 @@ "exclusive": ["deploy"], "color": "#9B4DCA", "priority": 160, + "partner": { "id": "railway", "tier": "gold" }, "integrations": [ { "type": "vite-plugin", diff --git a/packages/create/src/types.ts b/packages/create/src/types.ts index ef4106ce..23721d57 100644 --- a/packages/create/src/types.ts +++ b/packages/create/src/types.ts @@ -61,6 +61,13 @@ export const AddOnBaseSchema = z.object({ .optional(), color: z.string().optional(), priority: z.number().optional(), + partner: z + .object({ + id: z.string(), + tier: z.enum(['gold', 'silver', 'bronze']), + placementWeight: z.number().positive().optional(), + }) + .optional(), command: z .object({ command: z.string(),