diff --git a/.changeset/old-bags-rest.md b/.changeset/old-bags-rest.md new file mode 100644 index 00000000..b052217a --- /dev/null +++ b/.changeset/old-bags-rest.md @@ -0,0 +1,7 @@ +--- +"create-solana-dapp": patch +--- + +fix: upgrade @clack/prompts to v0.11.0 + +This upgrade makes the vendored tasks utility in `src/utils/vendor/clack-tasks.ts` obsolete, as this functionality is now exported directly by `@clack/prompts`. diff --git a/.changeset/rude-eyes-check.md b/.changeset/rude-eyes-check.md new file mode 100644 index 00000000..1b9ab4d4 --- /dev/null +++ b/.changeset/rude-eyes-check.md @@ -0,0 +1,5 @@ +--- +"create-solana-dapp": patch +--- + +Added CircleCI configuration for continuous integration. diff --git a/.changeset/two-bees-trade.md b/.changeset/two-bees-trade.md new file mode 100644 index 00000000..1b9ab4d4 --- /dev/null +++ b/.changeset/two-bees-trade.md @@ -0,0 +1,5 @@ +--- +"create-solana-dapp": patch +--- + +Added CircleCI configuration for continuous integration. diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..1ab4b335 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,34 @@ +version: 2.1 + +orbs: + node: circleci/node@x.y + +jobs: + install-node-example: + docker: + - image: cimg/base:stable + steps: + - checkout + - node/install: + install-yarn: true + node-version: '16.13' + - run: node --version +workflows: + test_my_app: + jobs: + - install-node-example + + # Add steps to the job + steps: + # Checkout the code as the first step. + - checkout + - run: + name: "Say hello" + command: "echo Hello, World!" + +# Orchestrate jobs using workflows +workflows: + say-hello-workflow: # This is the name of the workflow, feel free to change it to better match your workflow. + # Inside the workflow, you define the jobs you want to run. + jobs: + - say-hello diff --git a/.github/workflows/publish-pkg-pr-new.yml b/.github/workflows/publish-pkg-pr-new.yml index 722550e7..1f6e9487 100644 --- a/.github/workflows/publish-pkg-pr-new.yml +++ b/.github/workflows/publish-pkg-pr-new.yml @@ -7,19 +7,23 @@ on: env: # See https://consoledonottrack.com/ DO_NOT_TRACK: '1' - + + version: '2.1' + orbs: + node: circleci/node@x.y jobs: - build-and-publish-snapshots-to-npm: - runs-on: ubuntu-latest + test: + executor: node/default steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Dependencies - uses: ./.github/workflows/actions/install-dependencies + - checkout + - node/install-bun: + version: 1.2.22 + - node/install-packages: + pkg-manager: bun + - run: bun run test +workflows: + test_my_app: + jobs: + - test - - name: Run Build Step - run: pnpm build - - name: Publish to pkg.pr.new - run: pnpm pkg-pr-new publish diff --git a/package.json b/package.json index 3feb49f9..48c7fc5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "create-solana-dapp", - "version": "4.8.2", + "version": "4.6.0", "description": "The fastest way to create Solana apps", "repository": { "name": "solana-foundation/create-solana-dapp", @@ -39,52 +39,43 @@ "dev": "vitest dev", "docker:build": "docker build -t create-solana-dapp:latest .", "docker:run": "docker run --rm --name create-solana-dapp -v \"${PWD}/dist:/workspace/dist\" -it create-solana-dapp:latest", - "lint": "eslint .", - "lint:fix": "automd && eslint . --fix", + "lint": "eslint . && prettier -c .", + "lint:fix": "automd && eslint . --fix && prettier -w .", "prepublishOnly": "pnpm pkg delete devDependencies", - "publish-impl": "npm view $npm_package_name@$npm_package_version > /dev/null 2>&1 || pnpm publish --tag ${PUBLISH_TAG:-latest} --access public --no-git-checks", + "publish-impl": "npm view $npm_package_name@$npm_package_version > /dev/null 2>&1 || pnpm publish --tag ${PUBLISH_TAG:-canary} --access public --no-git-checks", "publish-packages": "pnpm prepublishOnly && pnpm publish-impl", "test": "pnpm lint && pnpm test:types && vitest run --coverage", "test:types": "tsc --noEmit --skipLibCheck" }, "devDependencies": { - "@changesets/changelog-github": "^0.5.1", - "@changesets/cli": "^2.29.7", + "@changesets/changelog-github": "^0.5.0", + "@changesets/cli": "^2.27.10", "@types/mock-fs": "^4.13.4", - "@types/node": "^24.3.1", - "@types/semver": "^7.7.1", + "@types/node": "^22.13.1", + "@types/semver": "^7.5.8", "@types/update-notifier": "^6.0.8", - "@vitest/coverage-v8": "^3.2.4", - "automd": "^0.4.0", - "eslint": "^9.35.0", - "eslint-config-prettier": "^10.1.8", - "eslint-config-unjs": "^0.5.0", - "eslint-plugin-prettier": "^5.5.4", - "eslint-plugin-sort": "^4.0.0", - "lefthook": "^1.12.4", - "memfs": "^4.39.0", + "@vitest/coverage-v8": "^3.0.5", + "automd": "^0.3.12", + "eslint": "^9.20.0", + "eslint-config-unjs": "^0.4.2", + "memfs": "^4.17.2", "mock-fs": "^5.5.0", - "pkg-pr-new": "^0.0.59", - "prettier": "^3.6.2", - "typescript": "^5.9.2", - "unbuild": "^3.6.1", - "vitest": "^3.2.4" - }, - "packageManager": "pnpm@10.15.1", - "pnpm": { - "onlyBuiltDependencies": [ - "lefthook" - ] + "prettier": "^3.4.2", + "typescript": "^5.6.0", + "unbuild": "^3.3.1", + "vitest": "^3.0.5" }, + "packageManager": "pnpm@10.5.2", "dependencies": { - "@clack/prompts": "0.7.0", - "commander": "14.0.0", - "giget": "2.0.0", - "is-in-ci": "2.0.0", - "picocolors": "1.1.1", - "semver": "7.7.2", - "update-notifier": "7.3.1", - "zod": "4.1.5" + "@beeman/repokit": "0.0.0-canary-20250801172233", + "@clack/prompts": "^0.11.0", + "commander": "^13.1.0", + "giget": "^1.2.4", + "is-in-ci": "^1.0.0", + "picocolors": "^1.1.1", + "semver": "^7.7.1", + "update-notifier": "^7.3.1", + "zod": "^3.24.1" }, "contributors": [ { diff --git a/src/utils/create-app-task-run-init-script.ts b/src/utils/create-app-task-run-init-script.ts index 986e43f3..b509864c 100644 --- a/src/utils/create-app-task-run-init-script.ts +++ b/src/utils/create-app-task-run-init-script.ts @@ -1,41 +1,176 @@ import { log } from '@clack/prompts' +import { join } from 'node:path' +import { bold, yellow } from 'picocolors' +import { ensureTargetPath } from './ensure-target-path' import { GetArgsResult } from './get-args-result' -import { getPackageJson } from './get-package-json' -import { initScriptDelete } from './init-script-delete' -import { initScriptInstructions } from './init-script-instructions' -import { initScriptRename } from './init-script-rename' -import { initScriptKey } from './init-script-schema' -import { initScriptVersion } from './init-script-version' +import { deleteInitScript, getInitScript, InitScript } from './get-init-script' +import { searchAndReplace } from './search-and-replace' +import { validateAnchorVersion, validateSolanaVersion } from './validate-version' import { Task, taskFail } from './vendor/clack-tasks' +import { namesValues } from './vendor/names' export function createAppTaskRunInitScript(args: GetArgsResult): Task { return { enabled: !args.skipInit, + title: 'Running init script', task: async (result) => { try { - const { contents } = getPackageJson(args.targetDirectory) - const init = contents[initScriptKey] + const init = getInitScript(args.targetDirectory) if (!init) { - return result({ message: 'Init script not found' }) + return result({ message: 'Repository does not have an init script' }) } if (args.verbose) { - log.warn(`Init script started`) + log.warn(`Running init script`) } - await initScriptVersion(init.versions, args.verbose) - - await initScriptRename(args, init.rename, args.verbose) + await initCheckVersion(init) + if (args.verbose) { + log.warn(`initCheckVersion done`) + } + await initRename(args, init, args.verbose) + if (args.verbose) { + log.warn(`initRename done`) + } - const instructions: string[] = initScriptInstructions(init.instructions, args.verbose) + const instructions: string[] = (initInstructions(init) ?? []) ?.filter(Boolean) .map((msg) => msg.replace('{pm}', args.packageManager)) - initScriptDelete(args) - return result({ instructions, message: 'Init script done' }) + if (args.verbose) { + log.warn(`initInstructions done`) + } + deleteInitScript(args.targetDirectory) + if (args.verbose) { + log.warn(`deleteInitScript done`) + } + return result({ message: 'Executed init script', instructions }) } catch (error) { - taskFail(`Error running init script: ${error}`) + taskFail(`init: Error running init script: ${error}`) } }, - title: 'Running init script', } } + +async function initRename(args: GetArgsResult, init: InitScript, verbose: boolean) { + const projectContainsTemplateName = args.name.includes(args.template.name) + // Rename template to project name throughout the whole project + await searchAndReplace( + args.targetDirectory, + projectContainsTemplateName ? [] : [`template-${args.template.name}`, args.template.name], + projectContainsTemplateName ? [] : [args.name, args.name], + false, + verbose, + ) + + // Return early if there are no renames defined in the init script + if (!init?.rename) { + return + } + if (args.verbose) { + log.warn(`initRename: Found renames in init script`) + console.log(init.rename) + } + let renameCount = 0 + + // Loop through each word in the rename object + for (const from of Object.keys(init.rename)) { + renameCount += 1 + if (args.verbose) { + log.warn(`initRename: [${renameCount}] Processing ${from}`) + } + // Get the 'to' property from the rename object + const to = init.rename[from].to.replace('{{name}}', args.name.replace(/-/g, '')) + if (args.verbose) { + log.warn(`initRename: [${renameCount}] from ${from} to ${to}`) + } + + // Get the name matrix for the 'from' and the 'to' value + const fromNames = namesValues(from) + const toNames = namesValues(to) + + if (args.verbose) { + log.warn(`initRename: [${renameCount}] fromNames: ${fromNames.join(', ')}`) + log.warn(`initRename: [${renameCount}] ..toNames: ${toNames.join(', ')}`) + } + + for (const path of init.rename[from].paths) { + if (args.verbose) { + log.warn(`initRename: [${renameCount}] => Processing path ${path}`) + } + const targetPath = join(args.targetDirectory, path) + if (!(await ensureTargetPath(targetPath))) { + console.error(`init-script.rename: target does not exist ${targetPath}`) + continue + } + if (args.verbose) { + log.warn(`initRename: [${renameCount}] => Searching and replacing ${fromNames.join(', ')} in ${targetPath}`) + } + await searchAndReplace(join(args.targetDirectory, path), fromNames, toNames, args.dryRun, args.verbose) + if (args.verbose) { + log.warn(`initRename: [${renameCount}] => Done`) + } + } + if (args.verbose) { + log.warn(`initRename: [${renameCount}] Done`) + } + } +} + +async function initCheckVersion(init: InitScript) { + if (init?.versions?.anchor) { + await initCheckVersionAnchor(init.versions.anchor) + } + if (init?.versions?.solana) { + await initCheckVersionSolana(init.versions.solana) + } +} + +async function initCheckVersionAnchor(requiredVersion: string) { + try { + const { required, valid, version } = validateAnchorVersion(requiredVersion) + if (!version) { + log.warn( + [ + bold(yellow(`Could not find Anchor version. Please install Anchor.`)), + 'https://www.anchor-lang.com/docs/installation', + ].join(' '), + ) + } else if (!valid) { + log.warn( + [ + yellow(`Found Anchor version ${version}. Expected Anchor version ${required}.`), + 'https://www.anchor-lang.com/release-notes/0.30.1', + ].join(' '), + ) + } + } catch (error_) { + log.warn(`Error ${error_}`) + } +} + +async function initCheckVersionSolana(requiredVersion: string) { + try { + const { required, valid, version } = validateSolanaVersion(requiredVersion) + if (!version) { + log.warn( + [ + bold(yellow(`Could not find Solana version. Please install Solana.`)), + 'https://docs.solana.com/cli/install-solana-cli-tools', + ].join(' '), + ) + } else if (!valid) { + log.warn( + [ + yellow(`Found Solana version ${version}. Expected Solana version ${required}.`), + 'https://docs.solana.com/cli/install-solana-cli-tools', + ].join(' '), + ) + } + } catch (error_) { + log.warn(`Error ${error_}`) + } +} + +function initInstructions(init: InitScript) { + return init?.instructions?.length === 0 ? [] : init?.instructions +} diff --git a/src/utils/init-script-schema.ts b/src/utils/init-script-schema.ts index 66e0b0af..434bc6fb 100644 --- a/src/utils/init-script-schema.ts +++ b/src/utils/init-script-schema.ts @@ -11,13 +11,23 @@ export const InitScriptSchemaVersions = z.object({ solana: z.string().optional(), }) -export const InitScriptSchemaRename = z.record( - z.object({ - // TODO: Rename 'paths' to 'in' (breaking change) - paths: z.array(z.string()), - to: z.string(), - }), -) +const InitScriptSchemaRenameEntryBase = z.object({ + // Accept alias `in` for backward/forward compatibility and normalize to `paths` + in: z.array(z.string()).optional(), + paths: z.array(z.string()).optional(), + to: z.string(), +}) + +export const InitScriptSchemaRename = z.record(InitScriptSchemaRenameEntryBase).transform((input) => { + // Normalize entries: if `in` is provided, move to `paths` + const normalized: Record = {} + for (const key of Object.keys(input)) { + const entry = input[key] as { in?: string[]; paths?: string[]; to: string } + const paths = entry.paths ?? entry.in ?? [] + normalized[key] = { paths, to: entry.to } + } + return normalized +}) export const InitScriptSchema = z.object({ instructions: InitScriptSchemaInstructions.optional(), diff --git a/src/utils/search-and-replace.ts b/src/utils/search-and-replace.ts index b4e75321..6292cd92 100644 --- a/src/utils/search-and-replace.ts +++ b/src/utils/search-and-replace.ts @@ -1,7 +1,7 @@ import { readdir, readFile, rename, writeFile } from 'node:fs/promises' import { join } from 'node:path' -const EXCLUDED_DIRECTORIES = new Set(['dist', 'coverage', 'node_modules', '.git', 'tmp']) +const EXCLUDED_DIRECTORIES = new Set(['dist', 'coverage', 'node_modules', '.git', '.github', 'tmp']) export async function searchAndReplace( rootFolder: string, @@ -14,6 +14,13 @@ export async function searchAndReplace( throw new Error('fromStrings and toStrings arrays must have the same length') } + if (isVerbose) { + console.log(`searchAndReplace: ${rootFolder} Searching for ${fromStrings.join(', ')}`, { + rootFolder, + fromStrings, + toStrings, + }) + } async function processFile(filePath: string): Promise { try { const content = await readFile(filePath, 'utf8') @@ -81,8 +88,14 @@ export async function searchAndReplace( } async function renamePaths(directoryPath: string): Promise { + if (isVerbose) { + console.log(`searchAndReplace [renamePaths]: START Renaming paths in ${directoryPath}`) + } try { const entries = await readdir(directoryPath, { withFileTypes: true }) + if (isVerbose) { + console.log(`searchAndReplace [renamePaths]: Renaming paths in ${directoryPath}`, { entries }) + } for (const entry of entries) { if (EXCLUDED_DIRECTORIES.has(entry.name)) { @@ -92,17 +105,36 @@ export async function searchAndReplace( continue } + if (isVerbose) { + console.log(`searchAndReplace [renamePaths] => Renaming ${entry.name} to ${entry.name}`) + } const oldPath = join(directoryPath, entry.name) - let newName = entry.name + let newPath = oldPath for (const [i, fromString] of fromStrings.entries()) { - newName = newName.replace(new RegExp(fromString, 'g'), toStrings[i]) + if (directoryPath.endsWith(toStrings[i])) { + if (isVerbose) { + console.log(`searchAndReplace [renamePaths] => [${i}] Skipping ${fromString} with ${toStrings[i]}`) + } + continue + } + if (isVerbose) { + console.log(`searchAndReplace [renamePaths] => [${i}] Replacing ${fromString} with ${toStrings[i]}`) + } + newPath = newPath.replace(new RegExp(fromString, 'g'), toStrings[i]) + if (isVerbose) { + console.log(`searchAndReplace [renamePaths] => [${i}] Renamed ${oldPath} -> ${newPath}`) + } } - const newPath = join(directoryPath, newName) - if (oldPath !== newPath) { + if (isVerbose) { + console.log(`searchAndReplace [renamePaths] => Renaming ${oldPath} to ${newPath}`) + } if (!isDryRun) { + if (isVerbose) { + console.log(`searchAndReplace [renamePaths] => Renaming ${oldPath} to ${newPath}`) + } await rename(oldPath, newPath) } if (isVerbose) { @@ -111,16 +143,25 @@ export async function searchAndReplace( } if (entry.isDirectory()) { - await renamePaths(entry.isDirectory() ? newPath : oldPath) + await renamePaths(newPath) } } + if (isVerbose) { + console.log(`searchAndReplace [renamePaths]: END Renaming paths in ${directoryPath}`) + } } catch (error) { console.error(`Error renaming paths in ${directoryPath}:`, error) } } try { + if (isVerbose) { + console.log(`searchAndReplace: Processing directory ${rootFolder}`) + } await processDirectory(rootFolder) + if (isVerbose) { + console.log(`searchAndReplace: Renaming paths in ${rootFolder}`) + } await renamePaths(rootFolder) if (isVerbose) { console.log(isDryRun ? 'Dry run completed' : 'Search and replace completed') diff --git a/test/init-script-schema.test.ts b/test/init-script-schema.test.ts new file mode 100644 index 00000000..a2a35b13 --- /dev/null +++ b/test/init-script-schema.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { InitScriptSchema } from '../src/utils/init-script-schema' + +describe('InitScriptSchema - rename alias', () => { + const parseRename = (rename: unknown) => InitScriptSchema.parse({ rename }).rename + + it('should accept `in` alias and normalize to `paths`', () => { + const parsed = parseRename({ example: { in: ['some/path/to/file'], to: '{{name}}Example' } } as unknown) + + // @ts-expect-error normalized by schema transform + expect(parsed.example.in).toBeUndefined() + expect(parsed?.example.paths).toEqual(['some/path/to/file']) + expect(parsed?.example.to).toBe('{{name}}Example') + }) + + it('should accept `paths` field without changes', () => { + const parsed = parseRename({ example: { paths: ['some/path/to/file'], to: '{{name}}Example' } }) + + expect(parsed?.example.paths).toEqual(['some/path/to/file']) + expect(parsed?.example.to).toBe('{{name}}Example') + }) + + it('should prioritize `paths` over `in` when both provided', () => { + const entry = { in: ['path/from/in'], paths: ['path/from/paths'], to: '{{name}}Example' } as unknown + const parsed = parseRename({ example: entry }) + + expect(parsed?.example.paths).toEqual(['path/from/paths']) + }) + + it('should handle empty arrays', () => { + const parsed = parseRename({ example: { in: [], to: '{{name}}Example' } } as unknown) + + expect(parsed?.example.paths).toEqual([]) + }) + + it('should handle mixed `in` and `paths` usage', () => { + const rename = { + example1: { in: ['some/path/to/file1'], to: '{{name}}Example1' }, + example2: { paths: ['some/path/to/file2'], to: '{{name}}Example2' }, + } as unknown + const parsed = parseRename(rename) + + expect(parsed?.example1.paths).toEqual(['some/path/to/file1']) + expect(parsed?.example2.paths).toEqual(['some/path/to/file2']) + }) +})