From edf8b5c2f34b836a7c76d8b6616afb158d4ec793 Mon Sep 17 00:00:00 2001 From: Herman Wikner Date: Wed, 25 Mar 2026 09:31:04 +0100 Subject: [PATCH] feat(init): support `sanity-template.json` with `postInitMessage` --- .../getPostInitMessageDisplay.test.ts | 46 ++++++ .../__tests__/readTemplateManifest.test.ts | 136 ++++++++++++++++++ .../@sanity/cli/src/actions/init/constants.ts | 2 + .../actions/init/getPostInitMessageDisplay.ts | 28 ++++ .../src/actions/init/readTemplateManifest.ts | 47 ++++++ .../@sanity/cli/src/actions/init/types.ts | 7 + packages/@sanity/cli/src/commands/init.ts | 21 +++ 7 files changed, 287 insertions(+) create mode 100644 packages/@sanity/cli/src/actions/init/__tests__/getPostInitMessageDisplay.test.ts create mode 100644 packages/@sanity/cli/src/actions/init/__tests__/readTemplateManifest.test.ts create mode 100644 packages/@sanity/cli/src/actions/init/getPostInitMessageDisplay.ts create mode 100644 packages/@sanity/cli/src/actions/init/readTemplateManifest.ts diff --git a/packages/@sanity/cli/src/actions/init/__tests__/getPostInitMessageDisplay.test.ts b/packages/@sanity/cli/src/actions/init/__tests__/getPostInitMessageDisplay.test.ts new file mode 100644 index 000000000..3dbcedcfd --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/__tests__/getPostInitMessageDisplay.test.ts @@ -0,0 +1,46 @@ +import {describe, expect, test} from 'vitest' + +import {getPostInitMessageDisplay} from '../getPostInitMessageDisplay.js' + +describe('getPostInitMessageDisplay', () => { + test('returns null for undefined', () => { + expect(getPostInitMessageDisplay(undefined)).toBeNull() + }) + + test('returns null for empty string', () => { + expect(getPostInitMessageDisplay('')).toBeNull() + expect(getPostInitMessageDisplay(' ')).toBeNull() + }) + + test('returns single line for non-empty string', () => { + expect(getPostInitMessageDisplay('Hello')).toEqual(['Hello']) + }) + + test('splits string on newlines like separate array entries', () => { + expect(getPostInitMessageDisplay('a\n\nb')).toEqual(['a', 'b']) + }) + + test('strips ANSI sequences from string', () => { + const withAnsi = '\u001B[31mred\u001B[0m text' + expect(getPostInitMessageDisplay(withAnsi)).toEqual(['red text']) + }) + + test('returns null for empty array', () => { + expect(getPostInitMessageDisplay([])).toBeNull() + }) + + test('returns null when array is only whitespace', () => { + expect(getPostInitMessageDisplay(['', ' ', '\t'])).toBeNull() + }) + + test('filters empty entries and preserves order', () => { + expect(getPostInitMessageDisplay(['a', '', ' ', 'b'])).toEqual(['a', 'b']) + }) + + test('strips ANSI from each array line', () => { + expect(getPostInitMessageDisplay(['\u001B[1mbold\u001B[0m', 'plain'])).toEqual([ + 'bold', + 'plain', + ]) + }) +}) diff --git a/packages/@sanity/cli/src/actions/init/__tests__/readTemplateManifest.test.ts b/packages/@sanity/cli/src/actions/init/__tests__/readTemplateManifest.test.ts new file mode 100644 index 000000000..efe64148a --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/__tests__/readTemplateManifest.test.ts @@ -0,0 +1,136 @@ +import path from 'node:path' + +import {afterEach, describe, expect, test, vi} from 'vitest' + +import {readTemplateManifest, removeTemplateManifestFromOutput} from '../readTemplateManifest.js' + +const MANIFEST_PATH = path.join('/tmp/project', 'sanity-template.json') + +const mocks = vi.hoisted(() => ({ + noop: () => {}, + readFile: vi.fn(), + unlink: vi.fn(), +})) + +vi.mock('node:fs/promises', () => ({ + readFile: mocks.readFile, + unlink: mocks.unlink, +})) + +vi.mock('@sanity/cli-core', () => ({ + subdebug: () => mocks.noop, +})) + +describe('readTemplateManifest', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test('returns manifest when sanity-template.json exists with postInitMessage', async () => { + mocks.readFile.mockResolvedValue( + JSON.stringify({postInitMessage: 'Run npx skills add sanity-io/agent-context --all'}), + ) + + const manifest = await readTemplateManifest('/tmp/project') + + expect(manifest).toEqual({ + postInitMessage: 'Run npx skills add sanity-io/agent-context --all', + }) + expect(mocks.readFile).toHaveBeenCalledWith(MANIFEST_PATH, 'utf8') + }) + + test('returns manifest when postInitMessage is a string array', async () => { + mocks.readFile.mockResolvedValue(JSON.stringify({postInitMessage: ['Line one', 'Line two']})) + + const manifest = await readTemplateManifest('/tmp/project') + + expect(manifest).toEqual({postInitMessage: ['Line one', 'Line two']}) + }) + + test('strips unknown keys from manifest', async () => { + mocks.readFile.mockResolvedValue( + JSON.stringify({ + postInitMessage: 'ok', + unknownField: 'ignored', + }), + ) + + const manifest = await readTemplateManifest('/tmp/project') + + expect(manifest).toEqual({postInitMessage: 'ok'}) + }) + + test('returns manifest when file has no postInitMessage field', async () => { + mocks.readFile.mockResolvedValue(JSON.stringify({})) + + const manifest = await readTemplateManifest('/tmp/project') + + expect(manifest).toEqual({}) + }) + + test('returns null when sanity-template.json does not exist', async () => { + mocks.readFile.mockRejectedValue(Object.assign(new Error('ENOENT'), {code: 'ENOENT'})) + + const manifest = await readTemplateManifest('/tmp/project') + + expect(manifest).toBeNull() + }) + + test('returns null when sanity-template.json contains invalid JSON', async () => { + mocks.readFile.mockResolvedValue('not valid json {{{') + + const manifest = await readTemplateManifest('/tmp/project') + + expect(manifest).toBeNull() + }) + + test('returns null when postInitMessage has invalid type', async () => { + mocks.readFile.mockResolvedValue(JSON.stringify({postInitMessage: 42})) + + const manifest = await readTemplateManifest('/tmp/project') + + expect(manifest).toBeNull() + }) + + test('returns null when postInitMessage array contains non-strings', async () => { + mocks.readFile.mockResolvedValue(JSON.stringify({postInitMessage: ['ok', 1]})) + + const manifest = await readTemplateManifest('/tmp/project') + + expect(manifest).toBeNull() + }) + + test('returns null when postInitMessage exceeds schema size limits', async () => { + mocks.readFile.mockResolvedValue(JSON.stringify({postInitMessage: 'x'.repeat(2001)})) + + const manifest = await readTemplateManifest('/tmp/project') + + expect(manifest).toBeNull() + }) +}) + +describe('removeTemplateManifestFromOutput', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test('unlinks sanity-template.json under output path', async () => { + mocks.unlink.mockResolvedValue(undefined) + + await removeTemplateManifestFromOutput('/tmp/project') + + expect(mocks.unlink).toHaveBeenCalledWith(MANIFEST_PATH) + }) + + test('does not throw when file is already missing', async () => { + mocks.unlink.mockRejectedValue(Object.assign(new Error('ENOENT'), {code: 'ENOENT'})) + + await expect(removeTemplateManifestFromOutput('/tmp/project')).resolves.toBeUndefined() + }) + + test('does not throw when unlink fails for another reason', async () => { + mocks.unlink.mockRejectedValue(Object.assign(new Error('EACCES'), {code: 'EACCES'})) + + await expect(removeTemplateManifestFromOutput('/tmp/project')).resolves.toBeUndefined() + }) +}) diff --git a/packages/@sanity/cli/src/actions/init/constants.ts b/packages/@sanity/cli/src/actions/init/constants.ts index 5fc60b31d..d66496429 100644 --- a/packages/@sanity/cli/src/actions/init/constants.ts +++ b/packages/@sanity/cli/src/actions/init/constants.ts @@ -1 +1,3 @@ export const INIT_API_VERSION = 'v2025-06-01' + +export const TEMPLATE_MANIFEST_FILENAME = 'sanity-template.json' as const diff --git a/packages/@sanity/cli/src/actions/init/getPostInitMessageDisplay.ts b/packages/@sanity/cli/src/actions/init/getPostInitMessageDisplay.ts new file mode 100644 index 000000000..fb2dc58ce --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/getPostInitMessageDisplay.ts @@ -0,0 +1,28 @@ +import {stripVTControlCharacters} from 'node:util' + +import {type TemplateManifest} from './types.js' + +function normalizeLines(rawLines: string[]): string[] | null { + const lines = rawLines + .map((line) => stripVTControlCharacters(line)) + .filter((line) => line.trim() !== '') + + return lines.length > 0 ? lines : null +} + +/** + * Normalizes the template manifest `postInitMessage` field into lines for the CLI to print. + * Strips VT/ANSI-style escapes and removes blank-only entries; returns `null` when there is nothing to show. + * String values are split on newlines so spacing matches an equivalent array of lines. + */ +export function getPostInitMessageDisplay( + postInitMessage: TemplateManifest['postInitMessage'], +): string[] | null { + if (!postInitMessage) return null + + if (Array.isArray(postInitMessage)) { + return normalizeLines(postInitMessage) + } + + return normalizeLines(postInitMessage.split(/\r?\n/)) +} diff --git a/packages/@sanity/cli/src/actions/init/readTemplateManifest.ts b/packages/@sanity/cli/src/actions/init/readTemplateManifest.ts new file mode 100644 index 000000000..2aade5879 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init/readTemplateManifest.ts @@ -0,0 +1,47 @@ +import {readFile, unlink} from 'node:fs/promises' +import {join} from 'node:path' + +import {subdebug} from '@sanity/cli-core' + +import {TEMPLATE_MANIFEST_FILENAME} from './constants.js' +import {type TemplateManifest, templateManifestSchema} from './types.js' + +const debug = subdebug('init:readTemplateManifest') + +/** + * Reads `sanity-template.json` from the bootstrapped project directory (`outputPath`) when present. + * Returns `null` if the file is missing, cannot be parsed as JSON, or does not match the manifest schema. + * Never throws. + */ +export async function readTemplateManifest(outputPath: string): Promise { + const manifestPath = join(outputPath, TEMPLATE_MANIFEST_FILENAME) + try { + const content = await readFile(manifestPath, 'utf8') + const json: unknown = JSON.parse(content) + const parsed = templateManifestSchema.safeParse(json) + + if (!parsed.success) { + debug('Invalid template manifest at %s', manifestPath) + return null + } + + return parsed.data + } catch (err) { + debug('Template manifest not used at %s: %s', manifestPath, err) + + return null + } +} + +/** + * Removes `sanity-template.json` from the project directory after init has read it. + */ +export async function removeTemplateManifestFromOutput(outputPath: string): Promise { + const manifestPath = join(outputPath, TEMPLATE_MANIFEST_FILENAME) + + try { + await unlink(manifestPath) + } catch (err) { + debug('Could not remove template manifest at %s: %s', manifestPath, err) + } +} diff --git a/packages/@sanity/cli/src/actions/init/types.ts b/packages/@sanity/cli/src/actions/init/types.ts index b9cbfafcd..2a21fccc4 100644 --- a/packages/@sanity/cli/src/actions/init/types.ts +++ b/packages/@sanity/cli/src/actions/init/types.ts @@ -1,4 +1,5 @@ import {Framework} from '@vercel/frameworks' +import {z} from 'zod' import {GenerateConfigOptions} from './createStudioConfig' @@ -17,3 +18,9 @@ export interface ProjectTemplate { type?: 'commonjs' | 'module' typescriptOnly?: boolean } + +export const templateManifestSchema = z.object({ + postInitMessage: z.union([z.string().max(2000), z.array(z.string().max(500)).max(50)]).optional(), +}) + +export type TemplateManifest = z.infer diff --git a/packages/@sanity/cli/src/commands/init.ts b/packages/@sanity/cli/src/commands/init.ts index 544aea81f..cc319f0f2 100644 --- a/packages/@sanity/cli/src/commands/init.ts +++ b/packages/@sanity/cli/src/commands/init.ts @@ -29,7 +29,12 @@ import {countNestedFolders} from '../actions/init/countNestedFolders.js' import {determineAppTemplate} from '../actions/init/determineAppTemplate.js' import {createOrAppendEnvVars} from '../actions/init/env/createOrAppendEnvVars.js' import {fetchPostInitPrompt} from '../actions/init/fetchPostInitPrompt.js' +import {getPostInitMessageDisplay} from '../actions/init/getPostInitMessageDisplay.js' import {tryGitInit} from '../actions/init/git.js' +import { + readTemplateManifest, + removeTemplateManifestFromOutput, +} from '../actions/init/readTemplateManifest.js' import { checkIsRemoteTemplate, getGitHubRepoInfo, @@ -717,6 +722,22 @@ export class InitCommand extends SanityCommand { this.log('We look forward to seeing you there!\n') } + const templateManifest = await readTemplateManifest(outputPath) + const postInitLines = getPostInitMessageDisplay(templateManifest?.postInitMessage) + + if (postInitLines) { + this.log('') + this.log(styleText('dim', 'Message from the template author:')) + for (const line of postInitLines) { + this.log('') + this.log(line) + } + } + + if (templateManifest) { + await removeTemplateManifestFromOutput(outputPath) + } + this._trace.complete() }