Dynamic grql api wrapper on application sync (#15791)
# Introduction Important note: for the moment testing this locally will require some hack due to latest twenty-sdk not being published. You will need to build twenty-cli and `cd packages/twenty-cli && yarn link` To finally sync the app in your app folder as `cd app-folder && twenty app sync` close https://github.com/twentyhq/core-team-issues/issues/1863 In this PR is introduced the generate sdk programmatic call to [genql](https://genql.dev/) exposed in a `client` barrel of `twenty-sdk` located in this package as there's high chances that will add a codegen layer above it at some point ? The cli calls this method after a sync application and writes a client in a generated folder. It will make a graql introspection query on the whole workspace. We should later improve that and only filter by current applicationId and its dependencies ( when twenty-standard application is introduced ) Fully typesafe ( input, output, filters etc ) auto-completed client ## Hello-world app serverless refactor <img width="2480" height="1326" alt="image" src="https://github.com/user-attachments/assets/b18ea372-b21d-4560-8fbc-1dc348427a95" /> --------- Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
import chalk from 'chalk';
|
||||
import * as chokidar from 'chokidar';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
|
||||
import { loadManifest } from '../utils/load-manifest';
|
||||
import { AppSyncCommand } from './app-sync.command';
|
||||
|
||||
export class AppDevCommand {
|
||||
private apiService = new ApiService();
|
||||
private syncCommand = new AppSyncCommand();
|
||||
|
||||
async execute(options: {
|
||||
appPath?: string;
|
||||
@@ -18,13 +17,7 @@ export class AppDevCommand {
|
||||
|
||||
this.logStartupInfo(appPath, debounceMs);
|
||||
|
||||
const { manifest, packageJson, yarnLock } = await loadManifest(appPath);
|
||||
|
||||
await this.apiService.syncApplication({
|
||||
manifest,
|
||||
packageJson,
|
||||
yarnLock,
|
||||
});
|
||||
await this.syncCommand.execute(appPath);
|
||||
|
||||
const watcher = this.setupFileWatcher(appPath, debounceMs);
|
||||
|
||||
@@ -64,13 +57,7 @@ export class AppDevCommand {
|
||||
timeout = setTimeout(async () => {
|
||||
console.log(chalk.blue('🔄 Changes detected, syncing...'));
|
||||
|
||||
const { manifest, packageJson, yarnLock } = await loadManifest(appPath);
|
||||
|
||||
await this.apiService.syncApplication({
|
||||
manifest,
|
||||
packageJson,
|
||||
yarnLock,
|
||||
});
|
||||
await this.syncCommand.execute(appPath);
|
||||
|
||||
console.log(
|
||||
chalk.gray('👀 Watching for changes... (Press Ctrl+C to stop)'),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import chalk from 'chalk';
|
||||
import { GenerateService } from '../services/generate.service';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
|
||||
|
||||
export class AppGenerateCommand {
|
||||
private generateService = new GenerateService();
|
||||
|
||||
async execute(appPath: string = CURRENT_EXECUTION_DIRECTORY) {
|
||||
try {
|
||||
await this.generateService.generateClient(appPath);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('Generate Twenty client failed:'),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,12 @@ import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-dire
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { ApiResponse } from '../types/config.types';
|
||||
import { loadManifest } from '../utils/load-manifest';
|
||||
import { GenerateService } from '../services/generate.service';
|
||||
|
||||
export class AppSyncCommand {
|
||||
private apiService = new ApiService();
|
||||
private generateService = new GenerateService();
|
||||
|
||||
// TODO improve typing
|
||||
async execute(
|
||||
appPath: string = CURRENT_EXECUTION_DIRECTORY,
|
||||
): Promise<ApiResponse<any>> {
|
||||
@@ -16,21 +17,7 @@ export class AppSyncCommand {
|
||||
console.log(chalk.gray(`📁 App Path: ${appPath}`));
|
||||
console.log('');
|
||||
|
||||
const { manifest, packageJson, yarnLock } = await loadManifest(appPath);
|
||||
|
||||
const result = await this.apiService.syncApplication({
|
||||
manifest,
|
||||
packageJson,
|
||||
yarnLock,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(chalk.red('❌ Sync failed:'), result.error);
|
||||
} else {
|
||||
console.log(chalk.green('✅ Application synced successfully'));
|
||||
}
|
||||
|
||||
return result;
|
||||
return await this.synchronize({ appPath });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('Sync failed:'),
|
||||
@@ -39,4 +26,38 @@ export class AppSyncCommand {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async synchronize({ appPath }: { appPath: string }) {
|
||||
const { manifest, packageJson, yarnLock, isTwentyClientUsed } =
|
||||
await loadManifest(appPath);
|
||||
|
||||
let serverlessSyncResult = await this.apiService.syncApplication({
|
||||
manifest,
|
||||
packageJson,
|
||||
yarnLock,
|
||||
});
|
||||
|
||||
if (isTwentyClientUsed) {
|
||||
await this.generateService.generateClient(appPath);
|
||||
|
||||
const { manifest: manifestWithClient } = await loadManifest(appPath);
|
||||
|
||||
serverlessSyncResult = await this.apiService.syncApplication({
|
||||
manifest: manifestWithClient,
|
||||
packageJson,
|
||||
yarnLock,
|
||||
});
|
||||
}
|
||||
|
||||
if (!serverlessSyncResult.success) {
|
||||
console.error(
|
||||
chalk.red('❌ Serverless functions Sync failed:'),
|
||||
serverlessSyncResult.error,
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green('✅ Serverless functions synced successfully'));
|
||||
}
|
||||
|
||||
return serverlessSyncResult;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AppDevCommand } from './app-dev.command';
|
||||
import { AppInitCommand } from './app-init.command';
|
||||
import { AppSyncCommand } from './app-sync.command';
|
||||
import { formatPath } from '../utils/format-path';
|
||||
import { AppGenerateCommand } from './app-generate.command';
|
||||
|
||||
export class AppCommand {
|
||||
private devCommand = new AppDevCommand();
|
||||
@@ -17,6 +18,7 @@ export class AppCommand {
|
||||
private deleteCommand = new AppDeleteCommand();
|
||||
private initCommand = new AppInitCommand();
|
||||
private addCommand = new AppAddCommand();
|
||||
private generateCommand = new AppGenerateCommand();
|
||||
|
||||
getCommand(): Command {
|
||||
const appCommand = new Command('app');
|
||||
@@ -96,6 +98,13 @@ export class AppCommand {
|
||||
await this.addCommand.execute(entityType as SyncableEntity);
|
||||
});
|
||||
|
||||
appCommand
|
||||
.command('generate [outputPath]')
|
||||
.description('Generate Twenty client')
|
||||
.action(async (appPath?: string) => {
|
||||
await this.generateCommand.execute(formatPath(appPath));
|
||||
});
|
||||
|
||||
return appCommand;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,8 @@
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
"resolveJsonModule": true,
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
type PackageJson,
|
||||
} from '../types/config.types';
|
||||
import { ConfigService } from './config.service';
|
||||
import {
|
||||
buildClientSchema,
|
||||
getIntrospectionQuery,
|
||||
printSchema,
|
||||
} from 'graphql/index';
|
||||
|
||||
export class ApiService {
|
||||
private client: AxiosInstance;
|
||||
@@ -188,4 +193,48 @@ export class ApiService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getSchema(): Promise<ApiResponse<string>> {
|
||||
try {
|
||||
const introspectionQuery = getIntrospectionQuery();
|
||||
|
||||
const response = await this.client.post(
|
||||
'/graphql',
|
||||
{
|
||||
query: introspectionQuery,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: '*/*',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.errors) {
|
||||
return {
|
||||
success: false,
|
||||
error: `GraphQL introspection errors: ${JSON.stringify(response.data.errors)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const schema = buildClientSchema(response.data.data);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: printSchema(schema),
|
||||
message: 'Successfully load schema',
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error.response.data.errors[0]?.message ||
|
||||
'Failed to load graphql Schema',
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import chalk from 'chalk';
|
||||
import { generate } from '@genql/cli';
|
||||
import { join, resolve } from 'path';
|
||||
import { ConfigService } from './config.service';
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
export const GENERATED_FOLDER_NAME = 'generated';
|
||||
|
||||
export class GenerateService {
|
||||
private configService: ConfigService;
|
||||
private apiService: ApiService;
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService();
|
||||
this.apiService = new ApiService();
|
||||
}
|
||||
|
||||
async generateClient(appPath: string): Promise<void> {
|
||||
const outputPath = join(appPath, GENERATED_FOLDER_NAME);
|
||||
|
||||
console.log(chalk.blue('📦 Generating Twenty client...'));
|
||||
console.log(chalk.gray(`📁 Output Path: ${outputPath}`));
|
||||
console.log('');
|
||||
const config = await this.configService.getConfig();
|
||||
|
||||
const url = config.apiUrl;
|
||||
const token = config.apiKey;
|
||||
|
||||
if (!url || !token) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ Skipping Client generation: API URL or token not configured',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.gray(`API URL: ${url}`));
|
||||
console.log(chalk.gray(`Output: ${outputPath}`));
|
||||
|
||||
const { data: schema } = await this.apiService.getSchema();
|
||||
|
||||
await generate({
|
||||
schema,
|
||||
output: resolve(outputPath),
|
||||
scalarTypes: {
|
||||
DateTime: 'string',
|
||||
JSON: 'Record<string, unknown>',
|
||||
UUID: 'string',
|
||||
},
|
||||
verbose: true,
|
||||
});
|
||||
|
||||
console.log(chalk.green('✓ Client generated successfully!'));
|
||||
console.log(chalk.gray(`Generated files at: ${outputPath}`));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ensureDirSync, removeSync, writeFileSync } from 'fs-extra';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { ensureDirSync, writeFileSync, removeSync } from 'fs-extra';
|
||||
import { copyBaseApplicationProject } from '../app-template';
|
||||
import { loadManifest } from '../load-manifest';
|
||||
|
||||
@@ -24,13 +24,13 @@ const tsLibMock = `declare module 'tslib' {
|
||||
const twentySdkTypesMock = `
|
||||
declare module 'twenty-sdk/application' {
|
||||
export type SyncableEntityOptions = { universalIdentifier: string };
|
||||
|
||||
|
||||
type ApplicationVariable = SyncableEntityOptions & {
|
||||
value?: string;
|
||||
description?: string;
|
||||
isSecret?: boolean;
|
||||
};
|
||||
|
||||
|
||||
export type ApplicationConfig = SyncableEntityOptions & {
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
@@ -44,27 +44,27 @@ declare module 'twenty-sdk/application' {
|
||||
httpMethod: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
isAuthRequired: boolean;
|
||||
};
|
||||
|
||||
|
||||
type CronTrigger = {
|
||||
type: 'cron';
|
||||
pattern: string;
|
||||
};
|
||||
|
||||
|
||||
type DatabaseEventTrigger = {
|
||||
type: 'databaseEvent';
|
||||
eventName: string;
|
||||
};
|
||||
|
||||
|
||||
type ServerlessFunctionTrigger = SyncableEntityOptions &
|
||||
(RouteTrigger | CronTrigger | DatabaseEventTrigger);
|
||||
|
||||
|
||||
export type ServerlessFunctionConfig = SyncableEntityOptions & {
|
||||
name?: string;
|
||||
description?: string;
|
||||
timeoutSeconds?: number;
|
||||
triggers?: ServerlessFunctionTrigger[];
|
||||
};
|
||||
|
||||
|
||||
type ObjectMetadataOptions = SyncableEntityOptions & {
|
||||
nameSingular: string;
|
||||
namePlural: string;
|
||||
@@ -73,7 +73,7 @@ declare module 'twenty-sdk/application' {
|
||||
description?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
|
||||
export const ObjectMetadata = (_: ObjectMetadataOptions): ClassDecorator => {
|
||||
return () => {};
|
||||
};
|
||||
@@ -412,11 +412,8 @@ export const format = async (params: any): Promise<any> => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails fast if TS validation fails', async () => {
|
||||
write(appDirectory, 'src/utils/broken.ts', `const x: number = 'oops';`);
|
||||
|
||||
await expect(loadManifest(appDirectory)).rejects.toThrow(
|
||||
/TypeScript validation failed/,
|
||||
);
|
||||
it('manifest should contains typescript sources', async () => {
|
||||
const { isTwentyClientUsed } = await loadManifest(appDirectory);
|
||||
expect(isTwentyClientUsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import ts, { formatDiagnosticsWithColorAndContext, sys } from 'typescript';
|
||||
|
||||
export const formatAndWarnTsDiagnostics = ({
|
||||
diagnostics,
|
||||
}: {
|
||||
diagnostics: ts.Diagnostic[];
|
||||
}) => {
|
||||
if (diagnostics.length > 0) {
|
||||
const formattedDiagnostics = formatDiagnosticsWithColorAndContext(
|
||||
diagnostics,
|
||||
{
|
||||
getCanonicalFileName: (f) => f,
|
||||
getCurrentDirectory: sys.getCurrentDirectory,
|
||||
getNewLine: () => sys.newLine,
|
||||
},
|
||||
);
|
||||
|
||||
console.warn(formattedDiagnostics);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import ts from 'typescript';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
createProgram,
|
||||
formatDiagnosticsWithColorAndContext,
|
||||
parseJsonConfigFileContent,
|
||||
readConfigFile,
|
||||
sys,
|
||||
} from 'typescript';
|
||||
|
||||
const getProgramFromTsconfig = ({
|
||||
appPath,
|
||||
tsconfigPath = 'tsconfig.json',
|
||||
}: {
|
||||
appPath: string;
|
||||
tsconfigPath?: string;
|
||||
}) => {
|
||||
const configFile = readConfigFile(join(appPath, tsconfigPath), sys.readFile);
|
||||
if (configFile.error)
|
||||
throw new Error(
|
||||
formatDiagnosticsWithColorAndContext([configFile.error], {
|
||||
getCanonicalFileName: (f) => f,
|
||||
getCurrentDirectory: sys.getCurrentDirectory,
|
||||
getNewLine: () => sys.newLine,
|
||||
}),
|
||||
);
|
||||
const parsed = parseJsonConfigFileContent(configFile.config, sys, appPath);
|
||||
if (parsed.errors.length) {
|
||||
throw new Error(
|
||||
formatDiagnosticsWithColorAndContext(parsed.errors, {
|
||||
getCanonicalFileName: (f) => f,
|
||||
getCurrentDirectory: sys.getCurrentDirectory,
|
||||
getNewLine: () => sys.newLine,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return createProgram(parsed.fileNames, parsed.options);
|
||||
};
|
||||
|
||||
export const getTsProgramAndDiagnostics = async ({
|
||||
appPath,
|
||||
}: {
|
||||
appPath: string;
|
||||
}): Promise<{ program: ts.Program; diagnostics: ts.Diagnostic[] }> => {
|
||||
const program = getProgramFromTsconfig({
|
||||
appPath,
|
||||
tsconfigPath: 'tsconfig.json',
|
||||
});
|
||||
|
||||
return {
|
||||
diagnostics: [
|
||||
...program.getSyntacticDiagnostics(),
|
||||
...program.getSemanticDiagnostics(),
|
||||
...program.getGlobalDiagnostics(),
|
||||
],
|
||||
program,
|
||||
};
|
||||
};
|
||||
@@ -1,52 +1,52 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { posix, relative, sep } from 'path';
|
||||
import {
|
||||
sys,
|
||||
getDecorators,
|
||||
readConfigFile,
|
||||
parseJsonConfigFileContent,
|
||||
formatDiagnosticsWithColorAndContext,
|
||||
createProgram,
|
||||
Decorator,
|
||||
isPropertyAccessExpression,
|
||||
isNumericLiteral,
|
||||
SyntaxKind,
|
||||
isArrayLiteralExpression,
|
||||
Expression,
|
||||
isPropertyAssignment,
|
||||
isComputedPropertyName,
|
||||
isStringLiteralLike,
|
||||
isShorthandPropertyAssignment,
|
||||
isIdentifier,
|
||||
FunctionDeclaration,
|
||||
VariableDeclaration,
|
||||
Program,
|
||||
Node,
|
||||
isClassDeclaration,
|
||||
isCallExpression,
|
||||
isObjectLiteralExpression,
|
||||
forEachChild,
|
||||
SourceFile,
|
||||
isVariableStatement,
|
||||
isArrowFunction,
|
||||
isFunctionExpression,
|
||||
isExportAssignment,
|
||||
Modifier,
|
||||
isPropertyDeclaration,
|
||||
Node,
|
||||
Program,
|
||||
SourceFile,
|
||||
SyntaxKind,
|
||||
VariableDeclaration,
|
||||
forEachChild,
|
||||
getDecorators,
|
||||
isArrayLiteralExpression,
|
||||
isArrowFunction,
|
||||
isCallExpression,
|
||||
isClassDeclaration,
|
||||
isComputedPropertyName,
|
||||
isExportAssignment,
|
||||
isFunctionExpression,
|
||||
isIdentifier,
|
||||
isNoSubstitutionTemplateLiteral,
|
||||
isNumericLiteral,
|
||||
isObjectLiteralExpression,
|
||||
isPropertyAccessExpression,
|
||||
isPropertyAssignment,
|
||||
isPropertyDeclaration,
|
||||
isShorthandPropertyAssignment,
|
||||
isStringLiteralLike,
|
||||
isTemplateExpression,
|
||||
isVariableStatement,
|
||||
isImportDeclaration,
|
||||
NamedImports,
|
||||
} from 'typescript';
|
||||
import {
|
||||
AppManifest,
|
||||
Application,
|
||||
FieldMetadata,
|
||||
ObjectManifest,
|
||||
PackageJson,
|
||||
ServerlessFunctionManifest,
|
||||
Sources,
|
||||
FieldMetadata,
|
||||
} from '../types/config.types';
|
||||
import { posix, relative, sep, resolve, join } from 'path';
|
||||
import { parseJsoncFile, parseTextFile } from '../utils/jsonc-parser';
|
||||
import { findPathFile } from '../utils/find-path-file';
|
||||
import { parseJsoncFile, parseTextFile } from '../utils/jsonc-parser';
|
||||
import { formatAndWarnTsDiagnostics } from './format-and-warn-ts-diagnostics';
|
||||
import { getTsProgramAndDiagnostics } from '../utils/get-ts-program-and-diagnostics';
|
||||
import { GENERATED_FOLDER_NAME } from '../services/generate.service';
|
||||
|
||||
type JSONValue =
|
||||
| string
|
||||
@@ -56,32 +56,6 @@ type JSONValue =
|
||||
| JSONValue[]
|
||||
| { [k: string]: JSONValue };
|
||||
|
||||
const getProgramFromTsconfig = (
|
||||
appPath: string,
|
||||
tsconfigPath = 'tsconfig.json',
|
||||
) => {
|
||||
const configFile = readConfigFile(join(appPath, tsconfigPath), sys.readFile);
|
||||
if (configFile.error)
|
||||
throw new Error(
|
||||
formatDiagnosticsWithColorAndContext([configFile.error], {
|
||||
getCanonicalFileName: (f) => f,
|
||||
getCurrentDirectory: sys.getCurrentDirectory,
|
||||
getNewLine: () => sys.newLine,
|
||||
}),
|
||||
);
|
||||
const parsed = parseJsonConfigFileContent(configFile.config, sys, appPath);
|
||||
if (parsed.errors.length) {
|
||||
throw new Error(
|
||||
formatDiagnosticsWithColorAndContext(parsed.errors, {
|
||||
getCanonicalFileName: (f) => f,
|
||||
getCurrentDirectory: sys.getCurrentDirectory,
|
||||
getNewLine: () => sys.newLine,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return createProgram(parsed.fileNames, parsed.options);
|
||||
};
|
||||
|
||||
const isDecoratorNamed = (node: Decorator, name: string): node is Decorator => {
|
||||
const expr = node.expression;
|
||||
if (isCallExpression(expr)) {
|
||||
@@ -392,23 +366,6 @@ const collectServerlessFunctions = (program: Program, appPath: string) => {
|
||||
return serverlessFunctions;
|
||||
};
|
||||
|
||||
const validateProgram = (program: Program) => {
|
||||
const diagnostics = [
|
||||
...program.getSyntacticDiagnostics(),
|
||||
...program.getSemanticDiagnostics(),
|
||||
...program.getGlobalDiagnostics(),
|
||||
];
|
||||
|
||||
if (diagnostics.length > 0) {
|
||||
const formatted = formatDiagnosticsWithColorAndContext(diagnostics, {
|
||||
getCanonicalFileName: (f) => f,
|
||||
getCurrentDirectory: sys.getCurrentDirectory,
|
||||
getNewLine: () => sys.newLine,
|
||||
});
|
||||
throw new Error(`TypeScript validation failed:\n${formatted}`);
|
||||
}
|
||||
};
|
||||
|
||||
const setNested = (root: Sources, parts: string[], value: string) => {
|
||||
let cur: Sources = root;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
@@ -423,14 +380,10 @@ const setNested = (root: Sources, parts: string[], value: string) => {
|
||||
};
|
||||
|
||||
const loadFolderContentIntoJson = async (
|
||||
sourcePath = '.',
|
||||
tsconfigPath = 'tsconfig.json',
|
||||
program: Program,
|
||||
appPath: string,
|
||||
): Promise<Sources> => {
|
||||
const sources: Sources = {};
|
||||
const baseAbs = resolve(sourcePath);
|
||||
|
||||
// Build the program from tsconfig (uses your getProgramFromTsconfig)
|
||||
const program: Program = getProgramFromTsconfig(baseAbs, tsconfigPath);
|
||||
|
||||
// Iterate only files the TS program knows about.
|
||||
for (const sf of program.getSourceFiles()) {
|
||||
@@ -438,7 +391,7 @@ const loadFolderContentIntoJson = async (
|
||||
|
||||
// Skip .d.ts and anything outside sourcePath
|
||||
if (sf.isDeclarationFile) continue;
|
||||
if (!abs.startsWith(baseAbs + sep) && abs !== baseAbs) continue;
|
||||
if (!abs.startsWith(appPath + sep) && abs !== appPath) continue;
|
||||
|
||||
// Keep only TS/TSX files
|
||||
if (!(abs.endsWith('.ts') || abs.endsWith('.tsx'))) continue;
|
||||
@@ -446,7 +399,7 @@ const loadFolderContentIntoJson = async (
|
||||
// Optional extra guard (usually unnecessary if tsconfig excludes node_modules)
|
||||
if (abs.includes(`${sep}node_modules${sep}`)) continue;
|
||||
|
||||
const relFromRoot = relative(baseAbs, abs);
|
||||
const relFromRoot = relative(appPath, abs);
|
||||
const parts = relFromRoot.split(sep);
|
||||
|
||||
const content = await fs.readFile(abs, 'utf8');
|
||||
@@ -495,15 +448,67 @@ export const extractTwentyAppConfig = (program: Program): Application => {
|
||||
throw new Error('Could not find default exported ApplicationConfig');
|
||||
};
|
||||
|
||||
const isTwentyClientUsedInProgram = (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 &&
|
||||
node.importClause.namedBindings &&
|
||||
node.importClause.namedBindings.kind === SyntaxKind.NamedImports
|
||||
) {
|
||||
const namedImports = node.importClause
|
||||
.namedBindings as NamedImports;
|
||||
|
||||
for (const element of namedImports.elements) {
|
||||
const importedName =
|
||||
element.propertyName?.text ?? element.name.text;
|
||||
|
||||
if (importedName === 'createClient') {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sf);
|
||||
|
||||
if (found) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const loadManifest = async (
|
||||
path?: string,
|
||||
appPath: string,
|
||||
): Promise<{
|
||||
packageJson: PackageJson;
|
||||
yarnLock: string;
|
||||
manifest: AppManifest;
|
||||
isTwentyClientUsed: boolean;
|
||||
}> => {
|
||||
const appPath = path ?? process.cwd();
|
||||
|
||||
const packageJson = await parseJsoncFile(
|
||||
await findPathFile(appPath, 'package.json'),
|
||||
);
|
||||
@@ -512,17 +517,22 @@ export const loadManifest = async (
|
||||
await findPathFile(appPath, 'yarn.lock'),
|
||||
);
|
||||
|
||||
const program = getProgramFromTsconfig(appPath, 'tsconfig.json');
|
||||
const { diagnostics, program } = await getTsProgramAndDiagnostics({
|
||||
appPath,
|
||||
});
|
||||
|
||||
validateProgram(program);
|
||||
formatAndWarnTsDiagnostics({
|
||||
diagnostics,
|
||||
});
|
||||
|
||||
const [objects, serverlessFunctions, application, sources] =
|
||||
await Promise.all([
|
||||
Promise.resolve(collectObjects(program)),
|
||||
Promise.resolve(collectServerlessFunctions(program, appPath)),
|
||||
Promise.resolve(extractTwentyAppConfig(program)),
|
||||
loadFolderContentIntoJson(appPath),
|
||||
]);
|
||||
const [objects, serverlessFunctions, application, sources] = [
|
||||
collectObjects(program),
|
||||
collectServerlessFunctions(program, appPath),
|
||||
extractTwentyAppConfig(program),
|
||||
await loadFolderContentIntoJson(program, appPath),
|
||||
];
|
||||
|
||||
const isTwentyClientUsed = isTwentyClientUsedInProgram(program);
|
||||
|
||||
return {
|
||||
packageJson,
|
||||
@@ -533,5 +543,6 @@ export const loadManifest = async (
|
||||
serverlessFunctions,
|
||||
sources,
|
||||
},
|
||||
isTwentyClientUsed,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user