diff --git a/.changeset/warm-dodos-decide.md b/.changeset/warm-dodos-decide.md new file mode 100644 index 0000000..72b7ce4 --- /dev/null +++ b/.changeset/warm-dodos-decide.md @@ -0,0 +1,10 @@ +--- +"@calycode/types": patch +"@calycode/utils": patch +"@calycode/core": patch +"@calycode/cli": patch +--- + +refactor: minor cleanup, removal of the linting command.. +fix: make backup to be stream, to allow bigger workspaces +refactor: cleanup of fs / path imports in the cli diff --git a/README.md b/README.md index faa8070..cf16482 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,7 @@ I have been astonished by the shadcn/ui CLI and the core principles of code dist - [x] **Scaffolding a registry** of reusable Xano components - [x] Exporting all available `xanoscript` from your instance via metadata API _*(important note: not all pieces of logic can be exported via metadata API, this especially is fragile on older and bigger instances)_. - [x] Adding components to Xano from a registry (only functions, tables, queries for now) -- [ ] Automated test runner with assertion configuration -- [ ] Linting with custom rulesets +- [x] Automated test runner with assertion configuration --- diff --git a/eslint.config.ts b/eslint.config.ts index e5c5335..3792975 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -38,7 +38,7 @@ export default [ // TS/TSX files config { files: ['**/*.ts'], - ignores: ['node_modules/**', 'dist/**', 'output/**'], + ignores: ['node_modules/**', 'dist/**', 'output/**', 'scripts/**'], languageOptions: { parser: '@typescript-eslint/parser', parserOptions: { diff --git a/packages/cli/esbuild.config.ts b/packages/cli/esbuild.config.ts index 6df1186..26e12bc 100644 --- a/packages/cli/esbuild.config.ts +++ b/packages/cli/esbuild.config.ts @@ -2,7 +2,6 @@ import { cp, writeFile } from 'fs/promises'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { build } from 'esbuild'; -import { intro, outro, log } from '@clack/prompts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = resolve(__dirname); @@ -10,13 +9,10 @@ const distDir = resolve(__dirname, 'dist'); (async () => { try { - intro('Bundling Caly with esbuild'); - // Copy github actions await cp(resolve(rootDir, 'src/actions'), resolve(distDir, 'actions'), { recursive: true, }); - log.step('Copied and minified assets to dist.'); // Bundle the application with esbuild const result = await build({ @@ -35,16 +31,15 @@ const distDir = resolve(__dirname, 'dist'); sourcemap: false, metafile: true, }); - log.step('esbuild bundling complete.'); // Write the metafile for analysis await writeFile(resolve(distDir, 'meta.json'), JSON.stringify(result.metafile, null, 2)); - outro( + console.log( 'Build complete. You can analyze the bundle with https://esbuild.github.io/analyze/ by uploading dist/meta.json' ); } catch (error) { - log.error(`Build failed: ${JSON.stringify(error, null, 2)}`); + console.error(`Build failed: ${JSON.stringify(error, null, 2)}`); process.exit(1); } })(); diff --git a/packages/cli/src/commands/analyze.ts b/packages/cli/src/commands/analyze.ts index 9f9b0e5..e4f3592 100644 --- a/packages/cli/src/commands/analyze.ts +++ b/packages/cli/src/commands/analyze.ts @@ -1,6 +1,5 @@ -import { mkdir } from 'fs/promises'; -import { writeFileSync, existsSync, mkdirSync } from 'fs'; -import { dirname, join } from 'path'; +import { mkdir, writeFile, access } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; import { log, outro, intro, spinner } from '@clack/prompts'; import { metaApiGet, replacePlaceholders, sanitizeFileName } from '@calycode/utils'; import { @@ -12,7 +11,6 @@ import { import { resolveConfigs } from '../utils/commands/context-resolution'; import { findProjectRoot } from '../utils/commands/project-root-finder'; -// [ ] CORE, but needs fs access. async function fetchFunctionsInXanoScript(instance, workspace, branch, printOutput = false, core) { intro('Starting to analyze functions.'); let branchFunctions = {}; @@ -81,12 +79,14 @@ async function fetchFunctionsInXanoScript(instance, workspace, branch, printOutp // Ensure the parent directory exists const parentDir = dirname(filePath); - if (!existsSync(parentDir)) { - mkdirSync(parentDir, { recursive: true }); + try { + await access(parentDir); + } catch { + await mkdir(parentDir, { recursive: true }); } // Write the file - writeFileSync(filePath, branchFunctions[item].script); + await writeFile(filePath, branchFunctions[item].script); } s.stop(`Xano Script files are ready -> ${outputDir}`); } catch (err) { diff --git a/packages/cli/src/commands/backups.ts b/packages/cli/src/commands/backups.ts index 3c3422a..d1d6cf2 100644 --- a/packages/cli/src/commands/backups.ts +++ b/packages/cli/src/commands/backups.ts @@ -1,5 +1,5 @@ -import path, { join } from 'path'; -import { readdirSync } from 'fs'; +import { basename, join } from 'node:path'; +import { readdir } from 'node:fs/promises'; import { openAsBlob } from 'node:fs'; import { select, confirm, outro } from '@clack/prompts'; import { replacePlaceholders } from '@calycode/utils'; @@ -48,7 +48,7 @@ async function restorationWizard({ instance, workspace, sourceBackup, forceConfi let availableBackups; try { - availableBackups = readdirSync(backupsDir); + availableBackups = await readdir(backupsDir); } catch { outro(`No backups directory found for branch "${branchConfig.label}".`); process.exit(1); @@ -82,7 +82,7 @@ async function restorationWizard({ instance, workspace, sourceBackup, forceConfi } const formData = new FormData(); - formData.append('file', await openAsBlob(backupFilePath), path.basename(backupFilePath)); + formData.append('file', await openAsBlob(backupFilePath), basename(backupFilePath)); formData.append('password', ''); // Pass on the formdata to the core implementation diff --git a/packages/cli/src/commands/generate-code.ts b/packages/cli/src/commands/generate-code.ts index 6d6294b..28fec36 100644 --- a/packages/cli/src/commands/generate-code.ts +++ b/packages/cli/src/commands/generate-code.ts @@ -1,5 +1,5 @@ import { log, outro, intro, spinner } from '@clack/prompts'; -import { metaApiGet, normalizeApiGroupName, replacePlaceholders, dirname } from '@calycode/utils'; +import { metaApiGet, normalizeApiGroupName, replacePlaceholders } from '@calycode/utils'; import { addApiGroupOptions, addFullContextOptions, diff --git a/packages/cli/src/commands/generate-repo.ts b/packages/cli/src/commands/generate-repo.ts index f235b71..eab6b43 100644 --- a/packages/cli/src/commands/generate-repo.ts +++ b/packages/cli/src/commands/generate-repo.ts @@ -1,7 +1,6 @@ -import { existsSync, readdirSync, lstatSync, rmdirSync, unlinkSync } from 'fs'; +import { mkdir, access, readdir, lstat, rm, unlink } from 'node:fs/promises'; import { log, intro, outro } from '@clack/prompts'; import { load } from 'js-yaml'; -import { mkdir } from 'fs/promises'; import { joinPath, dirname, replacePlaceholders, fetchAndExtractYaml } from '@calycode/utils'; import { addFullContextOptions, @@ -14,21 +13,30 @@ import { resolveConfigs } from '../utils/commands/context-resolution'; import { findProjectRoot } from '../utils/commands/project-root-finder'; /** - * Clears the contents of a directory. + * Recursively removes all files and subdirectories in a directory. * @param {string} directory - The directory to clear. */ -function clearDirectory(directory) { - if (existsSync(directory)) { - readdirSync(directory).forEach((file) => { +async function clearDirectory(directory: string): Promise { + try { + await access(directory); + } catch { + // Directory does not exist; nothing to clear + return; + } + + const files = await readdir(directory); + await Promise.all( + files.map(async (file) => { const curPath = joinPath(directory, file); - if (lstatSync(curPath).isDirectory()) { - clearDirectory(curPath); - rmdirSync(curPath); + const stat = await lstat(curPath); + if (stat.isDirectory()) { + await clearDirectory(curPath); + await rm(curPath, { recursive: true, force: true }); // removes the (now-empty) dir } else { - unlinkSync(curPath); + await unlink(curPath); } - }); - } + }) + ); } async function generateRepo({ diff --git a/packages/cli/src/commands/generate-xanoscript-repo.ts b/packages/cli/src/commands/generate-xanoscript-repo.ts index 187e9dc..1d7a3ef 100644 --- a/packages/cli/src/commands/generate-xanoscript-repo.ts +++ b/packages/cli/src/commands/generate-xanoscript-repo.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, lstatSync, rmdirSync, unlinkSync, mkdirSync } from 'fs'; +import { access, readdir, lstat, rm, unlink, mkdir } from 'node:fs/promises'; import { joinPath, dirname } from '@calycode/utils'; import { attachCliEventHandlers } from '../utils/event-listener'; import { replacePlaceholders } from '@calycode/utils'; @@ -8,21 +8,30 @@ import { resolveConfigs } from '../utils/commands/context-resolution'; import { findProjectRoot } from '../utils/commands/project-root-finder'; /** - * Clears the contents of a directory. + * Recursively removes all files and subdirectories in a directory. * @param {string} directory - The directory to clear. */ -function clearDirectory(directory) { - if (existsSync(directory)) { - readdirSync(directory).forEach((file) => { +async function clearDirectory(directory: string): Promise { + try { + await access(directory); + } catch { + // Directory does not exist; nothing to clear + return; + } + + const files = await readdir(directory); + await Promise.all( + files.map(async (file) => { const curPath = joinPath(directory, file); - if (lstatSync(curPath).isDirectory()) { - clearDirectory(curPath); - rmdirSync(curPath); + const stat = await lstat(curPath); + if (stat.isDirectory()) { + await clearDirectory(curPath); + await rm(curPath, { recursive: true, force: true }); } else { - unlinkSync(curPath); + await unlink(curPath); } - }); - } + }) + ); } async function generateXanoscriptRepo({ instance, workspace, branch, core, printOutput = false }) { @@ -46,7 +55,7 @@ async function generateXanoscriptRepo({ instance, workspace, branch, core, print }); clearDirectory(outputDir); - mkdirSync(outputDir, { recursive: true }); + await mkdir(outputDir, { recursive: true }); const plannedWrites: { path: string; content: string }[] = await core.buildXanoscriptRepo({ instance: context.instance, diff --git a/packages/cli/src/commands/run-lint.ts b/packages/cli/src/commands/run-lint.ts deleted file mode 100644 index 75b6535..0000000 --- a/packages/cli/src/commands/run-lint.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { log } from '@clack/prompts'; -import { replacePlaceholders } from '@calycode/utils'; -import { addPrintOutputFlag, printOutputDir, withErrorHandler } from '../utils/index'; -import { runLintXano } from '../features/lint-xano'; -import { findProjectRoot } from '../utils/commands/project-root-finder'; - -// [ ] CORE -async function runLinter(printOutput: boolean = false, core) { - const globalConfig = await core.loadGlobalConfig(); - const context = globalConfig.currentContext; - - const startDir = process.cwd(); - const { instanceConfig, workspaceConfig, branchConfig } = await core.getCurrentContextConfig({ - startDir, - context, - }); - - if (!instanceConfig || !workspaceConfig || !branchConfig) { - log.error( - 'Missing instance, workspace, or branch context. Please use setup-instance and switch-context.' - ); - process.exit(1); - } - - const inputDir = replacePlaceholders(instanceConfig.process.output, { - '@': await findProjectRoot(), - instance: instanceConfig.name, - workspace: workspaceConfig.name, - branch: branchConfig.label, - }); - - if (!inputDir) throw new Error('Input YAML file is required'); - - const outputDir = replacePlaceholders(instanceConfig.lint.output, { - '@': await findProjectRoot(), - instance: instanceConfig.name, - workspace: workspaceConfig.name, - branch: branchConfig.label, - }); - const now = new Date(); - const ts = now.toISOString().replace(/[:.]/g, '-'); - const outputPath = `${outputDir}/report-${ts}.json`; - - const ruleConfig = instanceConfig.lint.rules; - - log.info( - `Lint ${instanceConfig.name} > ${workspaceConfig.name} > ${branchConfig.label} in progress.` - ); - - await runLintXano({ inputDir, ruleConfig, outputFile: outputPath }); - printOutputDir(printOutput, outputDir); -} - -// [ ] CLI -function registerLintCommand(program, core) { - const cmd = program - .command('lint') - .description( - 'Lint backend logic, based on provided local file. Remote and dynamic sources are WIP...' - ); - - addPrintOutputFlag(cmd); - cmd.action( - withErrorHandler(async (opts) => { - await runLinter(opts.printOutput, core); - }) - ); -} - -export { registerLintCommand }; diff --git a/packages/cli/src/commands/run-tests.ts b/packages/cli/src/commands/run-tests.ts index 9000f1b..f44d8a5 100644 --- a/packages/cli/src/commands/run-tests.ts +++ b/packages/cli/src/commands/run-tests.ts @@ -1,4 +1,4 @@ -import fs from 'fs/promises'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { intro, log, spinner } from '@clack/prompts'; import { normalizeApiGroupName, replacePlaceholders } from '@calycode/utils'; import { @@ -52,7 +52,7 @@ async function runTest({ // Take the core implementation for test running: // for now testconfig has to exist on the machine prior to running the tests. - const testConfigFileContent = await fs.readFile(testConfigPath, { encoding: 'utf-8' }); + const testConfigFileContent = await readFile(testConfigPath, { encoding: 'utf-8' }); const testConfig = JSON.parse(testConfigFileContent); const s = spinner(); s.start('Running tests based on the provided spec'); @@ -81,8 +81,8 @@ async function runTest({ api_group_normalized_name: normalizeApiGroupName(outcome.group.name), }); - await fs.mkdir(apiGroupTestPath, { recursive: true }); - await fs.writeFile( + await mkdir(apiGroupTestPath, { recursive: true }); + await writeFile( `${apiGroupTestPath}/${testFileName}`, JSON.stringify(outcome.results, null, 2) ); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 1047e6a..9f7203a 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1,4 +1,4 @@ -import { spawn } from 'child_process'; +import { spawn } from 'node:child_process'; import { normalizeApiGroupName, replacePlaceholders } from '@calycode/utils'; import { addApiGroupOptions, addFullContextOptions, chooseApiGroupOrAll } from '../utils/index'; import { resolveConfigs } from '../utils/commands/context-resolution'; diff --git a/packages/cli/src/features/code-gen/open-api-generator.ts b/packages/cli/src/features/code-gen/open-api-generator.ts index 7ad854c..ceab50a 100644 --- a/packages/cli/src/features/code-gen/open-api-generator.ts +++ b/packages/cli/src/features/code-gen/open-api-generator.ts @@ -1,16 +1,15 @@ -import { spawn } from 'child_process'; -import { resolve, join } from 'path'; -import { mkdirSync, createWriteStream } from 'fs'; +import { createWriteStream } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { resolve, join } from 'node:path'; +import { spawn } from 'node:child_process'; -// [ ] CLI only feature -export function runOpenApiGenerator({ +export async function runOpenApiGenerator({ input, output, generator, additionalArgs = [], - logger = false, // If true, log to file, else discard logs + logger = false, }) { - return new Promise((resolvePromise, reject) => { // Always use npx and the official package const cliBin = process.platform === 'win32' ? 'npx.cmd' : 'npx'; const inputPath = resolve(input).replace(/\\/g, '/'); @@ -31,15 +30,14 @@ export function runOpenApiGenerator({ let logStream = null; let logPath = null; - // If logger is true, prepare log file and stream if (logger) { const logsDir = join(process.cwd(), 'output', '_logs'); - mkdirSync(logsDir, { recursive: true }); + await mkdir(logsDir, { recursive: true }); logPath = join(logsDir, `openapi-generator-${Date.now()}.log`); logStream = createWriteStream(logPath); } - // Start the process + return new Promise((resolvePromise, reject) => { const proc = spawn(cliBin, cliArgs, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] }); // Always suppress console output! @@ -47,7 +45,7 @@ export function runOpenApiGenerator({ proc.stdout.pipe(logStream); proc.stderr.pipe(logStream); } else { - proc.stdout.resume(); // prevent backpressure + proc.stdout.resume(); proc.stderr.resume(); } @@ -75,3 +73,4 @@ export function runOpenApiGenerator({ }); }); } + diff --git a/packages/cli/src/features/lint-xano/XanoLinter.ts b/packages/cli/src/features/lint-xano/XanoLinter.ts deleted file mode 100644 index e66c4d4..0000000 --- a/packages/cli/src/features/lint-xano/XanoLinter.ts +++ /dev/null @@ -1,93 +0,0 @@ -// src/lint-xano/XanoLinter.js -import { availableRules } from './rules/index'; -import { isNotEmpty } from '@calycode/utils'; - -// ----- Linting rule functions ----- -async function lintObject(obj, errors, ruleConfig, parentKey = '', parentObj = obj) { - for (const key in obj) { - const value = obj[key]; - - for (const [ruleName, ruleFn] of Object.entries(availableRules)) { - const level = ruleConfig[ruleName] || 'off'; - if (level === 'off') continue; - - // Call rule function with appropriate arguments - let ruleResult = null; - if ( - ruleName === 'is-camel-case' && - key === 'as' && - !parentKey.includes('dbo') && - isNotEmpty(value) - ) { - ruleResult = ruleFn(value, parentKey); - } else if (ruleName === 'is-valid-verb' && key === 'verb') { - ruleResult = ruleFn(value, parentObj?.name); - } else if (ruleName === 'is-description-present' && key === 'description') { - ruleResult = ruleFn(obj, parentKey); - } - - // Add rule result to errors array if it exists - if (ruleResult) { - errors.push({ ...ruleResult, level }); - } - } - - const additionalRules = [ - { - condition: () => parentKey.includes('search.expression') && - parentKey.includes('statement.left') && - key === 'filters' && - Array.isArray(value) && value.length > 0, - message: `Database query left operand should not have filters in "${obj.index ?? ''} ${obj.name} ${obj.as ?? ''}".`, - rule: "DB queries: Don't put filters in left operand" - }, - { - condition: () => key === 'disabled' && value === true, - message: `Disabled logic step found in "${parentObj.name}" as "${obj.index} ${obj.name} ${obj.as}".`, - rule: 'Good practice: remove commented code' - } - ]; - - additionalRules.forEach(({ condition, message, rule }) => { - if (condition()) { - errors.push({ message, rule }); - } - }); - - // Recurse if value is an object - if (typeof value === 'object' && value !== null) { - if (Array.isArray(value)) { - for (let index = 0; index < value.length; index++) { - await lintObject( - value[index], - errors, - ruleConfig, - `${parentKey}${key}[${index}].`, - obj - ); - } - } else { - await lintObject(value, errors, ruleConfig, `${parentKey}${key}.`, obj); - } - } - } -} - -// ----- Linter class ----- -class XanoLinter { - ruleConfig: any; - backendLogic: any; - - constructor(config: any, backendLogic: any) { - this.ruleConfig = config.rules || {}; - this.backendLogic = backendLogic; - } - - async lint() { - const errors: any[] = []; - await lintObject(this.backendLogic, errors, this.ruleConfig); - return errors; - } -} - -export default XanoLinter; diff --git a/packages/cli/src/features/lint-xano/index.ts b/packages/cli/src/features/lint-xano/index.ts deleted file mode 100644 index 28fbafd..0000000 --- a/packages/cli/src/features/lint-xano/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -// src/lint-xano/index.js -import fs from 'fs/promises'; -import path from 'path'; -import { log, spinner } from '@clack/prompts'; -import XanoLinter from './XanoLinter'; - -// [ ] CORE, needs fs -async function runLinterOnJsonFiles({ dirPath, lintResults, ruleConfig }) { - const files = await fs.readdir(dirPath); - - for (const file of files) { - const filePath = path.join(dirPath, file); - const stat = await fs.stat(filePath); - - if (stat.isDirectory()) { - await runLinterOnJsonFiles({ dirPath: filePath, lintResults, ruleConfig }); - } else if (path.extname(file) === '.json') { - try { - const data = await fs.readFile(filePath, 'utf8'); - const jsonData = JSON.parse(data); - const linter = new XanoLinter({ rules: ruleConfig }, jsonData); - const results = await linter.lint(); - if (results.length > 0) { - lintResults.push({ - name: jsonData.name, - filePath, - results, - }); - } - } catch (err) { - log.error(`Error processing file ${filePath}: ${err}`); - } - } - } -} - -async function main({ inputDir, outputFile, ruleConfig }) { - const s = spinner(); - s.start('Linting in progress...'); - try { - const tempDir = path.dirname(outputFile); - await fs.mkdir(tempDir, { recursive: true }); - const lintResults = []; - await runLinterOnJsonFiles({ - dirPath: inputDir, - lintResults, - ruleConfig, - }); - - // Write linting results to file - if (lintResults.length > 0) { - await fs.writeFile(outputFile, JSON.stringify(lintResults, null, 2)); - s.stop(`Linting results written -> ${outputFile}`); - } else { - s.stop('No linting issues found.'); - } - } catch (err) { - s.stop('Linting resulted in error:'); - log.error(`Error during linting process: ${JSON.stringify(err)}`); - } -} - -export { main as runLintXano }; diff --git a/packages/cli/src/features/lint-xano/rules/index.ts b/packages/cli/src/features/lint-xano/rules/index.ts deleted file mode 100644 index a3a7ec7..0000000 --- a/packages/cli/src/features/lint-xano/rules/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -// src/lint-xano/rules/index.js -import { isNotEmpty } from '@calycode/utils'; - -const VALID_HEADERS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD']); - -function isCamelCase(key, parentKey) { - if (!/^[a-z][a-zA-Z0-9]*$/.test(key)) { - return { - message: `Key "${parentKey}${key}" should be in camelCase.`, - rule: 'Good practice: camelCase', - }; - } - return null; -} - -function isValidRouteName(route) { - if (!/^\/[a-z0-9\-/]*$/.test(route)) { - return { - message: `Route name "${route}" does not follow the naming convention.`, - rule: 'validRouteName', - }; - } - return null; -} - -function isValidVerb(method, route) { - if (!VALID_HEADERS.has(method)) { - return { - message: `Invalid method "${method}" for endpoint "${route}".`, - rule: 'Invalid value: Endpoint verb is invalid', - }; - } - return null; -} - -function isDescriptionPresent(object, parentKey = '') { - if (!isNotEmpty(object.description) && object.disabled !== true) { - // Handle the input descriptions separately - if (parentKey.includes('input')) { - return { - message: `Description for input "${object.name}" is missing.`, - rule: 'Good practice: descriptions', - }; - } - return { - message: `Description for "${object.index ?? ''} ${object.name} ${ - object.as ?? '' - }" is missing.`, - rule: 'Good practice: descriptions', - }; - } -} - -const availableRules = { - 'is-camel-case': isCamelCase, - 'is-valid-route-name': isValidRouteName, - 'is-valid-verb': isValidVerb, - 'is-description-present': isDescriptionPresent, -}; - -export { availableRules }; diff --git a/packages/cli/src/node-config-storage.ts b/packages/cli/src/node-config-storage.ts index 35aa81b..6536748 100644 --- a/packages/cli/src/node-config-storage.ts +++ b/packages/cli/src/node-config-storage.ts @@ -4,22 +4,21 @@ * * Directory structure: * - ~/.xano-tools/config.json (global configuration) - * - ~/.xano-tools/instances/ (instance-specific configurations) * - ~/.xano-tools/tokens/ (API tokens with restricted permissions) */ -import fs from 'fs'; -import path from 'path'; -import os from 'os'; +import fs from 'node:fs'; +import path from 'node:path'; +import { tmpdir, homedir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; import { x } from 'tar'; -import { tmpdir } from 'os'; -import { join } from 'path'; import { ConfigStorage, InstanceConfig } from '@calycode/types'; -const BASE_DIR = path.join(os.homedir(), '.xano-tools'); +const BASE_DIR = path.join(homedir(), '.xano-tools'); const GLOBAL_CONFIG_PATH = path.join(BASE_DIR, 'config.json'); const TOKENS_DIR = path.join(BASE_DIR, 'tokens'); const DEFAULT_LOCAL_CONFIG_FILE = 'instance.config.json'; -const MERGE_KEYS = ['lint', 'test']; +const MERGE_KEYS = ['test']; /** * Walks up the directory tree to find the first directory containing @@ -227,7 +226,7 @@ export const nodeConfigStorage: ConfigStorage = { getStartDir() { return process.cwd(); }, -// + // // ----- FILESYSTEM OPS ----- async mkdir(dirPath, options) { await fs.promises.mkdir(dirPath, options); @@ -238,6 +237,30 @@ export const nodeConfigStorage: ConfigStorage = { async writeFile(filePath, data) { await fs.promises.writeFile(filePath, data); }, + async streamToFile( + destinationPath: string, + source: ReadableStream | NodeJS.ReadableStream + ): Promise { + const dest = fs.createWriteStream(destinationPath, { mode: 0o600 }); + let nodeStream: NodeJS.ReadableStream; + + // Convert if necessary + if (typeof (source as any).pipe === 'function') { + // already a NodeJS stream + nodeStream = source as NodeJS.ReadableStream; + } else { + // WHATWG stream (from fetch in Node 18+) + // Can only use fromWeb if available in the environment + nodeStream = Readable.fromWeb(source as any); + } + + await new Promise((resolve, reject) => { + nodeStream.pipe(dest); + dest.on('finish', () => resolve()); + dest.on('error', (err) => reject(err)); + nodeStream.on('error', (err) => reject(err)); + }); + }, async readFile(filePath) { return await fs.promises.readFile(filePath); // returns Buffer }, diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index b0045e6..905cfa4 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -9,7 +9,6 @@ import { registerExportBackupCommand, registerRestoreBackupCommand } from './com import { registerGenerateCodeCommand } from './commands/generate-code'; import { registerGenerateOasCommand } from './commands/generate-oas'; import { registerGenerateRepoCommand } from './commands/generate-repo'; -//import { registerLintCommand } from './commands/run-lint'; import { registerSetupCommand } from './commands/setup-instance'; import { registerRunTestCommand } from './commands/run-tests'; import { registerRegistryAddCommand, registerRegistryScaffoldCommand } from './commands/registry'; @@ -87,7 +86,6 @@ registerRegistryScaffoldCommand(program, core); registerRegistryServeCommand(program); registerExportBackupCommand(program, core); registerRestoreBackupCommand(program, core); -//registerLintCommand(program, core); registerRunTestCommand(program, core); registerCurrentContextCommand(program, core); diff --git a/packages/cli/src/utils/commands/project-root-finder.ts b/packages/cli/src/utils/commands/project-root-finder.ts index 9d946fe..550c01a 100644 --- a/packages/cli/src/utils/commands/project-root-finder.ts +++ b/packages/cli/src/utils/commands/project-root-finder.ts @@ -1,11 +1,15 @@ -import path from 'path'; -import fs from 'fs'; +import { join, dirname } from 'node:path'; +import { access } from 'node:fs/promises'; async function findProjectRoot(startDir = process.cwd(), sentinel = 'instance.config.json') { let dir = startDir; - while (dir !== path.dirname(dir)) { - if (fs.existsSync(path.join(dir, sentinel))) return dir; - dir = path.dirname(dir); + while (dir !== dirname(dir)) { + try { + await access(join(dir, sentinel)); + return dir; + } catch { + dir = dirname(dir); + } } throw new Error(`Project root not found (missing ${sentinel})`); } diff --git a/packages/cli/src/utils/event-listener.ts b/packages/cli/src/utils/event-listener.ts index f8ed698..80f76e2 100644 --- a/packages/cli/src/utils/event-listener.ts +++ b/packages/cli/src/utils/event-listener.ts @@ -2,11 +2,11 @@ import { intro, outro, log, spinner } from '@clack/prompts'; import { printOutputDir } from './methods/print-output-dir'; import { EventName } from '@calycode/types'; -export type CoreEventName = 'start' | 'end' | 'progress' | 'error' | 'info'; -export type HandlerFn = (data: any, context?: any) => void; -export type HandlerMap = Partial>; +type CoreEventName = 'start' | 'end' | 'progress' | 'error' | 'info'; +type HandlerFn = (data: any, context?: any) => void; +type HandlerMap = Partial>; -export const defaultHandlers: HandlerMap = { +const defaultHandlers: HandlerMap = { error: (data) => log.error(data.message), }; @@ -122,4 +122,4 @@ function attachCliEventHandlers( }); } -export { eventHandlers, attachCliEventHandlers }; +export { attachCliEventHandlers }; diff --git a/packages/cli/src/utils/feature-focused/registry/scaffold.ts b/packages/cli/src/utils/feature-focused/registry/scaffold.ts index e59dab5..785d4ec 100644 --- a/packages/cli/src/utils/feature-focused/registry/scaffold.ts +++ b/packages/cli/src/utils/feature-focused/registry/scaffold.ts @@ -1,9 +1,9 @@ -import fs from 'fs/promises'; -import path from 'path'; +import { dirname, join } from 'node:path'; +import { mkdir, writeFile } from 'node:fs/promises'; -async function ensureDirForFile(filePath) { - const dir = path.dirname(filePath); - await fs.mkdir(dir, { recursive: true }); +async function ensureDirForFile(filePath: string) { + const dir = dirname(filePath); + await mkdir(dir, { recursive: true }); } // [ ] CLI @@ -13,15 +13,15 @@ async function scaffoldRegistry( } ) { const componentsRoot = 'components'; - const definitionPath = path.join(registryRoot, 'definitions'); + const definitionPath = join(registryRoot, 'definitions'); const functionName = 'hello-world'; const functionRelPath = `functions/${functionName}`; const functionFileName = `${functionName}.xano`; // Paths - const functionFilePath = path.join(registryRoot, componentsRoot, 'functions', functionFileName); - const functionDefPath = path.join(definitionPath, 'functions', `${functionName}.json`); - const indexPath = path.join(definitionPath, 'index.json'); + const functionFilePath = join(registryRoot, componentsRoot, 'functions', functionFileName); + const functionDefPath = join(definitionPath, 'functions', `${functionName}.json`); + const indexPath = join(definitionPath, 'index.json'); // Sample content const sampleFunctionContent = ` @@ -56,9 +56,9 @@ async function scaffoldRegistry( }; const sampleIndex = { - $schema: 'https://nextcurve.hu/schemas/registry/registry.json', + $schema: 'https://calycode.com/schemas/registry/registry.json', name: 'xano-registry', - homepage: 'https://nextcurve.hu', + homepage: 'https://calycode.com', items: [sampleRegistryItem], }; @@ -67,9 +67,9 @@ async function scaffoldRegistry( await ensureDirForFile(functionDefPath); await ensureDirForFile(indexPath); - await fs.writeFile(functionFilePath, sampleFunctionContent, 'utf8'); - await fs.writeFile(functionDefPath, JSON.stringify(sampleRegistryItem, null, 2), 'utf8'); - await fs.writeFile(indexPath, JSON.stringify(sampleIndex, null, 2), 'utf8'); + await writeFile(functionFilePath, sampleFunctionContent, 'utf8'); + await writeFile(functionDefPath, JSON.stringify(sampleRegistryItem, null, 2), 'utf8'); + await writeFile(indexPath, JSON.stringify(sampleIndex, null, 2), 'utf8'); console.log(`✅ Registry scaffolded at "${registryRoot}" with a sample component!`); } diff --git a/packages/cli/src/utils/feature-focused/test/custom-assertions.ts b/packages/cli/src/utils/feature-focused/test/custom-assertions.ts deleted file mode 100644 index 6bcd9ec..0000000 --- a/packages/cli/src/utils/feature-focused/test/custom-assertions.ts +++ /dev/null @@ -1,60 +0,0 @@ -import * as assert from 'uvu/assert'; - -// [ ] CORE - -/** - * Assert that the response status is OK. - */ -function assertResponseStatus(context) { - const { res, method, path } = context; - if (!res.ok) { - throw new assert.Assertion({ - actual: res.status, - expects: 200, - operator: 'statusOk', - message: `${method.toUpperCase()}:${path} | ❌ Response status was ${ - res.status - } (expected 200)`, - details: '', // You could add more details or a diff here if you want - }); - } -} - -/** - * Assert that the response is defined. - */ -function assertResponseDefined(context) { - const { result, method, path } = context; - if (result === undefined || result === null) { - throw new assert.Assertion({ - actual: result, - expects: 'defined', - operator: 'responseDefined', - message: `${method.toUpperCase()}:${path} | ❌ Response was undefined.`, - }); - } -} - -/** - * Validate the response schema. - */ -function assertResponseSchema(context) { - const { isValid, errors = null, method, path } = context; - if (!isValid) { - throw new assert.Assertion({ - actual: isValid, - expects: true, - operator: 'responseSchema', - message: `${method.toUpperCase()}:${path} | ❌ Response schema was not valid.`, - details: `Validation errors: ${JSON.stringify(errors, null, 2)}`, - }); - } -} - -const availableAsserts = { - statusOk: assertResponseStatus, - responseDefined: assertResponseDefined, - responseSchema: assertResponseSchema, -}; - -export { availableAsserts }; diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts index 3fed0c6..7672789 100644 --- a/packages/cli/src/utils/index.ts +++ b/packages/cli/src/utils/index.ts @@ -2,7 +2,6 @@ export * from './commands/option-sets'; export * from './feature-focused/registry/api'; export * from './feature-focused/registry/general'; export * from './feature-focused/registry/scaffold'; -export * from './feature-focused/test/custom-assertions'; export * from './methods/choose-api-group'; export * from './methods/print-output-dir'; export * from './methods/safe-version-control'; diff --git a/packages/cli/src/utils/methods/safe-version-control.ts b/packages/cli/src/utils/methods/safe-version-control.ts index 64aa2f1..4db6659 100644 --- a/packages/cli/src/utils/methods/safe-version-control.ts +++ b/packages/cli/src/utils/methods/safe-version-control.ts @@ -1,11 +1,11 @@ -import fs from 'fs'; -import path from 'path'; +import { access, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { log } from '@clack/prompts'; // [ ] CLI const PROJECT_ROOT = process.cwd(); -const GITIGNORE_PATH = path.join(PROJECT_ROOT, '.gitignore'); -const EXAMPLE_GITIGNORE_PATH = path.join(PROJECT_ROOT, 'gitignore.example'); +const GITIGNORE_PATH = join(PROJECT_ROOT, '.gitignore'); +const EXAMPLE_GITIGNORE_PATH = join(PROJECT_ROOT, 'gitignore.example'); const RECOMMENDED_IGNORES = [ '.env', @@ -23,18 +23,22 @@ const RECOMMENDED_IGNORES = [ 'debug_requests.json', ]; -export function ensureGitignore() { +export async function ensureGitignore() { let gitignoreContent = ''; let needsWrite = false; // If no .gitignore, use example if it exists, or just recommended ignores - if (!fs.existsSync(GITIGNORE_PATH)) { - if (fs.existsSync(EXAMPLE_GITIGNORE_PATH)) { - gitignoreContent = fs.readFileSync(EXAMPLE_GITIGNORE_PATH, 'utf8').trim(); + try { + await access(GITIGNORE_PATH); + gitignoreContent = (await readFile(GITIGNORE_PATH, 'utf8')).trim(); + } catch { + try { + await access(EXAMPLE_GITIGNORE_PATH); + gitignoreContent = (await readFile(EXAMPLE_GITIGNORE_PATH, 'utf8')).trim(); + } catch { + gitignoreContent = ''; } needsWrite = true; - } else { - gitignoreContent = fs.readFileSync(GITIGNORE_PATH, 'utf8').trim(); } // Deduplicate: create a Set of existing lines (trimming whitespace) @@ -50,10 +54,11 @@ export function ensureGitignore() { if (missing.length > 0) { gitignoreContent += '\n' + missing.join('\n') + '\n'; - fs.writeFileSync(GITIGNORE_PATH, gitignoreContent.trim() + '\n'); + await writeFile(GITIGNORE_PATH, gitignoreContent.trim() + '\n'); log.success('[MAINTENANCE]: .gitignore updated with missing recommended ignores.'); } else if (needsWrite) { - fs.writeFileSync(GITIGNORE_PATH, gitignoreContent.trim() + '\n'); + await writeFile(GITIGNORE_PATH, gitignoreContent.trim() + '\n'); log.success('[MAINTENANCE]: .gitignore created from example.'); } } + diff --git a/packages/core/src/features/process-xano/core/generateRunReadme.ts b/packages/core/src/features/process-xano/core/generateRunReadme.ts index 61cabd4..572ecc5 100644 --- a/packages/core/src/features/process-xano/core/generateRunReadme.ts +++ b/packages/core/src/features/process-xano/core/generateRunReadme.ts @@ -1,5 +1,5 @@ import { xanoQueryToSql } from '../adapters/xanoQueryToSql'; -import { statementsMap } from '../../../utils/methods/statements-map'; +import { statementsMap } from '../../../utils'; function getMethodType(method) { if (method.name === 'mvp:function' && method.context?.function?.id) return 'function'; diff --git a/packages/core/src/implementations/backups.ts b/packages/core/src/implementations/backups.ts index 18db4dd..62ba8bb 100644 --- a/packages/core/src/implementations/backups.ts +++ b/packages/core/src/implementations/backups.ts @@ -1,4 +1,4 @@ -import { replacePlaceholders, metaApiRequestBlob, joinPath } from '@calycode/utils'; +import { replacePlaceholders, joinPath } from '@calycode/utils'; /** * Exports a backup and emits events for CLI/UI. @@ -20,7 +20,7 @@ async function exportBackupImplementation({ instance, workspace, branch, core }) instance, workspace, branch, - startDir + startDir, }); if (!instanceConfig || !workspaceConfig || !branchConfig) { @@ -50,13 +50,17 @@ async function exportBackupImplementation({ instance, workspace, branch, core }) percent: 40, }); - const backupBuffer = await metaApiRequestBlob({ - baseUrl: instanceConfig.url, - token: await core.loadToken(instanceConfig.name), - method: 'POST', - path: `/workspace/${workspaceConfig.id}/export`, - body: { branch: branchConfig.label }, - }); + const backupStreamRequest = await fetch( + `${instanceConfig.url}/api:meta/workspace/${workspaceConfig.id}/export`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${await core.loadToken(instanceConfig.name)}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ branch: branchConfig.label }), + } + ); core.emit('progress', { name: 'export-backup', @@ -67,7 +71,7 @@ async function exportBackupImplementation({ instance, workspace, branch, core }) const now = new Date(); const ts = now.toISOString().replace(/[:.]/g, '-'); const backupPath = joinPath(outputDir, `backup-${ts}.tar.gz`); - await core.storage.writeFile(backupPath, backupBuffer); + await core.storage.streamToFile(backupStreamRequest.body, backupPath); core.emit('progress', { name: 'export-backup', @@ -107,7 +111,7 @@ async function restoreBackupImplementation({ instance, workspace, formData, core const { instanceConfig, workspaceConfig } = await core.loadAndValidateContext({ instance, workspace, - startDir + startDir, }); const headers = { diff --git a/packages/core/src/utils/event-handling/event-emitter.ts b/packages/core/src/utils/event-handling/event-emitter.ts index 7101d58..3a1d313 100644 --- a/packages/core/src/utils/event-handling/event-emitter.ts +++ b/packages/core/src/utils/event-handling/event-emitter.ts @@ -2,8 +2,6 @@ * Type-safe event emitter that provides strongly-typed event handling. * Used as the base class for Caly to enable event-driven architecture. * - * @template E - Object type mapping event names to their data types - * * @example * ```typescript * interface MyEvents { diff --git a/packages/types/src/storage/index.ts b/packages/types/src/storage/index.ts index 12efe3d..b989d87 100644 --- a/packages/types/src/storage/index.ts +++ b/packages/types/src/storage/index.ts @@ -43,6 +43,7 @@ export interface ConfigStorage { mkdir(path: string, options?: { recursive?: boolean }): Promise; readdir(path: string): Promise; writeFile(path: string, data: string | Uint8Array): Promise; + streamToFile(path: string, stream: ReadableStream | NodeJS.ReadableStream): Promise; readFile(path: string): Promise; exists(path: string): Promise; diff --git a/scripts/generate-caly-cli-docs.ts b/scripts/generate-caly-cli-docs.ts index 68abfc4..3594ec5 100644 --- a/scripts/generate-caly-cli-docs.ts +++ b/scripts/generate-caly-cli-docs.ts @@ -3,13 +3,13 @@ import path from 'path'; import stripAnsi from 'strip-ansi'; import { program } from '../packages/cli/src/program'; -function copyTemplateFiles(templateDir, targetDir) { +function copyTemplateFiles(templateDir: string, targetDir: string) { // Copies everything from templateDir into targetDir, overwriting if needed cpSync(templateDir, targetDir, { recursive: true }); console.log(`Copied template files from ${templateDir} to ${targetDir}.\n`); } -function writeDocForCommand(cmd, dir = 'docs/commands') { +function writeDocForCommand(cmd: any, dir = 'docs/commands') { const name = cmd.name(); const description = cmd.description ? cmd.description() : ''; const help = stripAnsi(cmd.helpInformation()); @@ -19,7 +19,7 @@ function writeDocForCommand(cmd, dir = 'docs/commands') { optionsContent = [ '### Options', '', - ...options.map((opt) => { + ...options.map((opt: any) => { const optionDoc = [`#### ${opt.flags}`, `**Description:** ${opt.description}`].join( '\n' ); diff --git a/scripts/minify-json-in-dir.ts b/scripts/minify-json-in-dir.ts index 6259f45..3fdb8ea 100644 --- a/scripts/minify-json-in-dir.ts +++ b/scripts/minify-json-in-dir.ts @@ -1,7 +1,7 @@ import { readdir, readFile, writeFile } from 'fs/promises'; import { extname, join } from 'path'; -export async function minifyJsonInDir(dir) { +export async function minifyJsonInDir(dir: string) { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); diff --git a/scripts/minimize-xano-structure.ts b/scripts/minimize-xano-structure.ts index 346e582..795e7a0 100644 --- a/scripts/minimize-xano-structure.ts +++ b/scripts/minimize-xano-structure.ts @@ -11,8 +11,8 @@ async function main() { // Extract only the fields we care about const entries = statements - .filter((st) => st.name && st.display) // Only those with both fields - .map((st) => [ + .filter((st: any) => st.name && st.display) // Only those with both fields + .map((st: any) => [ st.name, { display: st.display }, // Add group: st.group if you want ]); diff --git a/tsconfig.base.json b/tsconfig.base.json index 660762e..4b04f65 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -24,5 +24,5 @@ { "path": "./packages/cli" } ], "include": ["src/**/*", "scripts/**/*", "eslint.config.ts"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "scripts"] }