Merge remote-tracking branch 'upstream/main' into permission-flag-definition-foundation

This commit is contained in:
ehconitin
2026-05-08 19:02:57 +05:30
25 changed files with 4815 additions and 6 deletions
-1
View File
@@ -60,7 +60,6 @@
"packages/twenty-sdk",
"packages/twenty-front-component-renderer",
"packages/twenty-client-sdk",
"packages/twenty-apps",
"packages/twenty-cli",
"packages/create-twenty-app",
"packages/twenty-oxlint-rules",
@@ -0,0 +1,14 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -0,0 +1,14 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -0,0 +1,14 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -20,7 +20,7 @@
"test:watch": "vitest --config vitest.unit.config.ts"
},
"dependencies": {
"twenty-sdk": "2.1.0"
"twenty-sdk": "2.3.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
@@ -31,4 +31,35 @@ export default defineLogicFunction({
required: ['teamId', 'title'],
},
},
workflowActionTriggerSettings: {
label: 'Create Linear Issue',
inputSchema: [
{
type: 'object',
properties: {
teamId: { type: 'string' },
title: { type: 'string' },
description: { type: 'string' },
},
},
],
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
issue: {
type: 'object',
properties: {
id: { type: 'string' },
identifier: { type: 'string' },
title: { type: 'string' },
url: { type: 'string' },
},
},
error: { type: 'string' },
},
},
],
},
});
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
{"tags": ["scope:apps"]}
@@ -5,6 +5,8 @@ import { OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orc
import { renderDevUI } from '@/cli/utilities/dev/ui/components/dev-ui';
import { DevUiStateManager } from '@/cli/utilities/dev/ui/dev-ui-state-manager';
import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility';
import { checkServerVersionCompatibility } from '@/cli/utilities/version/check-server-version-compatibility';
import { getVersionInfo } from '@/cli/utilities/version/get-version-info';
export type AppDevOptions = {
appPath?: string;
@@ -30,6 +32,10 @@ export class AppDevCommand {
await checkSdkVersionCompatibility(appPath);
if (options.headless) {
await checkServerVersionCompatibility();
}
const config = await new ConfigService().getConfig();
const orchestratorState = new OrchestratorState({
@@ -45,6 +51,10 @@ export class AppDevCommand {
const { unmount } = await renderDevUI(uiStateManager);
this.unmountUI = unmount;
void getVersionInfo().then((info) =>
orchestratorState.setVersionInfo(info),
);
}
this.orchestrator = new DevModeOrchestrator({
@@ -1,6 +1,7 @@
import { appInstall } from '@/cli/operations/install';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility';
import { checkServerVersionCompatibility } from '@/cli/utilities/version/check-server-version-compatibility';
import chalk from 'chalk';
export type AppInstallCommandOptions = {
@@ -13,6 +14,7 @@ export class AppInstallCommand {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
await checkSdkVersionCompatibility(appPath);
await checkServerVersionCompatibility();
console.log(chalk.blue('Installing application...'));
console.log(chalk.gray(`App path: ${appPath}\n`));
@@ -18,6 +18,7 @@ import {
checkServerHealth,
detectLocalServer,
} from '@/cli/utilities/server/detect-local-server';
import { checkServerVersionCompatibility } from '@/cli/utilities/version/check-server-version-compatibility';
import { execSync, spawn, spawnSync } from 'node:child_process';
import chalk from 'chalk';
@@ -274,10 +275,19 @@ const innerServerStart = async (
return { success: true, data: { port, url } };
};
export const serverStart = (
export const serverStart = async (
options?: ServerStartOptions,
): Promise<CommandResult<ServerStartResult>> =>
runSafe(
): Promise<CommandResult<ServerStartResult>> => {
const result = await runSafe(
() => innerServerStart(options),
SERVER_ERROR_CODES.CONTAINER_START_FAILED,
);
if (result.success) {
const containerName = options?.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
await checkServerVersionCompatibility(containerName);
}
return result;
};
@@ -4,6 +4,7 @@ import { type CheckServerOrchestratorStepOutput } from '@/cli/utilities/dev/orch
import { type StartWatchersOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/start-watchers-orchestrator-step';
import { type SyncApplicationOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step';
import { type UploadFilesOrchestratorStepOutput } from '@/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step';
import { type VersionInfo } from '@/cli/utilities/version/version-info';
import { type Manifest, SyncableEntity } from 'twenty-shared/application';
import { type FileFolder } from 'twenty-shared/types';
@@ -114,6 +115,8 @@ export class OrchestratorState {
pipeline: OrchestratorStatePipeline;
versionInfo: VersionInfo | null;
entities: Map<string, OrchestratorStateEntityInfo>;
events: OrchestratorStateEvent[];
@@ -172,10 +175,17 @@ export class OrchestratorState {
appName: null,
};
this.versionInfo = null;
this.entities = new Map();
this.events = [];
}
setVersionInfo(versionInfo: VersionInfo): void {
this.versionInfo = versionInfo;
this.notify();
}
notify(): void {
this.onChange?.();
}
@@ -17,6 +17,7 @@ import {
DevUiEntitySection,
ENTITY_ORDER,
} from '@/cli/utilities/dev/ui/components/dev-ui-entity-section';
import { DevUiVersionRow } from '@/cli/utilities/dev/ui/components/dev-ui-version-row';
import React from 'react';
export const DevUiSyncStatusIndicator = ({
@@ -96,6 +97,7 @@ export const DevUiApplicationPanel = ({
</Text>
</Box>
)}
<DevUiVersionRow versionInfo={state.versionInfo} />
</Box>
<Box marginLeft={2} flexDirection="column" marginTop={1}>
@@ -0,0 +1,60 @@
import { useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context';
import { type VersionInfo } from '@/cli/utilities/version/version-info';
import React from 'react';
const STALE_THRESHOLD_DAYS = 7;
export const DevUiVersionRow = ({
versionInfo,
}: {
versionInfo: VersionInfo | null;
}): React.ReactElement | null => {
const { Box, Text } = useInk();
if (versionInfo === null) {
return null;
}
const {
cliVersion,
localServerVersion,
latestServerVersion,
isMinorOrMajorBehind,
daysBehind,
} = versionInfo;
const isStale =
isMinorOrMajorBehind &&
daysBehind !== null &&
daysBehind > STALE_THRESHOLD_DAYS;
const isUpToDate =
localServerVersion !== null &&
latestServerVersion !== null &&
localServerVersion === latestServerVersion;
return (
<Box>
<Text dimColor>Versions: </Text>
<Text dimColor>CLI </Text>
<Text>v{cliVersion}</Text>
{localServerVersion !== null && (
<>
<Text dimColor> Server </Text>
<Text color={isStale ? 'yellow' : undefined}>
v{localServerVersion}
</Text>
{isUpToDate && <Text color="green"> latest</Text>}
{isStale && latestServerVersion !== null && (
<>
<Text color="yellow"> v{latestServerVersion}</Text>
{daysBehind !== null && (
<Text dimColor> ({daysBehind}d behind)</Text>
)}
</>
)}
</>
)}
</Box>
);
};
@@ -0,0 +1,19 @@
import { compareSemver } from '@/cli/utilities/version/compare-semver';
describe('compareSemver', () => {
it('returns 0 for equal versions', () => {
expect(compareSemver([2, 4, 0], [2, 4, 0])).toBe(0);
});
it('returns positive when a > b on major', () => {
expect(compareSemver([3, 0, 0], [2, 99, 99])).toBeGreaterThan(0);
});
it('returns negative when a < b on minor', () => {
expect(compareSemver([2, 3, 9], [2, 4, 0])).toBeLessThan(0);
});
it('returns negative when a < b on patch', () => {
expect(compareSemver([2, 4, 0], [2, 4, 1])).toBeLessThan(0);
});
});
@@ -0,0 +1,23 @@
import { parseSemver } from '@/cli/utilities/version/parse-semver';
describe('parseSemver', () => {
it('parses a plain semver', () => {
expect(parseSemver('1.2.3')).toEqual([1, 2, 3]);
});
it('strips a leading v', () => {
expect(parseSemver('v2.4.0')).toEqual([2, 4, 0]);
});
it('returns null for "latest"', () => {
expect(parseSemver('latest')).toBeNull();
});
it('returns null for a release-candidate tag', () => {
expect(parseSemver('v2.4.0-rc')).toBeNull();
});
it('returns null for empty input', () => {
expect(parseSemver('')).toBeNull();
});
});
@@ -0,0 +1,30 @@
import chalk from 'chalk';
import { CONTAINER_NAME } from '@/cli/utilities/server/docker-container';
import { getVersionInfo } from '@/cli/utilities/version/get-version-info';
const STALE_THRESHOLD_DAYS = 7;
export const checkServerVersionCompatibility = async (
containerName: string = CONTAINER_NAME,
): Promise<void> => {
const info = await getVersionInfo(containerName);
if (
info.localServerVersion === null ||
info.latestServerVersion === null ||
!info.isMinorOrMajorBehind ||
info.daysBehind === null ||
info.daysBehind <= STALE_THRESHOLD_DAYS
) {
return;
}
console.warn(
chalk.yellow(
`⚠ Local Twenty server is v${info.localServerVersion} (${info.daysBehind} days behind v${info.latestServerVersion}).`,
),
);
console.warn(chalk.dim(' Update with: yarn twenty server upgrade'));
console.warn('');
};
@@ -0,0 +1,8 @@
import { type SemverTuple } from '@/cli/utilities/version/semver-tuple';
export const compareSemver = (a: SemverTuple, b: SemverTuple): number => {
if (a[0] !== b[0]) return a[0] - b[0];
if (a[1] !== b[1]) return a[1] - b[1];
return a[2] - b[2];
};
@@ -0,0 +1,33 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { CONTAINER_NAME } from '@/cli/utilities/server/docker-container';
const execFileAsync = promisify(execFile);
export const getLocalServerVersion = async (
containerName: string = CONTAINER_NAME,
): Promise<string | null> => {
try {
const { stdout } = await execFileAsync(
'docker',
[
'inspect',
'-f',
'{{range .Config.Env}}{{println .}}{{end}}',
containerName,
],
{ encoding: 'utf-8' },
);
const match = stdout.match(/^APP_VERSION=(.+)$/m);
if (!match || match[1] === '0.0.0' || match[1] === '') {
return null;
}
return match[1].trim().replace(/^v/, '');
} catch {
return null;
}
};
@@ -0,0 +1,154 @@
import { readFile, writeFile } from 'node:fs/promises';
import * as os from 'os';
import * as path from 'path';
import { ensureDir } from '@/cli/utilities/file/fs-utils';
import { compareSemver } from '@/cli/utilities/version/compare-semver';
import { parseSemver } from '@/cli/utilities/version/parse-semver';
import { type PublishedServerVersion } from '@/cli/utilities/version/published-server-version';
const CACHE_FILE = path.join(
os.homedir(),
'.twenty',
'version-check-cache.json',
);
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const FETCH_TIMEOUT_MS = 3000;
const DOCKER_HUB_TAGS_URL =
'https://hub.docker.com/v2/repositories/twentycrm/twenty-app-dev/tags?page_size=100';
type CacheFile = {
fetchedAt: number;
versions: Array<{ name: string; lastUpdatedAt: string }>;
};
const readCache = async (): Promise<PublishedServerVersion[] | null> => {
try {
const content = await readFile(CACHE_FILE, 'utf-8');
const parsed = JSON.parse(content) as CacheFile;
if (
typeof parsed.fetchedAt !== 'number' ||
!Array.isArray(parsed.versions)
) {
return null;
}
if (Date.now() - parsed.fetchedAt > CACHE_TTL_MS) {
return null;
}
return parsed.versions.map((entry) => ({
name: entry.name,
lastUpdatedAt: new Date(entry.lastUpdatedAt),
}));
} catch {
return null;
}
};
const writeCache = async (
versions: PublishedServerVersion[],
): Promise<void> => {
try {
await ensureDir(path.dirname(CACHE_FILE));
await writeFile(
CACHE_FILE,
JSON.stringify({
fetchedAt: Date.now(),
versions: versions.map(({ name, lastUpdatedAt }) => ({
name,
lastUpdatedAt: lastUpdatedAt.toISOString(),
})),
} satisfies CacheFile),
);
} catch {
// Cache write failures shouldn't break the CLI
}
};
const fetchFromDockerHub = async (): Promise<
PublishedServerVersion[] | null
> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(DOCKER_HUB_TAGS_URL, {
signal: controller.signal,
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as {
results?: Array<{ name?: unknown; last_updated?: unknown }>;
};
if (!Array.isArray(body.results)) {
return null;
}
const versions: PublishedServerVersion[] = [];
for (const tag of body.results) {
if (
typeof tag.name !== 'string' ||
typeof tag.last_updated !== 'string' ||
tag.name === 'latest' ||
parseSemver(tag.name) === null
) {
continue;
}
const lastUpdatedAt = new Date(tag.last_updated);
if (Number.isNaN(lastUpdatedAt.getTime())) {
continue;
}
versions.push({
name: tag.name.replace(/^v/, ''),
lastUpdatedAt,
});
}
versions.sort((a, b) => {
const aParsed = parseSemver(a.name);
const bParsed = parseSemver(b.name);
if (aParsed === null || bParsed === null) {
return 0;
}
return compareSemver(bParsed, aParsed);
});
return versions;
} catch {
return null;
} finally {
clearTimeout(timeoutId);
}
};
export const getPublishedServerVersions = async (): Promise<
PublishedServerVersion[]
> => {
const cached = await readCache();
if (cached !== null) {
return cached;
}
const fresh = await fetchFromDockerHub();
if (fresh === null) {
return [];
}
await writeCache(fresh);
return fresh;
};
@@ -0,0 +1,70 @@
import { CONTAINER_NAME } from '@/cli/utilities/server/docker-container';
import { compareSemver } from '@/cli/utilities/version/compare-semver';
import { getLocalServerVersion } from '@/cli/utilities/version/get-local-server-version';
import { getPublishedServerVersions } from '@/cli/utilities/version/get-published-server-versions';
import { parseSemver } from '@/cli/utilities/version/parse-semver';
import { type VersionInfo } from '@/cli/utilities/version/version-info';
import sdkPackageJson from '../../../../package.json';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
// Fallback for versions older than the most recent ~100 Docker Hub tags:
// they can't be matched by name, so we report a sentinel age that always
// exceeds any reasonable staleness threshold.
const UNKNOWN_AGE_DAYS_BEHIND = 365;
export const getVersionInfo = async (
containerName: string = CONTAINER_NAME,
): Promise<VersionInfo> => {
const cliVersion = sdkPackageJson.version;
const [localServerVersion, publishedVersions] = await Promise.all([
getLocalServerVersion(containerName),
getPublishedServerVersions(),
]);
const latestServerVersion = publishedVersions[0]?.name ?? null;
const localParsed = localServerVersion
? parseSemver(localServerVersion)
: null;
const latestParsed = latestServerVersion
? parseSemver(latestServerVersion)
: null;
const isMinorOrMajorBehind =
localParsed !== null &&
latestParsed !== null &&
(localParsed[0] < latestParsed[0] ||
(localParsed[0] === latestParsed[0] && localParsed[1] < latestParsed[1]));
const localPublishedAt =
localParsed === null
? undefined
: publishedVersions.find((entry) => {
const entryParsed = parseSemver(entry.name);
return (
entryParsed !== null &&
compareSemver(entryParsed, localParsed) === 0
);
})?.lastUpdatedAt;
const latestPublishedAt = publishedVersions[0]?.lastUpdatedAt;
let daysBehind: number | null = null;
if (latestPublishedAt && localPublishedAt) {
daysBehind = Math.floor(
(latestPublishedAt.getTime() - localPublishedAt.getTime()) / MS_PER_DAY,
);
} else if (latestPublishedAt && isMinorOrMajorBehind) {
daysBehind = UNKNOWN_AGE_DAYS_BEHIND;
}
return {
cliVersion,
localServerVersion,
latestServerVersion,
isMinorOrMajorBehind,
daysBehind,
};
};
@@ -0,0 +1,16 @@
import { type SemverTuple } from '@/cli/utilities/version/semver-tuple';
export const parseSemver = (raw: string): SemverTuple | null => {
const trimmed = raw.replace(/^v/, '').trim();
const match = trimmed.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
return null;
}
return [
parseInt(match[1], 10),
parseInt(match[2], 10),
parseInt(match[3], 10),
];
};
@@ -0,0 +1,4 @@
export type PublishedServerVersion = {
name: string;
lastUpdatedAt: Date;
};
@@ -0,0 +1 @@
export type SemverTuple = [number, number, number];
@@ -0,0 +1,7 @@
export type VersionInfo = {
cliVersion: string;
localServerVersion: string | null;
latestServerVersion: string | null;
isMinorOrMajorBehind: boolean;
daysBehind: number | null;
};