From cc9c8b870b3b5d0522de660aa83becb576ef4f05 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Wed, 15 Apr 2026 01:47:45 -0400 Subject: [PATCH 1/4] fix(ci): emit TypeScript declarations before npm publish (#10768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Published tarballs of several @hcengineering/* packages (0.7.411+) are missing their .d.ts files, despite every package.json declaring "types": "types/index.d.ts" and listing "types/**/*" in "files". Repro: npm pack @hcengineering/core@0.7.413 --dry-run --json (no `types/` entries in the file list) Root cause ---------- `publish-npm.yml` runs `rush build`, which is defined in `common/config/rush/command-line.json` as a phased command with `"phases": ["_phase:build"]`. `_phase:build` resolves to `compile transpile src` (an esbuild-only transpile → `lib/`). The `.d.ts` emit lives in a separate phase, `_phase:validate` (`compile validate` → `tsc --emitDeclarationOnly` → `declarationDir: ./types`), which the publish workflow never runs. Because `types/` does not exist when safe-publish.js invokes `npm publish`, the `types/**/*` glob in each package's `files` field matches nothing and the tarball ships without declarations. Why this didn't regress earlier ------------------------------- These packages used to live in the now-dormant `hcengineering/huly.core` repo. That repo's `ci.yml` explicitly ran `rush validate` before `rush publish`, so `types/` was always present at publish time. After migration into this monorepo, the publish pipeline was rewritten (PR #10542, then extracted to `publish-npm.yml` in PR #10580) and the validate step was not carried over. Versions 0.7.18–0.7.382 still shipped declarations because: - 0.7.18–0.7.26 were published from `huly.core` (validate present). - 0.7.382 was published manually from a developer machine (`_nodeVersion: 22.13.0, _npmVersion: 11.0.0` in npm metadata — doesn't match the CI runner). `types/` happened to be left on disk from prior local development. - 0.7.411+ are the first versions published via `publish-npm.yml` on a fresh runner (`_nodeVersion: 22.22.2, _npmVersion: 10.9.7`, consistent with actions/setup-node@v6 resolving `.nvmrc: v22`). Fresh workspace, no validate step, no `types/`. Fix --- Add a `rush validate` step between build and publish in `publish-npm.yml`. Scoped to the publish workflow so regular CI build times are unaffected. An alternative would be to add `_phase:validate` to the `build` phased command in `command-line.json` (matching `build:watch`, which already does `["_phase:build", "_phase:validate"]`), but that would make any validate failure block all builds, not just publishes — strictly worse blast radius for this particular bug. Affected packages observed on npm (0.7.411+): @hcengineering/core @hcengineering/account-client @hcengineering/api-client @hcengineering/text @hcengineering/text-core @hcengineering/text-html Fixes #10767 --- .github/workflows/publish-npm.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index f3b9d350fa..617a7671c2 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -34,6 +34,8 @@ jobs: run: node common/scripts/install-run-rush.js install - name: Building... run: node common/scripts/install-run-rush.js build + - name: Emit TypeScript declarations + run: node common/scripts/install-run-rush.js validate - name: Publish to npm env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} From 7bb98df7795faa64911b5d9b3c4d8906cf8c1ec3 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Wed, 15 Apr 2026 14:33:37 +0700 Subject: [PATCH 2/4] Fix translation errors in copy table action (#10769) * Fix translation errors in md table Signed-off-by: Artem Savchenko * Add tests Signed-off-by: Artem Savchenko * Add warning Signed-off-by: Artem Savchenko --------- Signed-off-by: Artem Savchenko --- .../src/__tests__/formatter.utils.test.ts | 34 +++++++++++++++++++ .../src/formatter/utils.ts | 26 +++++++++++--- .../src/formatter/valueFormatter.ts | 12 +++++-- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/plugins/converter-resources/src/__tests__/formatter.utils.test.ts b/plugins/converter-resources/src/__tests__/formatter.utils.test.ts index a8729ef016..411ec0313a 100644 --- a/plugins/converter-resources/src/__tests__/formatter.utils.test.ts +++ b/plugins/converter-resources/src/__tests__/formatter.utils.test.ts @@ -50,6 +50,40 @@ describe('formatter/utils', () => { it('returns false for non-string', () => { expect(isIntlString(null as any)).toBe(false) expect(isIntlString(undefined as any)).toBe(false) + expect(isIntlString(42 as any)).toBe(false) + expect(isIntlString({} as any)).toBe(false) + expect(isIntlString([] as any)).toBe(false) + }) + + it('returns false when value looks like a URL (contains ://)', () => { + expect(isIntlString('https://example.com/path')).toBe(false) + expect(isIntlString('http://localhost:8080')).toBe(false) + expect(isIntlString('card:string:https://oops')).toBe(false) + }) + + it('returns true for embedded label prefix when non-empty after prefix', () => { + expect(isIntlString('embedded:embedded:Hello')).toBe(true) + expect(isIntlString('embedded:embedded:x')).toBe(true) + }) + + it('returns false for embedded label prefix only', () => { + expect(isIntlString('embedded:embedded:')).toBe(false) + }) + + it('returns false when plugin looks like http or https scheme', () => { + expect(isIntlString('http:string:Something')).toBe(false) + expect(isIntlString('https:string:Something')).toBe(false) + expect(isIntlString('HTTP:string:Something')).toBe(false) + }) + + it('returns false when plugin or resource kind does not match id pattern', () => { + expect(isIntlString('Card:string:Card')).toBe(false) + expect(isIntlString('plugin:1kind:Key')).toBe(false) + expect(isIntlString('plugin:_kind:Key')).toBe(false) + }) + + it('returns true for hyphenated plugin and underscore in resource id', () => { + expect(isIntlString('my-plugin:string:My_Key')).toBe(true) }) }) diff --git a/plugins/converter-resources/src/formatter/utils.ts b/plugins/converter-resources/src/formatter/utils.ts index b0499f6fee..941a9d2217 100644 --- a/plugins/converter-resources/src/formatter/utils.ts +++ b/plugins/converter-resources/src/formatter/utils.ts @@ -31,15 +31,33 @@ export enum DateFormatOption { } /** - * Check if a value is an IntlString (format: "plugin:resource:key"). - * Type guard: narrows unknown to string when true. + * Check if a value is an IntlString id ({@link Id}: {@code plugin:resourceKind:key}) or + * {@link getEmbeddedLabel} output ({@code embedded:embedded:...}). + * */ export function isIntlString (value: unknown): value is string { if (typeof value !== 'string' || value.length === 0) { return false } - const parts = value.split(':') - return parts.length >= 3 && parts.every((part) => part.length > 0) + if (value.includes('://')) { + return false + } + if (value.startsWith('embedded:embedded:')) { + return value.length > 'embedded:embedded:'.length + } + const m = /^([a-z][a-z0-9-]*):([a-zA-Z][a-zA-Z0-9_]*):(.+)$/.exec(value) + if (m === null) { + return false + } + const plugin = m[1] + const rest = m[3] + if (rest.length === 0) { + return false + } + if (/^https?$/i.test(plugin)) { + return false + } + return true } /** diff --git a/plugins/converter-resources/src/formatter/valueFormatter.ts b/plugins/converter-resources/src/formatter/valueFormatter.ts index b4aa3c11bb..cf7397f81f 100644 --- a/plugins/converter-resources/src/formatter/valueFormatter.ts +++ b/plugins/converter-resources/src/formatter/valueFormatter.ts @@ -245,7 +245,11 @@ export async function formatCustomAttributeValue ( if (typeof value === 'string') { if (isIntlString(value)) { - return await translate(value as unknown as IntlString, {}, language) + try { + return await translate(value as unknown as IntlString, {}, language) + } catch { + console.warn('Failed to translate intl string', value) + } } const isRef = attrType?._class === core.class.RefTo @@ -341,7 +345,11 @@ async function formatValueFallback ( } if (isIntlString(value)) { - return await translate(value as unknown as IntlString, {}, language) + try { + return await translate(value as unknown as IntlString, {}, language) + } catch { + console.warn('Failed to translate intl string', value) + } } if (attr.key === DocumentAttributeKey.CreatedBy || attr.key === DocumentAttributeKey.ModifiedBy) { return await loadPersonName(value as PersonId, hierarchy, userCache) From 869bec96dcaeb4c02b968f2a6e5462a00cc72900 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Wed, 15 Apr 2026 14:33:55 +0700 Subject: [PATCH 3/4] Add tool to create missing SocialIdentity (#10758) Signed-off-by: Artem Savchenko --- dev/tool/src/contact.test.ts | 208 +++++++++++++++++++++++++++++++++++ dev/tool/src/contact.ts | 131 ++++++++++++++++++++++ dev/tool/src/index.ts | 41 ++++++- 3 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 dev/tool/src/contact.test.ts create mode 100644 dev/tool/src/contact.ts diff --git a/dev/tool/src/contact.test.ts b/dev/tool/src/contact.test.ts new file mode 100644 index 0000000000..9957ddf842 --- /dev/null +++ b/dev/tool/src/contact.test.ts @@ -0,0 +1,208 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import contact, { type Person } from '@hcengineering/contact' +import { + type Doc, + MeasureMetricsContext, + SocialIdType, + type Space, + type PersonId, + type PersonInfo, + type PersonUuid, + type Ref, + type TxOperations +} from '@hcengineering/core' +import { ensureMissingSocialIdentities } from './contact' + +function personFixture (overrides: Partial = {}): Person { + return { + _id: 'contact:person:p1' as Ref, + _class: contact.class.Person, + space: contact.space.Contacts, + name: 'Person One', + modifiedOn: 0, + modifiedBy: 'core:account:System' as PersonId, + personUuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' as PersonUuid, + ...overrides + } as any +} + +function createMockOps (config: { + persons: Person[] + findOne?: jest.Mock + addCollection?: jest.Mock + employeePersonUuid?: PersonUuid +}): { ops: TxOperations, addCollection: jest.Mock, findOne: jest.Mock } { + const findOne = + config.findOne ?? + jest.fn(async () => { + return null + }) + const addCollection = config.addCollection ?? jest.fn(async () => 'new-id' as Ref) + const hierarchy = { + as: (_person: Person, mixin: Ref>) => { + if (mixin === contact.mixin.Employee) { + return config.employeePersonUuid != null ? { personUuid: config.employeePersonUuid } : {} + } + return undefined + } + } + const ops = { + findAll: jest.fn(async () => config.persons), + getHierarchy: () => hierarchy, + findOne, + addCollection + } as unknown as TxOperations + return { ops, addCollection, findOne } +} + +describe('ensureMissingSocialIdentities', () => { + const toolCtx = new MeasureMetricsContext('test', {}) + + it('counts persons without personUuid as skipped', async () => { + const person = personFixture({ personUuid: undefined }) + const { ops } = createMockOps({ persons: [person] }) + const accountClient = { getPersonInfo: jest.fn() } + + const result = await ensureMissingSocialIdentities(toolCtx, ops, accountClient, false) + + expect(result.skippedPersons).toBe(1) + expect(result.created).toBe(0) + expect(accountClient.getPersonInfo).not.toHaveBeenCalled() + }) + + it('dry-run does not call addCollection and counts wouldCreate', async () => { + const person = personFixture() + const { ops, addCollection } = createMockOps({ persons: [person] }) + const socialId = 'social-id-1' as PersonId + const accountClient = { + getPersonInfo: jest.fn( + async (): Promise => ({ + name: 'n', + socialIds: [ + { + _id: socialId, + type: SocialIdType.EMAIL, + value: 'u@v.com', + key: 'email:u@v.com' + } + ] + }) + ) + } + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}) + + const result = await ensureMissingSocialIdentities(toolCtx, ops, accountClient, true) + + expect(result.wouldCreate).toBe(1) + expect(result.created).toBe(0) + expect(addCollection).not.toHaveBeenCalled() + expect(logSpy).toHaveBeenCalledWith('[dry-run] missing SocialIdentity', expect.stringContaining('social-id-1')) + logSpy.mockRestore() + }) + + it('creates SocialIdentity with isDeleted false when not dry-run', async () => { + const person = personFixture() + const { ops, addCollection } = createMockOps({ persons: [person] }) + const socialId = 'social-id-2' as PersonId + const accountClient = { + getPersonInfo: jest.fn( + async (): Promise => ({ + name: 'n', + socialIds: [ + { + _id: socialId, + type: SocialIdType.EMAIL, + value: 'a@b.com', + key: 'email:a@b.com', + verifiedOn: 1 + } + ] + }) + ) + } + + const result = await ensureMissingSocialIdentities(toolCtx, ops, accountClient, false) + + expect(result.created).toBe(1) + expect(addCollection).toHaveBeenCalledTimes(1) + expect(addCollection).toHaveBeenCalledWith( + contact.class.SocialIdentity, + contact.space.Contacts, + person._id, + contact.class.Person, + 'socialIds', + expect.objectContaining({ + type: SocialIdType.EMAIL, + value: 'a@b.com', + key: 'email:a@b.com', + isDeleted: false, + verifiedOn: 1 + }), + socialId + ) + }) + + it('skips creation when SocialIdentity already exists by _id', async () => { + const person = personFixture() + const socialId = 'social-id-3' as PersonId + const findOne = jest.fn(async (_cls: unknown, query: { _id?: PersonId }) => { + if (query._id === socialId) { + return { _id: socialId } + } + return null + }) + const { ops, addCollection } = createMockOps({ persons: [person], findOne }) + const accountClient = { + getPersonInfo: jest.fn( + async (): Promise => ({ + name: 'n', + socialIds: [ + { + _id: socialId, + type: SocialIdType.EMAIL, + value: 'x@y.com', + key: 'email:x@y.com' + } + ] + }) + ) + } + + const result = await ensureMissingSocialIdentities(toolCtx, ops, accountClient, false) + + expect(result.created).toBe(0) + expect(addCollection).not.toHaveBeenCalled() + }) + + it('uses employee.personUuid when present', async () => { + const empUuid = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' as PersonUuid + const person = personFixture({ personUuid: undefined }) + const { ops } = createMockOps({ persons: [person], employeePersonUuid: empUuid }) + const accountClient = { + getPersonInfo: jest.fn( + async (): Promise => ({ + name: 'n', + socialIds: [] + }) + ) + } + + await ensureMissingSocialIdentities(toolCtx, ops, accountClient, false) + + expect(accountClient.getPersonInfo).toHaveBeenCalledWith(empUuid) + }) +}) diff --git a/dev/tool/src/contact.ts b/dev/tool/src/contact.ts new file mode 100644 index 0000000000..c9f7865bd2 --- /dev/null +++ b/dev/tool/src/contact.ts @@ -0,0 +1,131 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { AccountClient } from '@hcengineering/account-client' +import contact, { type SocialIdentityRef } from '@hcengineering/contact' +import { + buildSocialIdString, + type MeasureMetricsContext, + type PersonInfo, + type TxOperations +} from '@hcengineering/core' + +export interface EnsureMissingSocialIdentitiesResult { + skippedPersons: number + wouldCreate: number + created: number +} + +/** + * For each workspace person linked to an account, ensures SocialIdentity docs exist + * for every active account social id (same _id as in the account DB). See + * {@link createSocialIdentities} in model-contact migration. + */ +export async function ensureMissingSocialIdentities ( + toolCtx: MeasureMetricsContext, + ops: TxOperations, + accountClient: Pick, + dryRun: boolean +): Promise { + let wouldCreate = 0 + let created = 0 + let skippedPersons = 0 + + const persons = await ops.findAll(contact.class.Person, {}) + for (const person of persons) { + const employee = ops.getHierarchy().as(person, contact.mixin.Employee) + const personUuid = employee?.personUuid ?? person.personUuid + if (personUuid == null) { + skippedPersons++ + continue + } + let personInfo: PersonInfo + try { + personInfo = await accountClient.getPersonInfo(personUuid) + } catch (err: any) { + toolCtx.error('ensure-missing-social-identities: getPersonInfo failed', { + person: person._id, + personUuid, + message: err?.message ?? String(err) + }) + continue + } + const socials = (personInfo.socialIds ?? []).filter((s) => s.isDeleted !== true) + for (const social of socials) { + const socialDocId = social._id as SocialIdentityRef + const expectedKey = buildSocialIdString({ type: social.type, value: social.value }) + if (social.key !== expectedKey) { + toolCtx.warn('ensure-missing-social-identities: social key does not match type:value', { + person: person._id, + personUuid, + socialId: social._id, + key: social.key, + expectedKey + }) + } + const existingById = await ops.findOne(contact.class.SocialIdentity, { _id: socialDocId }) + if (existingById != null) { + continue + } + const existingByKey = await ops.findOne(contact.class.SocialIdentity, { + attachedTo: person._id, + key: social.key + }) + if (existingByKey != null) { + toolCtx.warn('ensure-missing-social-identities: SocialIdentity exists for key but different _id', { + person: person._id, + personUuid, + accountSocialId: social._id, + existingId: existingByKey._id, + key: social.key + }) + continue + } + if (dryRun) { + wouldCreate++ + console.log( + '[dry-run] missing SocialIdentity', + JSON.stringify({ + person: person._id, + personUuid, + socialId: social._id, + key: social.key, + type: social.type + }) + ) + } else { + await ops.addCollection( + contact.class.SocialIdentity, + contact.space.Contacts, + person._id, + contact.class.Person, + 'socialIds', + { + type: social.type, + value: social.value, + key: social.key, + isDeleted: false, + ...(social.verifiedOn != null ? { verifiedOn: social.verifiedOn } : {}), + ...(social.displayValue != null ? { displayValue: social.displayValue } : {}) + }, + socialDocId + ) + created++ + } + } + } + + return { skippedPersons, wouldCreate, created } +} diff --git a/dev/tool/src/index.ts b/dev/tool/src/index.ts index 56acb52fea..e748e621ba 100644 --- a/dev/tool/src/index.ts +++ b/dev/tool/src/index.ts @@ -73,7 +73,7 @@ import { updateField } from './workspace' import { RatingCalculator, ratingEvents, type QueueRatingMessage } from '@hcengineering/pod-rating' -import { +import core, { AccountRole, isArchivingMode, isDeletingMode, @@ -82,6 +82,7 @@ import { SocialIdType, systemAccountEmail, systemAccountUuid, + TxOperations, type AccountUuid, type Data, type Doc, @@ -125,13 +126,14 @@ import { restoreFromv6All, restoreTrustedV6Workspace } from './db' +import { ensureMissingSocialIdentities } from './contact' import { performGithubAccountMigrations } from './github' import { performGmailAccountMigrations } from './gmail' import { getToolToken, getWorkspace, getWorkspaceTransactorEndpoint } from './utils' import { createRestClient } from '@hcengineering/api-client' import { type CardID } from '@hcengineering/communication-types' -import { sendTransactorEvent } from '@hcengineering/server-tool' +import { connect, sendTransactorEvent } from '@hcengineering/server-tool' import { existsSync } from 'fs' import { mkdir, writeFile } from 'fs/promises' import { dirname } from 'path' @@ -2978,6 +2980,41 @@ export function devTool ( }) }) + program + .command('ensure-missing-social-identities ') + .description( + 'Create contact.class.SocialIdentity in the workspace for account social ids missing on the person (see contact migration createSocialIdentities)' + ) + .option('-d, --dry-run', 'Only log persons/social ids that would be created', false) + .action(async (workspace: string, cmd: { dryRun: boolean }) => { + await withAccountDatabase(async (db) => { + const info = await getWorkspace(db, workspace) + if (info === null) { + throw new Error(`Workspace ${workspace} not found`) + } + const wsUuid = info.uuid + const endpoint = await getWorkspaceTransactorEndpoint(wsUuid) + const accountClient = getAccountClient(getToolToken(wsUuid)) + const connection = await connect(endpoint, wsUuid, undefined, { model: 'upgrade' }) + const ops = new TxOperations(connection, core.account.ConfigUser) + try { + const { skippedPersons, wouldCreate, created } = await ensureMissingSocialIdentities( + toolCtx, + ops, + accountClient, + cmd.dryRun + ) + console.log( + cmd.dryRun + ? `ensure-missing-social-identities dry-run: persons without personUuid skipped=${skippedPersons}, social identities that would be created=${wouldCreate}` + : `ensure-missing-social-identities: persons without personUuid skipped=${skippedPersons}, SocialIdentity docs created=${created}` + ) + } finally { + await connection.close() + } + }) + }) + extendProgram?.(program) process.on('unhandledRejection', (reason, promise) => { From 44e43662550e3aa6ad38c8cd8a863146240551ae Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Thu, 16 Apr 2026 14:23:17 +0700 Subject: [PATCH 4/4] Set 1.0 product version during export (#10771) Signed-off-by: Artem Savchenko --- plugins/export-resources/src/export.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/export-resources/src/export.ts b/plugins/export-resources/src/export.ts index 60a9fff065..69333a989b 100644 --- a/plugins/export-resources/src/export.ts +++ b/plugins/export-resources/src/export.ts @@ -60,6 +60,11 @@ export async function exportToWorkspace ( 'documents:class:DocumentMeta': { author: '$currentUser', owner: '$currentUser' + }, + 'products:class:ProductVersion': { + major: 1, + minor: 0, + patch: 0 } }