Files
twenty/packages/twenty-cli/src/utils/load-manifest.ts
T
Paul Rastoin a735e3dfef Dirty fix twenty cli ci build order issue (#16024)
# Introduction
- fix twenty-apps hello world deps lockfile 
- improve error response format in application resolver
- fixed tests by adding applicationId back to serverless function
service v2

## New log format
We should have a tmp logs folder where we write the errors so the user
can see the whole of them such as what's done in yarn logs
```ts
    console.log
      ✓ Client generated successfully!

      at GenerateService.generateClient (src/services/generate.service.ts:58:13)

    console.log
      Generated files at: /Users/paulrastoin/ws/twenty/packages/twenty-apps/hello-world/generated

      at GenerateService.generateClient (src/services/generate.service.ts:59:13)

    console.error
       Serverless functions Sync failed: {
        message: 'Multiple validation errors occurred while creating serverless function',
        extensions: {
          code: 'METADATA_VALIDATION_FAILED',
          errors: {
            fieldMetadata: [],
            objectMetadata: [],
            view: [],
            viewField: [],
            viewGroup: [],
            index: [],
            serverlessFunction: [Array],
            cronTrigger: [],
            databaseEventTrigger: [],
            routeTrigger: [],
            viewFilter: []
          },
          summary: {
            invalidViewFilter: 0,
            invalidObjectMetadata: 0,
            invalidView: 0,
            invalidViewField: 0,
            invalidIndex: 0,
            invalidServerlessFunction: 0,
            invalidDatabaseEventTrigger: 0,
            invalidCronTrigger: 0,
            invalidRouteTrigger: 0,
            invalidFieldMetadata: 0,
            invalidViewGroup: 0,
            totalErrors: 0
          },
          message: 'Validation failed for 0 object(s) and 0 field(s)',
          userFriendlyMessage: 'Validation failed for 0 object(s) and 0 field(s)'
        }
      }

      63 |         JSON.stringify(serverlessSyncResult.error, null, 2),
      64 |       );
    > 65 |       console.error(
         |               ^
      66 |         chalk.red(' Serverless functions Sync failed:'),
      67 |         serverlessSyncResult.error,
      68 |       );

      at AppSyncCommand.synchronize (src/commands/app-sync.command.ts:65:15)
      at async AppSyncCommand.execute (src/commands/app-sync.command.ts:21:14)
      at async Object.<anonymous> (src/__tests__/e2e/applications-install-delete-reinstall.e2e-spec.ts:28:22)

```
2025-11-24 15:49:58 +00:00

533 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as fs from 'fs-extra';
import { posix, relative, sep } from 'path';
import {
Decorator,
Expression,
FunctionDeclaration,
Modifier,
Node,
Program,
SourceFile,
SyntaxKind,
VariableDeclaration,
forEachChild,
getDecorators,
isArrayLiteralExpression,
isArrowFunction,
isCallExpression,
isClassDeclaration,
isComputedPropertyName,
isExportAssignment,
isFunctionExpression,
isIdentifier,
isImportDeclaration,
isNoSubstitutionTemplateLiteral,
isNumericLiteral,
isObjectLiteralExpression,
isPropertyAccessExpression,
isPropertyAssignment,
isPropertyDeclaration,
isShorthandPropertyAssignment,
isStringLiteralLike,
isTemplateExpression,
isVariableStatement,
} from 'typescript';
import { GENERATED_FOLDER_NAME } from '../services/generate.service';
import {
AppManifest,
Application,
FieldMetadata,
ObjectManifest,
PackageJson,
ServerlessFunctionManifest,
Sources,
} from '../types/config.types';
import { findPathFile } from '../utils/find-path-file';
import { getTsProgramAndDiagnostics } from '../utils/get-ts-program-and-diagnostics';
import { parseJsoncFile, parseTextFile } from '../utils/jsonc-parser';
import { formatAndWarnTsDiagnostics } from './format-and-warn-ts-diagnostics';
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [k: string]: JSONValue };
const isDecoratorNamed = (node: Decorator, name: string): node is Decorator => {
const expr = node.expression;
if (isCallExpression(expr)) {
if (isIdentifier(expr.expression)) return expr.expression.text === name;
if (isPropertyAccessExpression(expr.expression))
return expr.expression.name.text === name;
}
return false;
};
const exprToValue = (expr: Expression): JSONValue => {
if (isStringLiteralLike(expr)) return expr.text;
if (isNumericLiteral(expr)) return Number(expr.text);
if (expr.kind === SyntaxKind.TrueKeyword) return true;
if (expr.kind === SyntaxKind.FalseKeyword) return false;
if (expr.kind === SyntaxKind.NullKeyword) return null;
if (isPropertyAccessExpression(expr)) {
if (isIdentifier(expr.expression) && isIdentifier(expr.name)) {
return expr.name.text;
}
return String(expr.getText());
}
if (isNoSubstitutionTemplateLiteral(expr)) {
return expr.text;
}
if (isTemplateExpression(expr)) {
let out = expr.head.text;
for (const span of expr.templateSpans) {
const v = exprToValue(span.expression);
out += String(v) + span.literal.text;
}
return out;
}
if (isArrayLiteralExpression(expr)) {
return expr.elements.map((e) =>
e.kind === SyntaxKind.SpreadElement ? [] : exprToValue(e),
);
}
if (isObjectLiteralExpression(expr)) {
const obj: Record<string, JSONValue> = {};
for (const prop of expr.properties) {
if (isPropertyAssignment(prop)) {
const key =
isIdentifier(prop.name) || isStringLiteralLike(prop.name)
? prop.name.text
: isComputedPropertyName(prop.name) &&
isStringLiteralLike(prop.name.expression)
? prop.name.expression.text
: undefined;
if (key) obj[key] = exprToValue(prop.initializer);
} else if (isShorthandPropertyAssignment(prop)) {
// Unsupported without a checker; skip to keep it "light".
// Could resolve via typechecker if needed.
}
// getters/setters/methods are ignored intentionally
}
return obj;
}
// Keep it intentionally strict/lightweight: anything non-literal becomes a string fallback.
// You can throw instead if you prefer to fail fast.
return isIdentifier(expr)
? expr.text
: String((expr as any).getText?.() ?? '');
};
const getFirstArgObject = (dec: Decorator) => {
if (!isCallExpression(dec.expression)) return undefined;
const [firstArg] = dec.expression.arguments;
return firstArg && isObjectLiteralExpression(firstArg)
? (exprToValue(firstArg) as Record<string, JSONValue>)
: undefined;
};
const collectObjects = (program: Program) => {
const manifest: ObjectManifest[] = [];
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile) {
continue;
}
const visit = (node: Node) => {
if (isClassDeclaration(node) && getDecorators(node)?.length) {
const decorators = getDecorators(node);
const objectDec = decorators?.find(
(d) =>
isDecoratorNamed(d, 'ObjectMetadata') ||
isDecoratorNamed(d, 'Object'),
);
if (objectDec) {
const cfg = getFirstArgObject(objectDec);
if (cfg && typeof cfg === 'object' && !Array.isArray(cfg)) {
const fields: Array<Record<string, JSONValue>> = [];
for (const member of node.members) {
if (!isPropertyDeclaration(member)) {
continue;
}
const fieldDec = getDecorators(member)?.find(
(d) =>
isDecoratorNamed(d, 'FieldMetadata') ||
isDecoratorNamed(d, 'Field'),
);
if (!fieldDec) {
continue;
}
const fieldCfg = getFirstArgObject(fieldDec);
if (!fieldCfg) {
continue;
}
// Try to attach the TypeScript property name as "name"
let name: string | undefined;
if (member.name && isIdentifier(member.name)) {
name = member.name.text;
} else {
// fallback to AST text if not a simple identifier
name = member.name?.getText?.() ?? undefined;
}
fields.push({
...(fieldCfg as FieldMetadata),
...(name ? { name } : {}),
});
}
manifest.push({ ...(cfg as any), fields } as ObjectManifest);
}
}
}
forEachChild(node, visit);
};
visit(sf);
}
return manifest;
};
// Add if you want a small guard for "export" presence on statements
const hasExportModifier = (st: any) =>
st.modifiers?.some((m: Modifier) => m.kind === SyntaxKind.ExportKeyword) ??
false;
/**
* Finds (and validates) the new serverless file shape:
* - exactly 2 exported bindings
* - one must be `config` (typed ServerlessFunctionConfig)
* - the other must be a function (exported function declaration, or const initialized with arrow/function expression)
*/
const findHandlerAndConfig = (
sf: SourceFile,
): {
handlerName: ServerlessFunctionManifest['handlerName'];
configObject: Pick<
ServerlessFunctionManifest,
| 'universalIdentifier'
| 'name'
| 'description'
| 'timeoutSeconds'
| 'triggers'
>;
} => {
type Exported = {
name: string;
kind: 'function' | 'const';
init?: Expression;
declNode: Node;
};
const exported: Exported[] = [];
// 1) export const X = <arrow|function expr>
for (const st of sf.statements) {
if (!isVariableStatement(st) || !hasExportModifier(st)) continue;
for (const decl of st.declarationList.declarations) {
if (!isIdentifier(decl.name)) continue;
const name = decl.name.text;
const init = decl.initializer ?? undefined;
exported.push({
name,
kind: 'const',
init,
declNode: decl,
});
}
}
// 2) export function X() { ... }
for (const st of sf.statements) {
if (st.kind === SyntaxKind.FunctionDeclaration && hasExportModifier(st)) {
const fd = st as FunctionDeclaration;
if (fd.name && isIdentifier(fd.name)) {
exported.push({
name: fd.name.text,
kind: 'function',
init: undefined,
declNode: fd,
});
}
}
}
// Enforce exactly two exports
const unique = Array.from(new Map(exported.map((e) => [e.name, e])).values());
if (unique.length !== 2) {
throw new Error(
`Serverless file ${sf.fileName} must export exactly 2 bindings (handler + config). Found: ${unique.map((e) => e.name).join(', ')}`,
);
}
// Find config
const configExport = unique.find((e) => e.name === 'config');
if (!configExport) {
throw new Error(
`Serverless file ${sf.fileName} must export a binding named "config".`,
);
}
// Must be initialized to an object literal
if (!configExport.init || !isObjectLiteralExpression(configExport.init)) {
throw new Error(
`"config" in ${sf.fileName} must be initialized to an object literal.`,
);
}
// (Light) type guard: ensure declared type mentions ServerlessFunctionConfig if present
const maybeVarDecl = configExport.declNode as VariableDeclaration;
if ('type' in maybeVarDecl && maybeVarDecl.type) {
const typeText = maybeVarDecl.type.getText(sf);
if (!/\bServerlessFunctionConfig\b/.test(typeText)) {
throw new Error(
`"config" in ${sf.fileName} must be typed as ServerlessFunctionConfig (got: ${typeText}).`,
);
}
}
const configObject = exprToValue(configExport.init) as Pick<
ServerlessFunctionManifest,
| 'universalIdentifier'
| 'name'
| 'description'
| 'timeoutSeconds'
| 'triggers'
>;
// Identify the handler: the other export
const handlerExport = unique.find((e) => e.name !== 'config');
if (!handlerExport) {
throw new Error(`Could not find the handler export in ${sf.fileName}.`);
}
// If it's a const, make sure its a function-ish initializer
if (handlerExport.kind === 'const') {
const init = handlerExport.init;
const isFuncLike =
!!init && (isArrowFunction(init) || isFunctionExpression(init));
if (!isFuncLike) {
throw new Error(
`Handler "${handlerExport.name}" in ${sf.fileName} must be a function (arrow or function expression).`,
);
}
}
return {
handlerName: handlerExport.name,
configObject,
};
};
const posixRelativeFromCwd = (fileName: string, appPath: string) => {
const rel = relative(appPath, fileName);
// normalize to posix separators for portability / manifest stability
return rel.split(sep).join(posix.sep);
};
const collectServerlessFunctions = (program: Program, appPath: string) => {
const serverlessFunctions: ServerlessFunctionManifest[] = [];
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile) continue;
try {
const { handlerName, configObject } = findHandlerAndConfig(sf);
const handlerPath = posixRelativeFromCwd(sf.fileName, appPath);
serverlessFunctions.push({
...configObject,
handlerPath,
handlerName,
});
} catch {
// Not a serverless file under the new format — ignore and continue scanning.
continue;
}
}
return serverlessFunctions;
};
const setNested = (root: Sources, parts: string[], value: string) => {
let cur: Sources = root;
for (let i = 0; i < parts.length; i++) {
const key = parts[i];
if (i === parts.length - 1) {
cur[key] = value;
} else {
cur[key] = (cur[key] ?? {}) as Sources;
cur = cur[key] as Sources;
}
}
};
const loadFolderContentIntoJson = async (
program: Program,
appPath: string,
): Promise<Sources> => {
const sources: Sources = {};
// Iterate only files the TS program knows about.
for (const sf of program.getSourceFiles()) {
const abs = sf.fileName;
// Skip .d.ts and anything outside sourcePath
if (sf.isDeclarationFile) continue;
if (!abs.startsWith(appPath + sep) && abs !== appPath) continue;
// Keep only TS/TSX files
if (!(abs.endsWith('.ts') || abs.endsWith('.tsx'))) continue;
// Optional extra guard (usually unnecessary if tsconfig excludes node_modules)
if (abs.includes(`${sep}node_modules${sep}`)) continue;
const relFromRoot = relative(appPath, abs);
const parts = relFromRoot.split(sep);
const content = await fs.readFile(abs, 'utf8');
setNested(sources, parts, content);
}
return sources;
};
export const extractTwentyAppConfig = (program: Program): Application => {
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile || !sf.fileName.endsWith('application.config.ts'))
continue;
let found: Application | undefined;
const visit = (node: any): void => {
// Look for "export default twentyAppConfig"
if (isExportAssignment(node) && isIdentifier(node.expression)) {
const varName = node.expression.text;
// find the corresponding variable declaration
for (const stmt of sf.statements) {
if (isVariableStatement(stmt)) {
for (const decl of stmt.declarationList.declarations) {
if (isIdentifier(decl.name) && decl.name.text === varName) {
if (
decl.initializer &&
isObjectLiteralExpression(decl.initializer)
) {
found = exprToValue(decl.initializer) as Application;
}
}
}
}
}
}
if (!found) forEachChild(node, visit);
};
visit(sf);
if (found) return found;
}
throw new Error('Could not find default exported ApplicationConfig');
};
const isGeneratedModuleUsedInProgram = (program: Program): boolean => {
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile) continue;
let found = false;
const visit = (node: Node): void => {
if (found) return;
if (isImportDeclaration(node)) {
const moduleSpecifier = node.moduleSpecifier;
if (isStringLiteralLike(moduleSpecifier)) {
const moduleText = moduleSpecifier.text;
// Match ../../generated, ../generated, ./foo/generated, etc.
const isGeneratedModule =
moduleText === GENERATED_FOLDER_NAME ||
moduleText.endsWith(`/${GENERATED_FOLDER_NAME}`);
if (isGeneratedModule && node.importClause) {
found = true;
return;
}
}
}
forEachChild(node, visit);
};
visit(sf);
if (found) return true;
}
return false;
};
export const loadManifest = async (
appPath: string,
): Promise<{
packageJson: PackageJson;
yarnLock: string;
manifest: AppManifest;
shouldGenerate: boolean;
}> => {
const packageJson = await parseJsoncFile(
await findPathFile(appPath, 'package.json'),
);
const yarnLock = await parseTextFile(
await findPathFile(appPath, 'yarn.lock'),
);
const { diagnostics, program } = await getTsProgramAndDiagnostics({
appPath,
});
formatAndWarnTsDiagnostics({
diagnostics,
});
const [objects, serverlessFunctions, application, sources] = [
collectObjects(program),
collectServerlessFunctions(program, appPath),
extractTwentyAppConfig(program),
await loadFolderContentIntoJson(program, appPath),
];
const shouldGenerate = isGeneratedModuleUsedInProgram(program);
return {
packageJson,
yarnLock,
manifest: {
application,
objects,
serverlessFunctions,
sources,
},
shouldGenerate,
};
};