diff --git a/packages/cli/src/commands/generate.js b/packages/cli/src/commands/generate.js index b63f21a271..d9919268b8 100644 --- a/packages/cli/src/commands/generate.js +++ b/packages/cli/src/commands/generate.js @@ -13,6 +13,7 @@ import * as generateJob from './generate/job/job.js' import * as generateLayout from './generate/layout/layout.js' import * as generateModel from './generate/model/model.js' import * as generateOgImage from './generate/ogImage/ogImage.js' +import * as generatePackage from './generate/package/package.js' import * as generatePage from './generate/page/page.js' import * as generateRealtime from './generate/realtime/realtime.js' import * as generateScaffold from './generate/scaffold/scaffold.js' @@ -48,6 +49,7 @@ export const builder = (yargs) => .command(generateLayout) .command(generateModel) .command(generateOgImage) + .command(generatePackage) .command(generatePage) .command(generateRealtime) .command(generateScaffold) diff --git a/packages/cli/src/commands/generate/package/__tests__/__snapshots__/package.test.ts.snap b/packages/cli/src/commands/generate/package/__tests__/__snapshots__/package.test.ts.snap new file mode 100644 index 0000000000..1d4b4c62e6 --- /dev/null +++ b/packages/cli/src/commands/generate/package/__tests__/__snapshots__/package.test.ts.snap @@ -0,0 +1,207 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`packageHandler > files > multi-word package names > creates a multi-word package 1`] = ` +"# Shared Package '@my-camel-case-app/form-validators' + +Use code in this package by adding it to the dependencies on the side you want +to use it, with the special \`workspace:*\` version. After that you can import it +into your code: + +\`\`\`json + "dependencies": { + "@my-camel-case-app/form-validators": "workspace:*" + } +\`\`\` + +\`\`\`javascript +import { formValidators } from '@my-camel-case-app/form-validators'; +\`\`\` +" +`; + +exports[`packageHandler > files > multi-word package names > creates a multi-word package 2`] = ` +"export function formValidators() { + return 0 +} +" +`; + +exports[`packageHandler > files > multi-word package names > creates a multi-word package 3`] = ` +"import { formValidators } from './index.js' + +describe('formValidators', () => { + it('should not throw any errors', async () => { + expect(formValidators()).not.toThrow() + }) +}) +" +`; + +exports[`packageHandler > files > multi-word package names > creates a multiWord package 1`] = ` +"# Shared Package '@my-camel-case-app/form-validators' + +Use code in this package by adding it to the dependencies on the side you want +to use it, with the special \`workspace:*\` version. After that you can import it +into your code: + +\`\`\`json + "dependencies": { + "@my-camel-case-app/form-validators": "workspace:*" + } +\`\`\` + +\`\`\`javascript +import { formValidators } from '@my-camel-case-app/form-validators'; +\`\`\` +" +`; + +exports[`packageHandler > files > multi-word package names > creates a multiWord package 2`] = ` +"export function formValidators() { + return 0 +} +" +`; + +exports[`packageHandler > files > multi-word package names > creates a multiWord package 3`] = ` +"import { formValidators } from './index.js' + +describe('formValidators', () => { + it('should not throw any errors', async () => { + expect(formValidators()).not.toThrow() + }) +}) +" +`; + +exports[`packageHandler > files > multi-word package names > uses the provided scope for multiWord-package name > index 1`] = ` +"export function formValidatorsPkg() { + return 0 +} +" +`; + +exports[`packageHandler > files > multi-word package names > uses the provided scope for multiWord-package name > packageJson 1`] = ` +"{ + "name": "@my-org/form-validators-pkg", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "watch": "tsc --watch" + }, + "devDependencies": { + "@cedarjs/testing": "2.2.1", + "typescript": "5.9.3" + } +} +" +`; + +exports[`packageHandler > files > multi-word package names > uses the provided scope for multiWord-package name > readme 1`] = ` +"# Shared Package '@my-org/form-validators-pkg' + +Use code in this package by adding it to the dependencies on the side you want +to use it, with the special \`workspace:*\` version. After that you can import it +into your code: + +\`\`\`json + "dependencies": { + "@my-org/form-validators-pkg": "workspace:*" + } +\`\`\` + +\`\`\`javascript +import { formValidatorsPkg } from '@my-org/form-validators-pkg'; +\`\`\` +" +`; + +exports[`packageHandler > files > multi-word package names > uses the provided scope for multiWord-package name > test 1`] = ` +"import { formValidatorsPkg } from './index.js' + +describe('formValidatorsPkg', () => { + it('should not throw any errors', async () => { + expect(formValidatorsPkg()).not.toThrow() + }) +}) +" +`; + +exports[`packageHandler > files > single word package name > infers package scope from project path > README.md 1`] = ` +"# Shared Package '@my-cedar-app/foo' + +Use code in this package by adding it to the dependencies on the side you want +to use it, with the special \`workspace:*\` version. After that you can import it +into your code: + +\`\`\`json + "dependencies": { + "@my-cedar-app/foo": "workspace:*" + } +\`\`\` + +\`\`\`javascript +import { foo } from '@my-cedar-app/foo'; +\`\`\` +" +`; + +exports[`packageHandler > files > single word package name > infers package scope from project path > foo.test.ts 1`] = ` +"import { foo } from './index.js' + +describe('foo', () => { + it('should not throw any errors', async () => { + expect(foo()).not.toThrow() + }) +}) +" +`; + +exports[`packageHandler > files > single word package name > infers package scope from project path > index.ts 1`] = ` +"export function foo() { + return 0 +} +" +`; + +exports[`packageHandler > files > single word package name > infers package scope from project path > package.json 1`] = ` +"{ + "name": "@my-cedar-app/foo", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "watch": "tsc --watch" + }, + "devDependencies": { + "@cedarjs/testing": "2.2.1", + "typescript": "5.9.3" + } +} +" +`; + +exports[`packageHandler > files > single word package name > infers package scope from project path > tsconfig.json 1`] = ` +"{ + "compilerOptions": { + "composite": true, + "target": "ES2023", + "module": "Node20", + "esModuleInterop": true, + "skipLibCheck": true, + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "sourceMap": true, + "declaration": true, + "declarationMap": true, + }, + "include": ["src"], +} +" +`; diff --git a/packages/cli/src/commands/generate/package/__tests__/package.test.ts b/packages/cli/src/commands/generate/package/__tests__/package.test.ts new file mode 100644 index 0000000000..6df43facfb --- /dev/null +++ b/packages/cli/src/commands/generate/package/__tests__/package.test.ts @@ -0,0 +1,456 @@ +globalThis.__dirname = __dirname + +// Load shared mocks +import '../../../../lib/test.js' + +const mockBase = vi.hoisted(() => ({ path: '/path/to/project' })) + +const { memfs, ufs, vol } = await vi.hoisted(async () => { + const { vol, fs: memfs } = await import('memfs') + const { ufs } = await import('unionfs') + return { memfs, ufs, vol } +}) + +vi.mock('../../../../lib/index.js', async (importOriginal) => { + const originalProjectConfig = await importOriginal() + + return { + ...originalProjectConfig, + getPaths: () => { + return { + base: mockBase.path, + api: { + base: path.join(mockBase.path, 'api'), + dataMigrations: path.join(mockBase.path, 'api/dataMigrations'), + src: path.join(mockBase.path, 'api/src'), + jobs: path.join(mockBase.path, 'api/src/jobs'), + services: path.join(mockBase.path, 'api/src/services'), + directives: path.join(mockBase.path, 'api/src/directives'), + graphql: path.join(mockBase.path, 'api/src/graphql'), + functions: path.join(mockBase.path, 'api/src/functions'), + }, + web: { + base: path.join(mockBase.path, 'web'), + config: path.join(mockBase.path, 'web/config'), + src: path.join(mockBase.path, 'web/src'), + routes: path.join(mockBase.path, 'web/src/Routes.js'), + components: path.join(mockBase.path, 'web/src/components'), + layouts: path.join(mockBase.path, 'web/src/layouts'), + pages: path.join(mockBase.path, 'web/src/pages'), + app: path.join(mockBase.path, 'web/src/App.js'), + }, + scripts: path.join(mockBase.path, 'scripts'), + packages: path.join(mockBase.path, 'packages'), + generatorTemplates: path.join(mockBase.path, 'generatorTemplates'), + generated: { + base: path.join(mockBase.path, '.redwood'), + schema: path.join(mockBase.path, '.redwood/schema.graphql'), + types: { + includes: path.join(mockBase.path, '.redwood/types/includes'), + mirror: path.join(mockBase.path, '.redwood/types/mirror'), + }, + }, + } + }, + } +}) + +import fs from 'node:fs' +import path from 'node:path' + +import { vi, describe, it, expect, afterEach } from 'vitest' + +// @ts-expect-error - No types for JS files +import type * as LibIndex from '../../../../lib/index.js' +// TODO: Separate test file for filesTask.js +// @ts-expect-error - No types for JS files +import * as filesTask from '../filesTask.js' +// @ts-expect-error - No types for JS files +import * as packageHandler from '../packageHandler.js' + +vi.mock('node:fs', async (importOriginal) => { + const { wrapFsForUnionfs } = await import( + // @ts-expect-error - No types for JS files + '../../../../__tests__/ufsFsProxy.js' + ) + const originalFs = await importOriginal() + ufs.use(wrapFsForUnionfs(originalFs)).use(memfs as any) + return { + ...ufs, + default: ufs, + } +}) + +afterEach(() => { + mockBase.path = '/path/to/project' +}) + +describe('packageHandler', () => { + describe('handler', () => { + it('throws on package name with two slashes', async () => { + await expect(() => + packageHandler.handler({ name: 'package//name' }), + ).rejects.toThrowError( + 'Invalid package name "package//name". Package names can have at most one slash.', + ) + + await expect(() => + packageHandler.handler({ name: '@test-org/package/name' }), + ).rejects.toThrowError( + 'Invalid package name "@test-org/package/name". Package names can have at most one slash.', + ) + }) + }) + + describe('nameVariants', () => { + it('uses the provided scope name', () => { + expect(packageHandler.nameVariants('@myOrg/formValidators-pkg')).toEqual({ + name: 'formValidators-pkg', + folderName: 'form-validators-pkg', + packageName: '@my-org/form-validators-pkg', + fileName: 'formValidatorsPkg', + }) + }) + + it('constructs a scope name if none is provided', () => { + expect(packageHandler.nameVariants('foo')).toEqual({ + name: 'foo', + folderName: 'foo', + packageName: '@project/foo', + fileName: 'foo', + }) + }) + + it('handles camelCase when constructing a scope name', () => { + mockBase.path = '/path/to/cedarInc' + expect(packageHandler.nameVariants('MyFoo')).toEqual({ + name: 'MyFoo', + folderName: 'my-foo', + packageName: '@cedar-inc/my-foo', + fileName: 'myFoo', + }) + }) + }) + + describe('files', () => { + describe('single word package name', () => { + it('infers package scope from project path', async () => { + mockBase.path = '/path/to/my-cedar-app' + + const files = await filesTask.files({ + ...packageHandler.nameVariants('foo'), + typescript: true, + }) + + const fileNames = Object.keys(files) + expect(fileNames.length).toEqual(5) + + expect(fileNames).toEqual( + expect.arrayContaining([ + expect.stringContaining('README.md'), + expect.stringContaining('package.json'), + expect.stringContaining('tsconfig.json'), + expect.stringContaining('index.ts'), + expect.stringContaining('foo.test.ts'), + ]), + ) + + const packageJsonPath = path.normalize( + mockBase.path + '/packages/foo/package.json', + ) + const readmePath = path.normalize( + mockBase.path + '/packages/foo/README.md', + ) + const tsconfigJsonPath = path.normalize( + mockBase.path + '/packages/foo/tsconfig.json', + ) + const indexPath = path.normalize( + mockBase.path + '/packages/foo/src/index.ts', + ) + const testPath = path.normalize( + mockBase.path + '/packages/foo/src/foo.test.ts', + ) + + // Both making sure the file is valid json (parsing would fail otherwise) + // and that the package name is correct + const packageJson = JSON.parse(files[packageJsonPath]) + + expect(packageJson.name).toEqual('@my-cedar-app/foo') + expect(files[packageJsonPath]).toMatchSnapshot('package.json') + expect(files[readmePath]).toMatchSnapshot('README.md') + expect(files[tsconfigJsonPath]).toMatchSnapshot('tsconfig.json') + expect(files[indexPath]).toMatchSnapshot('index.ts') + expect(files[testPath]).toMatchSnapshot('foo.test.ts') + }) + + it('uses kebab-case for package scope', async () => { + // Using both a hyphen and camelCase here on purpose to make sure it's + // handled correctly + mockBase.path = '/path/to/my-camelCaseApp' + + const files = await filesTask.files({ + ...packageHandler.nameVariants('foo'), + typescript: true, + }) + + const packageJsonPath = path.normalize( + mockBase.path + '/packages/foo/package.json', + ) + + // Both making sure the file is valid json (parsing would fail otherwise) + // and that the package name is correct + const packageJson = JSON.parse(files[packageJsonPath]) + + expect(packageJson.name).toEqual('@my-camel-case-app/foo') + }) + + it('uses the provided package scope name', async () => { + const files = await filesTask.files({ + ...packageHandler.nameVariants('@my-org/foo'), + typescript: true, + }) + + const packageJsonPath = path.normalize( + mockBase.path + '/packages/foo/package.json', + ) + + // Both making sure the file is valid json (parsing would fail otherwise) + // and that the package name is correct + const packageJson = JSON.parse(files[packageJsonPath]) + + expect(packageJson.name).toEqual('@my-org/foo') + }) + }) + + // I had to decide if I wanted the folder name for multi-word packages to be + // hyphenated (kebab-case) or camelCase. I decided to use kebab-case because + // it matches what the package name is. So, just like we use PascalCase for + // folder names for React components, we use kebab-case for folder names for + // packages. + describe('multi-word package names', () => { + it('creates a multi-word package', async () => { + mockBase.path = '/path/to/myCamelCaseApp' + + const files = await filesTask.files({ + ...packageHandler.nameVariants('form-validators'), + typescript: true, + }) + + const readmePath = path.normalize( + mockBase.path + '/packages/form-validators/README.md', + ) + const indexPath = path.normalize( + mockBase.path + '/packages/form-validators/src/index.ts', + ) + const testPath = path.normalize( + mockBase.path + + '/packages/form-validators/src/formValidators.test.ts', + ) + + expect(files[readmePath]).toMatchSnapshot() + expect(files[indexPath]).toMatchSnapshot() + expect(files[testPath]).toMatchSnapshot() + }) + + it('creates a multiWord package', async () => { + mockBase.path = '/path/to/myCamelCaseApp' + + const files = await filesTask.files({ + ...packageHandler.nameVariants('formValidators'), + typescript: true, + }) + + const readmePath = path.normalize( + mockBase.path + '/packages/form-validators/README.md', + ) + const indexPath = path.normalize( + mockBase.path + '/packages/form-validators/src/index.ts', + ) + const testPath = path.normalize( + mockBase.path + + '/packages/form-validators/src/formValidators.test.ts', + ) + + expect(files[readmePath]).toMatchSnapshot() + expect(files[indexPath]).toMatchSnapshot() + expect(files[testPath]).toMatchSnapshot() + }) + + it('uses the provided scope for multiWord-package name', async () => { + const files = await filesTask.files({ + ...packageHandler.nameVariants('@myOrg/formValidators-pkg'), + typescript: true, + }) + + const readmePath = path.normalize( + mockBase.path + '/packages/form-validators-pkg/README.md', + ) + const packageJsonPath = path.normalize( + mockBase.path + '/packages/form-validators-pkg/package.json', + ) + const indexPath = path.normalize( + mockBase.path + '/packages/form-validators-pkg/src/index.ts', + ) + const testPath = path.normalize( + mockBase.path + + '/packages/form-validators-pkg/src/formValidatorsPkg.test.ts', + ) + + // Both making sure the file is valid json (parsing would fail otherwise) + // and that the package name is correct + const packageJson = JSON.parse(files[packageJsonPath]) + + expect(packageJson.name).toEqual('@my-org/form-validators-pkg') + expect(files[indexPath]).toMatch( + 'export function formValidatorsPkg() {', + ) + + expect(files[readmePath]).toMatchSnapshot('readme') + expect(files[packageJsonPath]).toMatchSnapshot('packageJson') + expect(files[indexPath]).toMatchSnapshot('index') + expect(files[testPath]).toMatchSnapshot('test') + }) + }) + + it('returns the corrent files for JS', async () => { + const jsFiles = await filesTask.files({ + ...packageHandler.nameVariants('Sample'), + }) + const fileNames = Object.keys(jsFiles) + expect(fileNames.length).toEqual(5) + + expect(fileNames).toEqual( + expect.arrayContaining([ + expect.stringContaining('README.md'), + expect.stringContaining('package.json'), + // TODO: Make the script output jsconfig.json + expect.stringContaining('tsconfig.json'), + expect.stringContaining('index.js'), + expect.stringContaining('sample.test.js'), + ]), + ) + }) + }) + + describe('updateTsconfig', () => { + const tsconfigPath = path.join(mockBase.path, 'api', 'tsconfig.json') + + const tsconfig = ` + { + "compilerOptions": { + "noEmit": true, + "allowJs": true, + "esModuleInterop": true, + "target": "ES2023", + "module": "Node16", // This is the line to update + "moduleResolution": "Node16", + "skipLibCheck": false, + "rootDirs": ["./src", "../.redwood/types/mirror/api/src"], + "paths": { + "src/*": ["./src/*", "../.redwood/types/mirror/api/src/*"], + "types/*": ["./types/*", "../types/*"], + "@cedarjs/testing": ["../node_modules/@cedarjs/testing/api"] + }, + "typeRoots": ["../node_modules/@types", "./node_modules/@types"], + "types": ["jest"], + // No end-of-line comma here, as you don't need that in tsconfig + // files. We shouldn't insert one when editing this file + "jsx": "react-jsx" + }, + "include": [ + "src", + "../.redwood/types/includes/all-*", + "../.redwood/types/includes/api-*", + "../types" + ] + } + ` + + it('updates from Node16 to Node20', async () => { + vol.fromJSON( + { + [tsconfigPath]: tsconfig, + 'redwood.toml': '', + }, + mockBase.path, + ) + + await packageHandler.updateTsconfig({ skip: () => {} }) + + // Comments are valid in tsconfig files, we want to make sure we don't + // remove those + expect(fs.readFileSync(tsconfigPath, 'utf8')).toMatch( + /"module": "Node20", \/\/ This is the line to update/, + ) + expect(fs.readFileSync(tsconfigPath, 'utf8')).toEqual( + tsconfig.replace('"module": "Node16",', '"module": "Node20",'), + ) + }) + + it('skips update if "module" is already Node20', async () => { + const node20tsconfig = tsconfig.replace( + '"module": "Node16",', + '"module": "Node20",', + ) + vol.fromJSON( + { + [tsconfigPath]: node20tsconfig, + 'redwood.toml': '', + }, + mockBase.path, + ) + + const skipFn = vi.fn() + await packageHandler.updateTsconfig({ skip: skipFn }) + + expect(skipFn).toHaveBeenCalled() + expect(fs.readFileSync(tsconfigPath, 'utf8')).toMatch( + /"module": "Node20"/, + ) + }) + + it('skips update if "module" is already NodeNext', async () => { + vol.fromJSON( + { + [tsconfigPath]: tsconfig.replace( + '"module": "Node16",', + '"module": "NodeNext",', + ), + 'redwood.toml': '', + }, + mockBase.path, + ) + + const skipFn = vi.fn() + await packageHandler.updateTsconfig({ skip: skipFn }) + + expect(skipFn).toHaveBeenCalled() + expect(fs.readFileSync(tsconfigPath, 'utf8')).toMatch( + /"module": "NodeNext"/, + ) + }) + }) + + // This test is to make sure we don't forget to update the template when we + // upgrade TypeScript + it('has the correct version of TypeScript in the generated package', () => { + const cedarPackageJsonPath = path.normalize( + path.join(__dirname, ...Array(7).fill('..'), 'package.json'), + ) + const packageJson = JSON.parse( + fs.readFileSync(cedarPackageJsonPath, 'utf8'), + ) + + const packageJsonTemplatePath = path.join( + __dirname, + '..', + 'templates', + 'package.json.template', + ) + const packageJsonTemplate = fs.readFileSync(packageJsonTemplatePath, 'utf8') + + expect(packageJsonTemplate).toContain( + `"typescript": "${packageJson.devDependencies.typescript}"`, + ) + }) +}) diff --git a/packages/cli/src/commands/generate/package/filesTask.js b/packages/cli/src/commands/generate/package/filesTask.js new file mode 100644 index 0000000000..51802ea40a --- /dev/null +++ b/packages/cli/src/commands/generate/package/filesTask.js @@ -0,0 +1,113 @@ +import path from 'node:path' + +import { transformTSToJS } from '../../../lib/index.js' +import { templateForFile } from '../yargsHandlerHelpers.js' + +/** + * Generates the file structure and content for a new package. + * + * Creates all necessary files for a package including source files, configuration, + * README, and optionally test files. Handles both TypeScript and JavaScript generation. + * + * @param {Object} options - The file generation options + * @param {string} options.name - The package name + * @param {string} options.folderName - The folder name for the package (param-case) + * @param {string} options.packageName - The full scoped package name (e.g., '@org/package') + * @param {string} options.fileName - The camelCase file name + * @param {boolean} [options.typescript] - Whether to generate TypeScript files (defaults to JS if not provided) + * @param {boolean} [options.tests=true] - Whether to generate test files + * + * @returns {Promise} A promise that resolves to an object mapping file paths to their content + * + * @example + * // Generate TypeScript package files with tests + * const fileMap = await files({ + * name: 'MyPackage', + * folderName: 'my-package', + * packageName: '@myorg/my-package', + * fileName: 'myPackage', + * typescript: true, + * tests: true + * }) + */ +export const files = async ({ + name, + folderName, + packageName, + fileName, + typescript, + tests: generateTests = true, + ...rest +}) => { + const extension = typescript ? '.ts' : '.js' + + const outputFiles = [] + + const indexFile = await templateForFile({ + name, + side: 'packages', + generator: 'package', + templatePath: 'index.ts.template', + templateVars: rest, + outputPath: path.join(folderName, 'src', `index${extension}`), + }) + + const readmeFile = await templateForFile({ + name, + side: 'packages', + generator: 'package', + templatePath: 'README.md.template', + templateVars: { packageName, ...rest }, + outputPath: path.join(folderName, 'README.md'), + }) + + const packageJsonFile = await templateForFile({ + name, + side: 'packages', + generator: 'package', + templatePath: 'package.json.template', + templateVars: { packageName, ...rest }, + outputPath: path.join(folderName, 'package.json'), + }) + + const tsconfigFile = await templateForFile({ + name, + side: 'packages', + generator: 'package', + templatePath: 'tsconfig.json.template', + templateVars: { packageName, ...rest }, + outputPath: path.join(folderName, 'tsconfig.json'), + }) + + outputFiles.push(indexFile) + outputFiles.push(readmeFile) + outputFiles.push(packageJsonFile) + outputFiles.push(tsconfigFile) + + if (generateTests) { + const testFile = await templateForFile({ + name, + side: 'packages', + generator: 'package', + templatePath: 'test.ts.template', + templateVars: rest, + outputPath: path.join(folderName, 'src', `${fileName}.test${extension}`), + }) + + outputFiles.push(testFile) + } + + return outputFiles.reduce(async (accP, [outputPath, content]) => { + const acc = await accP + + const template = + typescript || outputPath.endsWith('.md') || outputPath.endsWith('.json') + ? content + : await transformTSToJS(outputPath, content) + + return { + [outputPath]: template, + ...acc, + } + }, Promise.resolve({})) +} diff --git a/packages/cli/src/commands/generate/package/package.js b/packages/cli/src/commands/generate/package/package.js new file mode 100644 index 0000000000..33cd07beaa --- /dev/null +++ b/packages/cli/src/commands/generate/package/package.js @@ -0,0 +1,11 @@ +import { createHandler, createBuilder } from '../yargsCommandHelpers.js' + +export const command = 'package ' +export const description = 'Generate a workspace Package' + +export const builder = createBuilder({ + componentName: 'package', + addStories: false, +}) + +export const handler = createHandler('package') diff --git a/packages/cli/src/commands/generate/package/packageHandler.js b/packages/cli/src/commands/generate/package/packageHandler.js new file mode 100644 index 0000000000..a10edd9c82 --- /dev/null +++ b/packages/cli/src/commands/generate/package/packageHandler.js @@ -0,0 +1,214 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { paramCase, camelCase } from 'change-case' +import execa from 'execa' +import { Listr } from 'listr2' + +import { recordTelemetryAttributes } from '@cedarjs/cli-helpers' +import { errorTelemetry } from '@cedarjs/telemetry' + +import c from '../../../lib/colors.js' +import { getPaths, writeFilesTask } from '../../../lib/index.js' +import { prepareForRollback } from '../../../lib/rollback.js' + +import { files } from './filesTask.js' + +/** + * @typedef {Object} ListrContext + * @property {Object} nameVariants - The parsed name variants for the package + * @property {string} nameVariants.name - The base name + * @property {string} nameVariants.folderName - The param-case folder name + * @property {string} nameVariants.packageName - The full scoped package name + * @property {string} nameVariants.fileName - The camelCase file name + */ + +// Exported for testing +export function nameVariants(nameArg) { + const base = path.basename(getPaths().base) + + const [orgName, name] = nameArg.startsWith('@') + ? nameArg.slice(1).split('/', 2) + : [paramCase(base), nameArg] + + const folderName = paramCase(name) + const packageName = '@' + paramCase(orgName) + '/' + folderName + const fileName = camelCase(name) + + return { name, folderName, packageName, fileName } +} + +// Exported for testing +export async function updateTsconfig(task) { + const tsconfigPath = path.join(getPaths().api.base, 'tsconfig.json') + const tsconfig = await fs.promises.readFile(tsconfigPath, 'utf8') + const tsconfigLines = tsconfig.split('\n') + + const moduleLineIndex = tsconfigLines.findIndex((line) => + /^\s*"module":\s*"/.test(line), + ) + const moduleLine = tsconfigLines[moduleLineIndex] + + if ( + moduleLine.toLowerCase().includes('node20') || + // While Cedar doesn't officially endorse the usage of NodeNext, it + // will still work here, so I won't overwrite it + moduleLine.toLowerCase().includes('nodenext') + ) { + task.skip('tsconfig already up to date') + return + } + + tsconfigLines[moduleLineIndex] = moduleLine.replace( + /":\s*"[\w\d]+"/, + '": "Node20"', + ) + + await fs.promises.writeFile(tsconfigPath, tsconfigLines.join('\n')) +} + +async function installAndBuild(folderName) { + const packagePath = path.join('packages', folderName) + await execa('yarn', ['install'], { stdio: 'inherit', cwd: getPaths().base }) + // TODO: `yarn cedar build ` + await execa('yarn', ['build'], { stdio: 'inherit', cwd: packagePath }) +} + +/** + * Handler for the generate package command. + * + * Creates a new package in the Cedar monorepo with the specified name and + * configuration. + * Sets up the package structure including source files, tests, configuration + * files, and updates the workspace configuration. + * + * @param {Object} options - The command options + * @param {string} options.name - The package name (can be scoped like + * '@org/package' or just 'package') + * @param {boolean} [options.force] - Whether to overwrite existing files + * @param {boolean} [options.typescript] - Whether to generate TypeScript files + * (passed in rest) + * @param {boolean} [options.tests] - Whether to generate test files (passed in + * rest) + * @param {boolean} [options.rollback] - Whether to enable rollback on failure + * (passed in rest) + * + * @returns {Promise} + * + * @throws {Error} If the package name contains more than one slash + * @throws {Error} If the workspace configuration is invalid + * + * @example + * // Generate a basic package + * await handler({ name: 'my-package', force: false }) + * + * @example + * // Generate a scoped TypeScript package with tests + * await handler({ name: '@myorg/my-package', force: false, typescript: true, tests: true }) + */ +export const handler = async ({ name, force, ...rest }) => { + recordTelemetryAttributes({ + command: 'generate package', + force, + rollback: rest.rollback, + }) + + if (name.replaceAll('/', '').length < name.length - 1) { + throw new Error( + `Invalid package name "${name}". ` + + 'Package names can have at most one slash.', + ) + } + + let packageFiles = {} + const tasks = new Listr( + /** @type {import('listr2').ListrTask[]} */ ([ + { + title: 'Parsing package name...', + task: (ctx) => { + ctx.nameVariants = nameVariants(name) + }, + }, + { + title: 'Updating workspace config...', + task: async (ctx, task) => { + const rootPackageJsonPath = path.join(getPaths().base, 'package.json') + const packageJson = JSON.parse( + await fs.promises.readFile(rootPackageJsonPath, 'utf8'), + ) + + if (!Array.isArray(packageJson.workspaces)) { + throw new Error( + 'Invalid workspace config in ' + rootPackageJsonPath, + ) + } + + const packagePath = `packages/${ctx.nameVariants.folderName}` + const hasWildcardPackagesWorkspace = + packageJson.workspaces.includes('packages/*') + const hasNamedPackagesWorkspace = + packageJson.workspaces.includes(packagePath) + const hasOtherNamedPackages = packageJson.workspaces.some( + (workspace) => + workspace.startsWith('packages/') && workspace !== packagePath, + ) + + if (hasWildcardPackagesWorkspace || hasNamedPackagesWorkspace) { + task.skip('Workspaces already configured') + } else { + if (hasOtherNamedPackages) { + packageJson.workspaces.push(packagePath) + } else { + packageJson.workspaces.push('packages/*') + } + + await fs.promises.writeFile( + rootPackageJsonPath, + JSON.stringify(packageJson, null, 2), + ) + } + }, + }, + { + title: 'Updating api side tsconfig file...', + task: (_ctx, task) => updateTsconfig(task), + }, + { + title: 'Generating package files...', + task: async (ctx) => { + packageFiles = await files({ ...ctx.nameVariants, ...rest }) + return writeFilesTask(packageFiles, { overwriteExisting: force }) + }, + }, + { + title: 'Installing and building...', + task: (ctx) => installAndBuild(ctx.nameVariants.folderName), + }, + { + title: 'Cleaning up...', + task: () => { + execa.sync('yarn', [ + 'eslint', + '--fix', + '--config', + `${getPaths().base}/node_modules/@cedarjs/eslint-config/index.js`, + ...Object.keys(packageFiles), + ]) + }, + }, + ]), + { rendererOptions: { collapseSubtasks: false }, exitOnError: true }, + ) + + try { + if (rest.rollback && !force) { + prepareForRollback(tasks) + } + + await tasks.run() + } catch (e) { + errorTelemetry(process.argv, e.message) + console.error(c.error(e.message)) + process.exit(e?.exitCode || 1) + } +} diff --git a/packages/cli/src/commands/generate/package/templates/README.md.template b/packages/cli/src/commands/generate/package/templates/README.md.template new file mode 100644 index 0000000000..716a69a85a --- /dev/null +++ b/packages/cli/src/commands/generate/package/templates/README.md.template @@ -0,0 +1,15 @@ +# Shared Package '${packageName}' + +Use code in this package by adding it to the dependencies on the side you want +to use it, with the special `workspace:*` version. After that you can import it +into your code: + +```json + "dependencies": { + "${packageName}": "workspace:*" + } +``` + +```javascript +import { ${camelName} } from '${packageName}'; +``` diff --git a/packages/cli/src/commands/generate/package/templates/index.ts.template b/packages/cli/src/commands/generate/package/templates/index.ts.template new file mode 100644 index 0000000000..8ee7964ff5 --- /dev/null +++ b/packages/cli/src/commands/generate/package/templates/index.ts.template @@ -0,0 +1,3 @@ +export function ${camelName}() { + return 0 +} diff --git a/packages/cli/src/commands/generate/package/templates/package.json.template b/packages/cli/src/commands/generate/package/templates/package.json.template new file mode 100644 index 0000000000..48ed8ea96e --- /dev/null +++ b/packages/cli/src/commands/generate/package/templates/package.json.template @@ -0,0 +1,15 @@ +{ + "name": "${packageName}", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "watch": "tsc --watch" + }, + "devDependencies": { + "@cedarjs/testing": "2.2.1", + "typescript": "5.9.3" + } +} diff --git a/packages/cli/src/commands/generate/package/templates/scenarios.ts.template b/packages/cli/src/commands/generate/package/templates/scenarios.ts.template new file mode 100644 index 0000000000..a3c19a8b06 --- /dev/null +++ b/packages/cli/src/commands/generate/package/templates/scenarios.ts.template @@ -0,0 +1,8 @@ +import type { ScenarioData } from '@cedarjs/testing/api' + +export const standard = defineScenario({ + // Define the "fixture" to write into your test database here + // See guide: https://cedarjs.com/docs/testing#scenarios +}) + +export type StandardScenario = ScenarioData diff --git a/packages/cli/src/commands/generate/package/templates/test.ts.template b/packages/cli/src/commands/generate/package/templates/test.ts.template new file mode 100644 index 0000000000..e7aad1c8fe --- /dev/null +++ b/packages/cli/src/commands/generate/package/templates/test.ts.template @@ -0,0 +1,7 @@ +import { ${camelName} } from './index.js' + +describe('${camelName}', () => { + it('should not throw any errors', async () => { + expect(${camelName}()).not.toThrow() + }) +}) diff --git a/packages/cli/src/commands/generate/package/templates/tsconfig.json.template b/packages/cli/src/commands/generate/package/templates/tsconfig.json.template new file mode 100644 index 0000000000..a2e259fee3 --- /dev/null +++ b/packages/cli/src/commands/generate/package/templates/tsconfig.json.template @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "composite": true, + "target": "ES2023", + "module": "Node20", + "esModuleInterop": true, + "skipLibCheck": true, + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "sourceMap": true, + "declaration": true, + "declarationMap": true, + }, + "include": ["src"], +} diff --git a/packages/cli/src/commands/generate/yargsHandlerHelpers.js b/packages/cli/src/commands/generate/yargsHandlerHelpers.js index 0fe3056eeb..2562b413cf 100644 --- a/packages/cli/src/commands/generate/yargsHandlerHelpers.js +++ b/packages/cli/src/commands/generate/yargsHandlerHelpers.js @@ -86,20 +86,23 @@ export const templateForFile = async ({ templatePath, templateVars, }) => { - const basePath = getPaths()[side][sidePathSection] + const basePath = sidePathSection + ? getPaths()[side][sidePathSection] + : getPaths()[side] const fullOutputPath = path.join(basePath, outputPath) const fullTemplatePath = customOrDefaultTemplatePath({ generator, templatePath, side, }) - const content = await generateTemplate(fullTemplatePath, { + const mergedTemplateVars = { name, outputPath: ensurePosixPath( `./${path.relative(getPaths().base, fullOutputPath)}`, ), ...templateVars, - }) + } + const content = await generateTemplate(fullTemplatePath, mergedTemplateVars) return [fullOutputPath, content] } diff --git a/packages/cli/src/commands/setup/generator/generatorHandler.js b/packages/cli/src/commands/setup/generator/generatorHandler.js index bf84ec3da9..cc62e458fd 100644 --- a/packages/cli/src/commands/setup/generator/generatorHandler.js +++ b/packages/cli/src/commands/setup/generator/generatorHandler.js @@ -10,6 +10,7 @@ const SIDE_MAP = { web: ['cell', 'component', 'layout', 'page', 'scaffold'], api: ['function', 'sdl', 'service'], scripts: ['script'], + packages: ['package'], } const copyGenerator = (name, { force }) => { diff --git a/packages/cli/src/lib/index.js b/packages/cli/src/lib/index.js index 5e476f1595..965fa5fc79 100644 --- a/packages/cli/src/lib/index.js +++ b/packages/cli/src/lib/index.js @@ -68,7 +68,6 @@ export const generateTemplate = async (templateFilename, { name, ...rest }) => { ...nameVariants(name), ...rest, }) - return prettify(templateFilename, renderedTemplate) } catch (error) { error.message = `Error applying template at ${templateFilename} for ${name}: ${error.message}` diff --git a/packages/cli/src/lib/test.js b/packages/cli/src/lib/test.js index 7cb623d316..5eb26af18f 100644 --- a/packages/cli/src/lib/test.js +++ b/packages/cli/src/lib/test.js @@ -60,6 +60,7 @@ vi.mock('@cedarjs/project-config', async (importOriginal) => { generators: path.join(BASE_PATH, './web/generators'), }, scripts: path.join(BASE_PATH, 'scripts'), + packages: path.join(BASE_PATH, 'packages'), generatorTemplates: path.join(BASE_PATH, 'generatorTemplates'), generated: { base: path.join(BASE_PATH, '.redwood'), diff --git a/packages/project-config/src/__tests__/paths.test.ts b/packages/project-config/src/__tests__/paths.test.ts index c737c1f9e6..5587d0631f 100644 --- a/packages/project-config/src/__tests__/paths.test.ts +++ b/packages/project-config/src/__tests__/paths.test.ts @@ -29,6 +29,7 @@ const DEFAULT_PATHS = { }, prebuild: ['.redwood', 'prebuild'], }, + packages: ['packages'], scripts: ['scripts'], generatorTemplates: ['generatorTemplates'], api: { diff --git a/packages/project-config/src/paths.ts b/packages/project-config/src/paths.ts index a153714dda..148c5d70f3 100644 --- a/packages/project-config/src/paths.ts +++ b/packages/project-config/src/paths.ts @@ -158,6 +158,7 @@ export const getPaths = (BASE_DIR: string = getBaseDir()): Paths => { }, scripts: path.join(BASE_DIR, 'scripts'), + packages: path.join(BASE_DIR, 'packages'), generatorTemplates: path.join(BASE_DIR, 'generatorTemplates'), api: {