Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/dry-files-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@calycode/core": minor
"@calycode/cli": minor
---

fix: **BREAKING CHANGE** fixing a breaking change in the Xano Metadata API for the workspace/{workspace_id}/export-schema endpoint causing the repo and internal docs generator commands to fail
23 changes: 20 additions & 3 deletions packages/cli/src/commands/generate/implementation/internal-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,29 @@ async function generateInternalDocs({

intro('Building directory structure...');

if (!inputFile) throw new Error('Input YAML file is required');
if (!inputFile) throw new Error('Input schema file (.json or .yaml) is required');
if (!outputDir) throw new Error('Output directory is required');

log.step(`Reading and parsing YAML file -> ${inputFile}`);
log.step(`Reading and parsing schema file -> ${inputFile}`);
const fileContents = await core.storage.readFile(inputFile, 'utf8');
const jsonData = load(fileContents);

let jsonData: any;
try {
if (inputFile.endsWith('.json')) {
jsonData = JSON.parse(fileContents);
} else if (inputFile.endsWith('.yaml') || inputFile.endsWith('.yml')) {
jsonData = load(fileContents);
} else {
// Fallback: Try JSON, then YAML if extension is missing or weird
try {
jsonData = JSON.parse(fileContents);
} catch {
jsonData = load(fileContents);
}
}
} catch (err) {
throw new Error(`Failed to parse schema file: ${err.message}`);
}

const plannedWrites: { path: string; content: string }[] = await core.generateInternalDocs({
jsonData,
Expand Down
25 changes: 22 additions & 3 deletions packages/cli/src/commands/generate/implementation/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,31 @@ async function generateRepo({

intro('Building directory structure...');

if (!inputFile) throw new Error('Input YAML file is required');
if (!inputFile) throw new Error('Input schema file (.json or .yaml) is required');
if (!outputDir) throw new Error('Output directory is required');

log.step(`Reading and parsing YAML file -> ${inputFile}`);
log.step(`Reading and parsing schema file -> ${inputFile}`);
const fileContents = await core.storage.readFile(inputFile, 'utf8');
const jsonData = load(fileContents);

let jsonData: any;
try {
if (inputFile.endsWith('.json')) {
jsonData = JSON.parse(fileContents);
} else if (inputFile.endsWith('.yaml') || inputFile.endsWith('.yml')) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistency: .yml extension not supported in fetch path.

This code correctly handles .yml extension (line 114), but packages/utils/src/methods/fetch-and-extract-yaml.ts at line 50 only checks for .json and .yaml extensions. This means:

  • Manual input with .yml works ✓
  • Fetched schemas with .yml fail ✗

For consistency, the fetch utility should also check for .yml:

-   const schemaFile = files.find((f) => f.endsWith('.json') || f.endsWith('.yaml'));
+   const schemaFile = files.find((f) => f.endsWith('.json')) || files.find((f) => f.endsWith('.yaml') || f.endsWith('.yml'));

This can be combined with the priority fix suggested earlier in fetch-and-extract-yaml.ts.

Committable suggestion skipped: line range outside the PR's diff.

jsonData = load(fileContents);
} else {
// Fallback: Try JSON, then YAML if extension is missing or weird
try {
jsonData = JSON.parse(fileContents);
} catch {
jsonData = load(fileContents);
}
}
} catch (err) {
throw new Error(`Failed to parse schema file: ${err.message}`);
}

// 3. Proceed with generation
const plannedWrites: { path: string; content: string }[] = await core.generateRepo({
jsonData,
instance: instanceConfig.name,
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/commands/generate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ function registerGenerateCommands(program, core) {
.description(
'Collect all descriptions, and internal documentation from a Xano instance and combine it into a nice documentation suite that can be hosted on a static hosting.'
)
.option('-I, --input <file>', 'Workspace yaml file from a local source, if present.')
.option('-I, --input <file>', 'Workspace schema file (.yaml [legacy] or .json) from a local source, if present.')
.option(
'-O, --output <dir>',
'Output directory (overrides default config), useful when ran from a CI/CD pipeline and want to ensure consistent output location.'
Expand Down Expand Up @@ -134,7 +134,10 @@ function registerGenerateCommands(program, core) {
.description(
'Process Xano workspace into repo structure. We use the export-schema metadata API to offer the full details. However that is enriched with the Xanoscripts after Xano 2.0 release.'
)
.option('-I, --input <file>', 'Workspace yaml file from a local source, if present.')
.option(
'-I, --input <file>',
'Workspace schema file (.yaml [legacy] or .json) from a local source, if present.'
)
.option(
'-O, --output <dir>',
'Output directory (overrides default config), useful when ran from a CI/CD pipeline and want to ensure consistent output location.'
Expand Down
19 changes: 12 additions & 7 deletions packages/cli/src/node-config-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,16 +291,21 @@ export const nodeConfigStorage: ConfigStorage = {
// Extract with tar
await x({ file: tarGzPath, cwd: tempDir });

// Read all files in tempDir
const files = await fs.promises.readdir(tempDir);
for (const file of files) {
if (file.endsWith('.yaml')) {
result[file] = await fs.promises.readFile(join(tempDir, file));
const entries = await fs.promises.readdir(tempDir, { recursive: true });

for (const file of entries) {
// Check for both extensions
if (file.endsWith('.yaml') || file.endsWith('.json')) {
const fullPath = join(tempDir, file);
// Ensure we are reading a file, not a directory that happens to end in .json
const stat = await fs.promises.stat(fullPath);
if (stat.isFile()) {
result[file] = await fs.promises.readFile(fullPath);
}
}
}
Comment on lines +294 to 306
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

fd -t f 'package.json' --exec jq -r '.engines.node // "not specified"' {} \;

Repository: calycode/xano-tools

Length of output: 182


🏁 Script executed:

# Check the actual code at the specified lines in the file
cat -n packages/cli/src/node-config-storage.ts | sed -n '294,306p'

Repository: calycode/xano-tools

Length of output: 741


🏁 Script executed:

# Get more context around the tarExtract method to understand the full implementation
cat -n packages/cli/src/node-config-storage.ts | sed -n '280,315p'

Repository: calycode/xano-tools

Length of output: 1472


🏁 Script executed:

cat packages/cli/package.json | jq '.engines.node // "not specified"'

Repository: calycode/xano-tools

Length of output: 73


🏁 Script executed:

# Check root package.json if it exists
if [ -f package.json ]; then cat package.json | jq '.engines.node // "not specified"'; fi

Repository: calycode/xano-tools

Length of output: 78


🌐 Web query:

Node.js fs.promises.readdir recursive option version requirement

💡 Result:

The fs.promises.readdir({ recursive: true }) option was added in Node.js v20.1.0 and backported to v18.17.0 — so you need Node >= 18.17.0 (or any v20.1.0+ release). [1][2]

Sources: Node.js API docs. [1] [2]


Update minimum Node.js version or refactor readdir usage.

The recursive: true option in fs.promises.readdir() requires Node.js >= 18.17.0, but the project's package.json specifies engines.node: ">=18.0.0". This creates a runtime incompatibility for users on Node.js 18.0.0–18.16.x.

Either:

  1. Update package.json to specify ">=18.17.0" (or ">=20.1.0")
  2. Refactor to collect files without the recursive option
🤖 Prompt for AI Agents
In @packages/cli/src/node-config-storage.ts around lines 294 - 306, The code
uses fs.promises.readdir(tempDir, { recursive: true }) which requires Node
>=18.17.0; instead remove the recursive option and implement a small recursive
directory walker: replace the single readdir call with a helper async function
(e.g., walkDir(dir)) that calls fs.promises.readdir(dir), iterates entries,
builds fullPath with join(dir, entry), calls fs.promises.stat(fullPath),
recursively calls walkDir(fullPath) for directories and for files checks
extensions (endsWith('.yaml') || endsWith('.json')) then reads the file into
result using the same keys you used before (ensure you preserve how you derive
the key from file/fullPath relative to tempDir); update the call site to await
walkDir(tempDir) so behavior matches the original without requiring the
recursive readdir option.

} finally {
// Clean up tempDir here
await fs.promises.rm(tempDir, { recursive: true });
await fs.promises.rm(tempDir, { recursive: true, force: true });
}

return result;
Expand Down
13 changes: 9 additions & 4 deletions packages/utils/src/methods/fetch-and-extract-yaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,20 @@ export async function fetchAndExtractYaml({

// Find the .yaml file inside outDir
const files: string[] = await core.storage.readdir(outDir);
const yamlFile = files.find((f) => f.endsWith('.yaml'));
if (!yamlFile) throw new Error('No .yaml found in the exported archive!');
const yamlFilePath = joinPath(outDir, yamlFile);
const schemaFile = files.find((f) => f.endsWith('.json') || f.endsWith('.yaml'));

if (!schemaFile) {
throw new Error(
`No schema file (.json or .yaml) found in the exported archive! Found: ${files.join(', ')}`
);
}
const schemaFilePath = joinPath(outDir, schemaFile);

core.emit('progress', {
name: 'fetch-extract-yaml',
message: 'Extracted workspace schema!',
percent: 100,
});

return yamlFilePath;
return schemaFilePath;
}