Fix app install file upload (#18593)
remove wrong file path based file selection --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
parent
0641e07ca6
commit
f3e0c12ce6
@@ -58,6 +58,12 @@ yarn twenty function:execute --preInstall
|
||||
# Execute the post-install function
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
# Build the app for distribution
|
||||
yarn twenty app:build
|
||||
|
||||
# Publish the app to npm or directly to a Twenty server
|
||||
yarn twenty app:publish
|
||||
|
||||
# Uninstall the application from the current workspace
|
||||
yarn twenty app:uninstall
|
||||
```
|
||||
@@ -109,29 +115,40 @@ npx create-twenty-app@latest my-app -m
|
||||
- Use `yarn twenty app:dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
|
||||
- `CoreApiClient` (for workspace data via `/graphql`) is auto-generated by `yarn twenty app:dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/clients'`.
|
||||
|
||||
## Publish your application
|
||||
## Build and publish your application
|
||||
|
||||
Applications are currently stored in `twenty/packages/twenty-apps`.
|
||||
Once your app is ready, build and publish it using the CLI:
|
||||
|
||||
You can share your application with all Twenty users:
|
||||
```bash
|
||||
# Build the app (output goes to .twenty/output/)
|
||||
yarn twenty app:build
|
||||
|
||||
# Build and create a tarball (.tgz) for distribution
|
||||
yarn twenty app:build --tarball
|
||||
|
||||
# Publish to npm (requires npm login)
|
||||
yarn twenty app:publish
|
||||
|
||||
# Publish with a dist-tag (e.g. beta, next)
|
||||
yarn twenty app:publish --tag beta
|
||||
|
||||
# Publish directly to a Twenty server (builds, uploads, and installs in one step)
|
||||
yarn twenty app:publish --server https://app.twenty.com
|
||||
```
|
||||
|
||||
### Publish to the Twenty marketplace
|
||||
|
||||
You can also contribute your application to the curated marketplace:
|
||||
|
||||
```bash
|
||||
# pull the Twenty project
|
||||
git clone https://github.com/twentyhq/twenty.git
|
||||
cd twenty
|
||||
|
||||
# create a new branch
|
||||
git checkout -b feature/my-awesome-app
|
||||
```
|
||||
|
||||
- Copy your app folder into `twenty/packages/twenty-apps`.
|
||||
- Commit your changes and open a pull request on https://github.com/twentyhq/twenty
|
||||
|
||||
```bash
|
||||
git commit -m "Add new application"
|
||||
git push
|
||||
```
|
||||
|
||||
Our team reviews contributions for quality, security, and reusability before merging.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "0.7.0-canary.0",
|
||||
"version": "0.7.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -63,6 +63,12 @@ yarn twenty function:execute --preInstall
|
||||
# Execute the post-install function
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
# Build the app for distribution
|
||||
yarn twenty app:build
|
||||
|
||||
# Publish the app to npm or a Twenty server
|
||||
yarn twenty app:publish
|
||||
|
||||
# Uninstall the application from the current workspace
|
||||
yarn twenty app:uninstall
|
||||
|
||||
@@ -1224,6 +1230,113 @@ Key points:
|
||||
|
||||
Explore a minimal, end-to-end example that demonstrates objects, logic functions, front components, and multiple triggers [here](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/hello-world):
|
||||
|
||||
## Building your app
|
||||
|
||||
Once you've developed your app with `app:dev`, use `app:build` to compile it into a distributable package.
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Build the app (output goes to .twenty/output/)
|
||||
yarn twenty app:build
|
||||
|
||||
# Build and create a tarball (.tgz) for distribution
|
||||
yarn twenty app:build --tarball
|
||||
```
|
||||
|
||||
The build process:
|
||||
|
||||
1. **Parses and validates the manifest** — reads all `defineX()` entities from your source files and validates the manifest structure.
|
||||
2. **Compiles logic functions and front components** — bundles TypeScript sources into ESM `.mjs` files using esbuild.
|
||||
3. **Generates checksums** — computes MD5 hashes for each built file, stored in the manifest as `builtHandlerChecksum` / `builtComponentChecksum`.
|
||||
4. **Generates the typed API client** — introspects the GraphQL schema and generates typed `CoreApiClient` and `MetadataApiClient` clients.
|
||||
5. **Runs a TypeScript type check** — runs `tsc --noEmit` to catch type errors before publishing.
|
||||
6. **Rebuilds with the generated client** — performs a second compilation pass so the generated client types are included.
|
||||
7. **Optionally creates a tarball** — if `--tarball` is passed, runs `npm pack` to create a `.tgz` file ready for distribution.
|
||||
|
||||
The build output in `.twenty/output/` contains:
|
||||
|
||||
```text
|
||||
.twenty/output/
|
||||
├── manifest.json # Manifest with checksums for all built files
|
||||
├── package.json # Copied from app root
|
||||
├── yarn.lock # Copied from app root
|
||||
├── src/
|
||||
│ ├── logic-functions/ # Compiled .mjs logic function files
|
||||
│ └── front-components/ # Compiled .mjs front component files
|
||||
├── public/ # Static assets (if any)
|
||||
└── my-app-1.0.0.tgz # Only with --tarball flag
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `[appPath]` | Path to the app directory (defaults to current directory) |
|
||||
| `--tarball` | Also pack the output into a `.tgz` tarball |
|
||||
|
||||
## Publishing your app
|
||||
|
||||
Use `app:publish` to distribute your app — either to the npm registry or directly to a Twenty server.
|
||||
|
||||
### Publish to npm (default)
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Publish to npm (requires npm login)
|
||||
yarn twenty app:publish
|
||||
|
||||
# Publish with a dist-tag (e.g. beta, next)
|
||||
yarn twenty app:publish --tag beta
|
||||
```
|
||||
|
||||
This builds the app and runs `npm publish` from the `.twenty/output/` directory. The published package can then be installed from the Twenty marketplace by any workspace.
|
||||
|
||||
### Publish to a Twenty server
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Publish directly to a Twenty server
|
||||
yarn twenty app:publish --server https://app.twenty.com
|
||||
```
|
||||
|
||||
This builds the app with a tarball, uploads it to the server via the `uploadAppTarball` GraphQL mutation, and triggers installation in one step. This is useful for private deployments or testing against a specific server.
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `[appPath]` | Path to the app directory (defaults to current directory) |
|
||||
| `--server <url>` | Publish to a Twenty server instead of npm |
|
||||
| `--token <token>` | Authentication token for the target server |
|
||||
| `--tag <tag>` | npm dist-tag (e.g. `beta`, `next`) — only for npm publish |
|
||||
|
||||
## Application registration
|
||||
|
||||
Before an app can be installed in a workspace, it must be **registered**. A registration is a metadata record that describes where the app comes from and how to authenticate it. This is handled automatically by the CLI in most cases.
|
||||
|
||||
### Source types
|
||||
|
||||
Each registration has a **source type** that determines how the app's files are resolved during installation:
|
||||
|
||||
| Source type | How files are resolved | Typical use case |
|
||||
|-------------|----------------------|------------------|
|
||||
| `LOCAL` | Files are synced in real-time by the CLI watcher — installation is skipped | Development with `app:dev` |
|
||||
| `NPM` | Fetched from the npm registry via the `sourcePackage` field | Published apps on npm |
|
||||
| `TARBALL` | Extracted from an uploaded `.tgz` file stored on the server | Private apps published with `--server` |
|
||||
|
||||
### How registration happens
|
||||
|
||||
- **`app:dev`** — automatically creates a `LOCAL` registration the first time you run dev mode against a workspace.
|
||||
- **`app:publish --server`** — uploads a tarball and creates (or updates) a `TARBALL` registration, then installs the app.
|
||||
- **npm marketplace** — `NPM` registrations are created when apps are synced from the npm registry into the Twenty marketplace catalog.
|
||||
- **GraphQL API** — you can also create registrations programmatically via the `createApplicationRegistration` mutation.
|
||||
|
||||
### Registration vs installation
|
||||
|
||||
**Registration** and **installation** are separate concepts:
|
||||
|
||||
- A **registration** (`ApplicationRegistration`) is a global metadata record describing the app: its name, source type, OAuth credentials, and marketplace listing status. It exists independently of any workspace.
|
||||
- An **installation** (`Application`) is a per-workspace instance. When a user installs an app, Twenty resolves the package from the registration's source, writes the built files to storage, and synchronizes the manifest (creating objects, fields, logic functions, etc.) in that workspace.
|
||||
|
||||
One registration can be installed in many workspaces. Each workspace gets its own copy of the app's files and data model.
|
||||
|
||||
### OAuth credentials
|
||||
|
||||
Each registration includes OAuth credentials (`oAuthClientId` and `oAuthClientSecret`) generated at creation time. These are used by the app to authenticate API requests on behalf of users. The client secret is returned **once** at creation — store it securely. You can rotate it later via the `rotateApplicationRegistrationClientSecret` mutation.
|
||||
|
||||
## Manual setup (without the scaffolder)
|
||||
|
||||
While we recommend using `create-twenty-app` for the best getting-started experience, you can also set up a project manually. Do not install the CLI globally. Instead, add `twenty-sdk` as a local dependency and wire a single script in your package.json:
|
||||
|
||||
@@ -212,6 +212,21 @@ twenty entity:add navigation-menu-item
|
||||
# Add a new skill
|
||||
twenty entity:add skill
|
||||
|
||||
# Build the app (output in .twenty/output/)
|
||||
twenty app:build
|
||||
|
||||
# Build and create a tarball
|
||||
twenty app:build --tarball
|
||||
|
||||
# Publish to npm
|
||||
twenty app:publish
|
||||
|
||||
# Publish with a dist-tag
|
||||
twenty app:publish --tag beta
|
||||
|
||||
# Publish directly to a Twenty server (builds, uploads, and installs)
|
||||
twenty app:publish --server https://app.twenty.com
|
||||
|
||||
# Uninstall the app from the workspace
|
||||
twenty app:uninstall
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-sdk",
|
||||
"version": "0.7.0-canary.0",
|
||||
"version": "0.7.0",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/sdk/index.d.ts",
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ describe('functionExecute E2E', () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
},
|
||||
{ timeout: 10_000, interval: 1_000 },
|
||||
{ timeout: 30_000, interval: 1_000 },
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
|
||||
+50
-57
@@ -2,8 +2,9 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { join, relative } from 'path';
|
||||
import { resolve } from 'path';
|
||||
|
||||
import { Manifest } from 'twenty-shared/application';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
@@ -24,23 +25,6 @@ import { ApplicationSyncService } from 'src/engine/core-modules/application/appl
|
||||
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
|
||||
const FILE_FOLDER_MAPPING: Record<string, FileFolder> = {
|
||||
'package.json': FileFolder.Dependencies,
|
||||
'yarn.lock': FileFolder.Dependencies,
|
||||
};
|
||||
|
||||
const FILE_FOLDER_PATTERN_MAPPING: Array<{
|
||||
pattern: RegExp;
|
||||
folder: FileFolder;
|
||||
}> = [
|
||||
{ pattern: /\.function\.mjs$/, folder: FileFolder.BuiltLogicFunction },
|
||||
{
|
||||
pattern: /\.front-component\.mjs$/,
|
||||
folder: FileFolder.BuiltFrontComponent,
|
||||
},
|
||||
{ pattern: /^public\//, folder: FileFolder.PublicAsset },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationInstallService {
|
||||
private readonly logger = new Logger(ApplicationInstallService.name);
|
||||
@@ -123,6 +107,7 @@ export class ApplicationInstallService {
|
||||
|
||||
await this.writeFilesToStorage(
|
||||
resolvedPackage.extractedDir,
|
||||
resolvedPackage.manifest,
|
||||
universalIdentifier,
|
||||
params.workspaceId,
|
||||
);
|
||||
@@ -155,15 +140,32 @@ export class ApplicationInstallService {
|
||||
|
||||
private async writeFilesToStorage(
|
||||
extractedDir: string,
|
||||
manifest: Manifest,
|
||||
applicationUniversalIdentifier: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const files = await this.collectFiles(extractedDir);
|
||||
const filesToWrite = this.buildFileList(manifest);
|
||||
|
||||
for (const filePath of files) {
|
||||
const relativePath = relative(extractedDir, filePath);
|
||||
const fileFolder = this.resolveFileFolder(relativePath);
|
||||
const content = await fs.readFile(filePath);
|
||||
for (const { relativePath, fileFolder } of filesToWrite) {
|
||||
const absolutePath = resolve(extractedDir, relativePath);
|
||||
|
||||
if (!absolutePath.startsWith(extractedDir)) {
|
||||
throw new ApplicationException(
|
||||
`Path traversal detected for file: ${relativePath}`,
|
||||
ApplicationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
let content: Buffer;
|
||||
|
||||
try {
|
||||
content = await fs.readFile(absolutePath);
|
||||
} catch {
|
||||
throw new ApplicationException(
|
||||
`File not found in package: ${relativePath}`,
|
||||
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
await this.fileStorageService.writeFile({
|
||||
sourceFile: content,
|
||||
@@ -177,47 +179,38 @@ export class ApplicationInstallService {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveFileFolder(relativePath: string): FileFolder {
|
||||
const exact = FILE_FOLDER_MAPPING[relativePath];
|
||||
private buildFileList(
|
||||
manifest: Manifest,
|
||||
): Array<{ relativePath: string; fileFolder: FileFolder }> {
|
||||
const files: Array<{ relativePath: string; fileFolder: FileFolder }> = [];
|
||||
|
||||
if (isDefined(exact)) {
|
||||
return exact;
|
||||
files.push(
|
||||
{ relativePath: 'package.json', fileFolder: FileFolder.Dependencies },
|
||||
{ relativePath: 'manifest.json', fileFolder: FileFolder.Source },
|
||||
);
|
||||
|
||||
for (const logicFunction of manifest.logicFunctions ?? []) {
|
||||
files.push({
|
||||
relativePath: logicFunction.builtHandlerPath,
|
||||
fileFolder: FileFolder.BuiltLogicFunction,
|
||||
});
|
||||
}
|
||||
|
||||
for (const { pattern, folder } of FILE_FOLDER_PATTERN_MAPPING) {
|
||||
if (pattern.test(relativePath)) {
|
||||
return folder;
|
||||
}
|
||||
for (const frontComponent of manifest.frontComponents ?? []) {
|
||||
files.push({
|
||||
relativePath: frontComponent.builtComponentPath,
|
||||
fileFolder: FileFolder.BuiltFrontComponent,
|
||||
});
|
||||
}
|
||||
|
||||
return FileFolder.Source;
|
||||
}
|
||||
|
||||
private async collectFiles(dir: string): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.name === 'node_modules' || entry.name === '.yarn') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const subFiles = await this.collectFiles(fullPath);
|
||||
|
||||
result.push(...subFiles);
|
||||
} else {
|
||||
result.push(fullPath);
|
||||
}
|
||||
for (const publicAsset of manifest.publicAssets ?? []) {
|
||||
files.push({
|
||||
relativePath: publicAsset.filePath,
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
return files;
|
||||
}
|
||||
|
||||
private async ensureApplicationExists(params: {
|
||||
|
||||
+41
-10
@@ -1,7 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { join } from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -239,7 +240,16 @@ export class LogicFunctionResourceService {
|
||||
workspaceId: string;
|
||||
inMemoryFolderPath: string;
|
||||
}) {
|
||||
await Promise.all([
|
||||
const yarnLockExists = await this.fileStorageService.checkFileExists({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
fileFolder: FileFolder.Dependencies,
|
||||
resourcePath: 'yarn.lock',
|
||||
});
|
||||
|
||||
const promises = [];
|
||||
|
||||
promises.push(
|
||||
this.fileStorageService.downloadFile({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
@@ -247,14 +257,35 @@ export class LogicFunctionResourceService {
|
||||
resourcePath: 'package.json',
|
||||
localPath: join(inMemoryFolderPath, 'package.json'),
|
||||
}),
|
||||
this.fileStorageService.downloadFile({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
fileFolder: FileFolder.Dependencies,
|
||||
resourcePath: 'yarn.lock',
|
||||
localPath: join(inMemoryFolderPath, 'yarn.lock'),
|
||||
}),
|
||||
]);
|
||||
);
|
||||
|
||||
if (yarnLockExists) {
|
||||
promises.push(
|
||||
this.fileStorageService.downloadFile({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
fileFolder: FileFolder.Dependencies,
|
||||
resourcePath: 'yarn.lock',
|
||||
localPath: join(inMemoryFolderPath, 'yarn.lock'),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const yarnLockPath = join(inMemoryFolderPath, 'yarn.lock');
|
||||
|
||||
promises.push(
|
||||
fs.mkdir(dirname(yarnLockPath), { recursive: true }).then(() =>
|
||||
fs.writeFile(
|
||||
yarnLockPath,
|
||||
`# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
`,
|
||||
'utf-8',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
async getBuiltCode({
|
||||
|
||||
Reference in New Issue
Block a user