Skip to content
Open
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
4 changes: 4 additions & 0 deletions src/cli/commands/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export const ingestCommand = new Command("ingest")
frontendPath: frontend,
backendPath: backend,
apply: Boolean(opts.apply),
samTemplate: cfg.ingest?.samTemplate,
appEntry: cfg.ingest?.appEntry,
handlerSourceRoots: cfg.ingest?.handlerSourceRoots,
skipDirs: cfg.ingest?.skipDirs,
});

printSyncResults(results, Boolean(opts.apply));
Expand Down
24 changes: 24 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,17 @@ export const NexoConfigSchema = z.object({
database: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
caFile: z.string().optional(),
})
.optional(),
ingest: z
.object({
frontend: z.string().optional(),
backend: z.string().optional(),
samTemplate: z.string().optional(),
appEntry: z.string().optional(),
handlerSourceRoots: z.array(z.string()).optional(),
skipDirs: z.array(z.string()).optional(),
})
.optional(),
web: z
Expand All @@ -39,4 +44,23 @@ export const DEFAULTS = {
port: 3000,
host: "127.0.0.1",
},
ingest: {
samTemplateCandidates: [
"unified-stack/template.yaml",
"template.yaml",
"infra/web.yaml",
],
appEntryCandidates: [
"src/App.tsx",
"src/App.jsx",
],
handlerSourceRoots: [
"unified-stack/src",
"src",
],
builtinSkipDirs: [
"node_modules", "dist", ".git", ".cache", "build", "coverage",
"__tests__", "__mocks__", "test", "tests", ".aws-sam",
],
},
} as const;
19 changes: 12 additions & 7 deletions src/ingest/parsers/sourceFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@ export interface ParsedSourceFile {
layer: string;
}

const SKIP_DIRS = new Set([
"node_modules", "dist", ".git", ".cache", "build", "coverage",
"__tests__", "__mocks__", "test", "tests", ".aws-sam",
]);
import { DEFAULTS } from "../../config/schema.js";

const BUILTIN_SKIP_DIRS = new Set(DEFAULTS.ingest.builtinSkipDirs);

const LANG_MAP: Record<string, string> = {
".jsx": "jsx", ".tsx": "tsx", ".js": "js", ".ts": "ts",
Expand All @@ -25,11 +24,16 @@ const LANG_MAP: Record<string, string> = {
export function parseSourceFiles(
rootPath: string,
repo: string,
extraSkipDirs?: string[],
): ParsedSourceFile[] {
if (!existsSync(rootPath)) return [];

const skipDirs = extraSkipDirs?.length
? new Set([...BUILTIN_SKIP_DIRS, ...extraSkipDirs])
: BUILTIN_SKIP_DIRS;

const files: ParsedSourceFile[] = [];
walkDir(rootPath, rootPath, repo, files);
walkDir(rootPath, rootPath, repo, files, skipDirs);
return files;
}

Expand All @@ -38,6 +42,7 @@ function walkDir(
rootPath: string,
repo: string,
files: ParsedSourceFile[],
skipDirs: Set<string>,
): void {
let entries;
try {
Expand All @@ -48,12 +53,12 @@ function walkDir(

for (const entry of entries) {
if (entry.name.startsWith(".")) continue;
if (SKIP_DIRS.has(entry.name)) continue;
if (skipDirs.has(entry.name)) continue;

const fullPath = join(dir, entry.name);

if (entry.isDirectory()) {
walkDir(fullPath, rootPath, repo, files);
walkDir(fullPath, rootPath, repo, files, skipDirs);
} else if (entry.isFile()) {
const ext = extname(entry.name);
const language = LANG_MAP[ext];
Expand Down
21 changes: 10 additions & 11 deletions src/ingest/sourceMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { existsSync, readdirSync } from "fs";
import { join, resolve } from "path";
import type { Surreal } from "surrealdb";
import { listEdges } from "../db/edges.js";
import { DEFAULTS } from "../config/schema.js";
import type { Node } from "../schema/types.js";
import type { ParsedEndpoint } from "./parsers/sam.js";
import type { ParsedScreen } from "./parsers/routes.js";

export interface SourceMapConfig {
frontendRoot?: string;
backendRoot?: string;
handlerSourceRoots?: string[];
}

/**
Expand All @@ -23,19 +25,16 @@ export function endpointSourceFile(

// Handler format: "api/trips.handler" → src/api/trips.js (or .ts, .mjs, etc.)
const handlerPath = ep.handler.replace(/\.[^.]+$/, ""); // strip .handler
const srcRoot = join(resolve(config.backendRoot), "unified-stack", "src");
const roots = config.handlerSourceRoots ?? DEFAULTS.ingest.handlerSourceRoots;
const backendRoot = resolve(config.backendRoot);
const extensions = [".js", ".ts", ".mjs", ".cjs"];

for (const ext of extensions) {
const candidate = join(srcRoot, handlerPath + ext);
if (existsSync(candidate)) return candidate;
}

// Try without unified-stack subdirectory
const altRoot = resolve(config.backendRoot);
for (const ext of extensions) {
const candidate = join(altRoot, "src", handlerPath + ext);
if (existsSync(candidate)) return candidate;
for (const root of roots) {
const srcRoot = join(backendRoot, root);
for (const ext of extensions) {
const candidate = join(srcRoot, handlerPath + ext);
if (existsSync(candidate)) return candidate;
}
}

return null;
Expand Down
73 changes: 67 additions & 6 deletions src/ingest/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { generateNodeId } from "../schema/ids.js";
import type { Node } from "../schema/types.js";
import type { ParsedEndpoint } from "./parsers/sam.js";
import type { ParsedScreen } from "./parsers/routes.js";
import { basename, resolve } from "node:path";
import { basename, resolve, join } from "node:path";
import { existsSync } from "node:fs";
import { DEFAULTS } from "../config/schema.js";
import type { ParsedSourceFile } from "./parsers/sourceFiles.js";
import { endpointName, parseSamTemplate } from "./parsers/sam.js";
import { screenName, parseRoutes } from "./parsers/routes.js";
Expand All @@ -18,6 +20,10 @@ export interface SyncOptions {
frontendPath?: string;
backendPath?: string;
apply?: boolean;
samTemplate?: string;
appEntry?: string;
handlerSourceRoots?: string[];
skipDirs?: string[];
}

export interface SyncResults {
Expand All @@ -37,6 +43,60 @@ interface PropsUpdate {
[key: string]: { old: unknown; new: unknown };
}

/**
* Find the SAM template file within a backend path.
* If `configured` is set, resolves it directly; otherwise searches candidates.
*/
function findSamTemplate(backendPath: string, configured?: string): string {
if (configured) {
const resolved = join(backendPath, configured);
if (existsSync(resolved)) return resolved;
throw new Error(
`Configured SAM template not found: ${resolved}\n` +
` (set via ingest.samTemplate in .nexo/config.json)`
);
}

const candidates = DEFAULTS.ingest.samTemplateCandidates.map(
(c) => join(backendPath, c)
);
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
throw new Error(
`No SAM template found in ${backendPath}. Checked:\n` +
candidates.map((c) => ` - ${c}`).join("\n") +
`\n Hint: set ingest.samTemplate in .nexo/config.json`
);
}

/**
* Find the React app entry file (App.tsx or App.jsx).
* If `configured` is set, resolves it directly; otherwise searches candidates.
*/
function findAppEntry(frontendPath: string, configured?: string): string {
if (configured) {
const resolved = join(frontendPath, configured);
if (existsSync(resolved)) return resolved;
throw new Error(
`Configured app entry not found: ${resolved}\n` +
` (set via ingest.appEntry in .nexo/config.json)`
);
}

const candidates = DEFAULTS.ingest.appEntryCandidates.map(
(c) => join(frontendPath, c)
);
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
throw new Error(
`No App entry file found in ${frontendPath}. Checked:\n` +
candidates.map((c) => ` - ${c}`).join("\n") +
`\n Hint: set ingest.appEntry in .nexo/config.json`
);
}

/**
* Run the full ingest sync. Dry-run by default unless opts.apply is true.
*/
Expand All @@ -53,14 +113,14 @@ export async function runSync(db: Surreal, opts: SyncOptions): Promise<SyncResul

// Sync APIEndpoints from SAM template
if (opts.backendPath) {
const templatePath = `${opts.backendPath}/unified-stack/template.yaml`;
const templatePath = findSamTemplate(opts.backendPath, opts.samTemplate);
parsedEndpoints = parseSamTemplate(templatePath);
await syncEndpoints(db, parsedEndpoints, opts, results.endpoints);
}

// Sync Screens from App.jsx
// Sync Screens from App.jsx/tsx
if (opts.frontendPath) {
const appPath = `${opts.frontendPath}/src/App.jsx`;
const appPath = findAppEntry(opts.frontendPath, opts.appEntry);
parsedScreens = parseRoutes(appPath);
await syncScreens(db, parsedScreens, opts, results.screens);
}
Expand All @@ -69,11 +129,11 @@ export async function runSync(db: Surreal, opts: SyncOptions): Promise<SyncResul
const allSourceFiles: ParsedSourceFile[] = [];
if (opts.frontendPath) {
const repoName = basename(resolve(opts.frontendPath));
allSourceFiles.push(...parseSourceFiles(opts.frontendPath, repoName));
allSourceFiles.push(...parseSourceFiles(opts.frontendPath, repoName, opts.skipDirs));
}
if (opts.backendPath) {
const repoName = basename(resolve(opts.backendPath));
allSourceFiles.push(...parseSourceFiles(opts.backendPath, repoName));
allSourceFiles.push(...parseSourceFiles(opts.backendPath, repoName, opts.skipDirs));
}
if (allSourceFiles.length > 0) {
await syncSourceFiles(db, allSourceFiles, opts, results.sourceFiles);
Expand Down Expand Up @@ -237,6 +297,7 @@ async function syncImplementedInEdges(
const config = {
frontendRoot: opts.frontendPath,
backendRoot: opts.backendPath,
handlerSourceRoots: opts.handlerSourceRoots,
};

// Match API endpoints to handler files
Expand Down