Add 'foundations/core/' from commit '4f31d1b32637d2f124f555531ee12be8af3fd4fc'

git-subtree-dir: foundations/core
git-subtree-mainline: 16b1109180
git-subtree-split: 4f31d1b326
This commit is contained in:
Denis Bykhov
2025-11-26 22:56:44 +05:00
493 changed files with 71999 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# Don't allow people to merge changes to these generated files, because the result
# may be invalid. You need to run "rush update" again.
pnpm-lock.yaml merge=text
shrinkwrap.yaml merge=binary
npm-shrinkwrap.json merge=binary
yarn.lock merge=binary
# Rush's JSON config files use JavaScript-style code comments. The rule below prevents pedantic
# syntax highlighters such as GitHub's from highlighting these comments as errors. Your text editor
# may also require a special configuration to allow comments in JSON.
#
# For more information, see this issue: https://github.com/microsoft/rushstack/issues/1088
#
*.json linguist-language=JSON-with-Comments
+69
View File
@@ -0,0 +1,69 @@
# GitHub Copilot Instructions - Huly Core
**Type**: Rush monorepo (TypeScript)
**License**: EPL-2.0
**Build System**: Rush v5.158.1 + pnpm v10.15.1
## Critical Rules
1. **Always use Rush commands**, never npm/pnpm directly: `rush install`, `rush build`, `rush test`, `rush update`
2. **Internal dependencies must use `workspace:^` protocol** in package.json
3. **All packages extend `@hcengineering/platform-rig`** for tsconfig/eslint - don't override configs
4. **Co-locate tests** in `src/__tests__/` directories (Jest + ts-jest)
5. **Named exports only** - avoid default exports
## Quick Start
```bash
rush install && rush build # Initial setup
rush build:watch # Development mode
rush rebuild # Force clean rebuild (clears cache)
rush test # Run all tests
```
## Package Structure (All packages follow this)
```
packages/<name>/
├── src/index.ts # Main entry (exports)
├── lib/ # Compiled JS (gitignored)
├── types/ # TS declarations (gitignored)
├── package.json # Scope: @hcengineering/, main: lib/index.js
├── tsconfig.json # Extends platform-rig
└── jest.config.js # preset: 'ts-jest', roots: ['./src']
```
## Key Architecture Patterns
**Plugin System**: `@hcengineering/platform` provides dependency injection. Packages register resources/services via plugin manifests. Example: `packages/core/src/plugin.ts`
**Data Flow**: `core` → abstract models (Doc, Ref, Class) → `client` → concrete implementations → WebSocket/REST via `api-client`
**Text Processing**: Modular `text-*` packages (core/html/markdown/ydoc) support extensible rich-text editing with Yjs collaboration
**Storage Abstraction**: `storage` package defines interfaces; `storage-client` provides implementations; backends are pluggable
## Common Tasks
**Add a package**: Create under `packages/`, add standard files (see structure above), register in `rush.json` if needed, run `rush update`
**Debug build failures**: Check `<package>/rush-logs/` and `.build/build.tsbuildinfo`; use `rush rebuild` to clear incremental state
**Run single package tests**: `cd packages/<name> && rushx test`
**Update dependencies**: Edit package.json, run `rush update`, verify with `rush build`
## Where to Look
- **Rush config**: `common/config/rush/`, `rush.json`
- **Shared scripts**: `common/scripts/` (coverage merging, install helpers)
- **Core abstractions**: `packages/core/src/` (Doc, Hierarchy, TxOperations)
- **Platform runtime**: `packages/platform/src/` (plugin loader, resources)
- **Client layer**: `packages/client/src/` (LiveQuery, TxOperations client wrapper)
## Watch Out For
- Rush uses **incremental builds** - cached artifacts in `.build/` and `common/temp/`
- **TypeScript strict mode** enabled - type safety enforced
- **platform-rig** centralizes tooling configs (ESLint, Prettier, tsconfig)
- Check `common/temp/rush-recycler/` for moved files during dependency updates
+46
View File
@@ -0,0 +1,46 @@
name: CI
on:
push:
branches: ['main']
tags:
- 'v0.7.*'
- 's0.7.*'
pull_request:
branches: ['main']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 2
- uses: actions/setup-node@v3
with:
node-version: 22
- name: Verify Change Logs
run: node common/scripts/install-run-rush.js change --verify
- name: Rush Install
run: node common/scripts/install-run-rush.js install
- name: Rush check
run: node common/scripts/install-run-rush.js check --verbose
- name: Rush validate
run: node common/scripts/install-run-rush.js validate --verbose
- name: Rush test
run: node common/scripts/install-run-rush.js test --verbose
- name: Formatting...
run: node common/scripts/install-run-rush.js format --force
- name: Check files formatting
run: |
echo '================================================================'
echo 'Checking for diff files'
echo '================================================================'
git diff '*.js' '*.ts' '*.svelte' '*.json' '*.yaml' | cat
[ -z "$(git diff --name-only '*.js' '*.ts' '*.svelte' '*.json' '*.yaml' | cat)" ]
echo '================================================================'
- name: Publish packages
if: startsWith(github.ref, 'refs/tags/v0.7.') || startsWith(github.ref, 'refs/tags/s0.7.')
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node common/scripts/install-run-rush.js publish --include-all --publish
+115
View File
@@ -0,0 +1,115 @@
.heft/
lib/
_api-extractor-temp/
temp/
.idea
pods/workspace/init/
pods/workspace/init-scripts/
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*./rush-logs
*tests/sanity/screenshots
# Runtime data
*.pid
*.seed
*.pid.lock
# VS Code settings
.vscode/settings.json
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
# .env
# next.js build output
.next
# OS X temporary files
.DS_Store
# Rush temporary files
common/deploy/
common/temp/
common/autoinstallers/*/.npmrc
**/.rush/temp/
bundle.js
bundle/*.js
dist
.build
typings
types
.validate
tsconfig.tsbuildinfo
ingest-attachment-*.zip
tsdoc-metadata.json
pods/front/dist
*.cpuprofile
*.pyc
metrics.txt
dev/tool/report*.csv
tests/db_dump
.build
.format
tools/apm/apm.js
deploy
metrics.txt
services/github/pod-github/src/github.graphql
.build
.format
dev/tool/report.csv
bundle/*
bundle.js.map
tests/profiles
**/bundle/model.json
.wrangler
dump
**/logs/**
dev/tool/history.json
.aider*
/combined_dependencies
.tmp
ws-tests/docker-compose.override.yml
+1
View File
@@ -0,0 +1 @@
v22
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"trailingComma": "none",
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"printWidth": 120,
"useTabs": false,
"bracketSpacing": true,
"proseWrap": "preserve",
"plugins": [],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"svelte.svelte-vscode",
"esbenp.prettier-vscode",
"firsttris.vscode-jest-runner"
]
}
+277
View File
@@ -0,0 +1,277 @@
Eclipse Public License - v 2.0
THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
1. DEFINITIONS
"Contribution" means:
a) in the case of the initial Contributor, the initial content
Distributed under this Agreement, and
b) in the case of each subsequent Contributor:
i) changes to the Program, and
ii) additions to the Program;
where such changes and/or additions to the Program originate from
and are Distributed by that particular Contributor. A Contribution
"originates" from a Contributor if it was added to the Program by
such Contributor itself or anyone acting on such Contributor's behalf.
Contributions do not include changes or additions to the Program that
are not Modified Works.
"Contributor" means any person or entity that Distributes the Program.
"Licensed Patents" mean patent claims licensable by a Contributor which
are necessarily infringed by the use or sale of its Contribution alone
or when combined with the Program.
"Program" means the Contributions Distributed in accordance with this
Agreement.
"Recipient" means anyone who receives the Program under this Agreement
or any Secondary License (as applicable), including Contributors.
"Derivative Works" shall mean any work, whether in Source Code or other
form, that is based on (or derived from) the Program and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship.
"Modified Works" shall mean any work in Source Code or other form that
results from an addition to, deletion from, or modification of the
contents of the Program, including, for purposes of clarity any new file
in Source Code form that contains any contents of the Program. Modified
Works shall not include works that contain only declarations,
interfaces, types, classes, structures, or files of the Program solely
in each case in order to link to, bind by name, or subclass the Program
or Modified Works thereof.
"Distribute" means the acts of a) distributing or b) making available
in any manner that enables the transfer of a copy.
"Source Code" means the form of a Program preferred for making
modifications, including but not limited to software source code,
documentation source, and configuration files.
"Secondary License" means either the GNU General Public License,
Version 2.0, or any later versions of that license, including any
exceptions or additional permissions as identified by the initial
Contributor.
2. GRANT OF RIGHTS
a) Subject to the terms of this Agreement, each Contributor hereby
grants Recipient a non-exclusive, worldwide, royalty-free copyright
license to reproduce, prepare Derivative Works of, publicly display,
publicly perform, Distribute and sublicense the Contribution of such
Contributor, if any, and such Derivative Works.
b) Subject to the terms of this Agreement, each Contributor hereby
grants Recipient a non-exclusive, worldwide, royalty-free patent
license under Licensed Patents to make, use, sell, offer to sell,
import and otherwise transfer the Contribution of such Contributor,
if any, in Source Code or other form. This patent license shall
apply to the combination of the Contribution and the Program if, at
the time the Contribution is added by the Contributor, such addition
of the Contribution causes such combination to be covered by the
Licensed Patents. The patent license shall not apply to any other
combinations which include the Contribution. No hardware per se is
licensed hereunder.
c) Recipient understands that although each Contributor grants the
licenses to its Contributions set forth herein, no assurances are
provided by any Contributor that the Program does not infringe the
patent or other intellectual property rights of any other entity.
Each Contributor disclaims any liability to Recipient for claims
brought by any other entity based on infringement of intellectual
property rights or otherwise. As a condition to exercising the
rights and licenses granted hereunder, each Recipient hereby
assumes sole responsibility to secure any other intellectual
property rights needed, if any. For example, if a third party
patent license is required to allow Recipient to Distribute the
Program, it is Recipient's responsibility to acquire that license
before distributing the Program.
d) Each Contributor represents that to its knowledge it has
sufficient copyright rights in its Contribution, if any, to grant
the copyright license set forth in this Agreement.
e) Notwithstanding the terms of any Secondary License, no
Contributor makes additional grants to any Recipient (other than
those set forth in this Agreement) as a result of such Recipient's
receipt of the Program under the terms of a Secondary License
(if permitted under the terms of Section 3).
3. REQUIREMENTS
3.1 If a Contributor Distributes the Program in any form, then:
a) the Program must also be made available as Source Code, in
accordance with section 3.2, and the Contributor must accompany
the Program with a statement that the Source Code for the Program
is available under this Agreement, and informs Recipients how to
obtain it in a reasonable manner on or through a medium customarily
used for software exchange; and
b) the Contributor may Distribute the Program under a license
different than this Agreement, provided that such license:
i) effectively disclaims on behalf of all other Contributors all
warranties and conditions, express and implied, including
warranties or conditions of title and non-infringement, and
implied warranties or conditions of merchantability and fitness
for a particular purpose;
ii) effectively excludes on behalf of all other Contributors all
liability for damages, including direct, indirect, special,
incidental and consequential damages, such as lost profits;
iii) does not attempt to limit or alter the recipients' rights
in the Source Code under section 3.2; and
iv) requires any subsequent distribution of the Program by any
party to be under a license that satisfies the requirements
of this section 3.
3.2 When the Program is Distributed as Source Code:
a) it must be made available under this Agreement, or if the
Program (i) is combined with other material in a separate file or
files made available under a Secondary License, and (ii) the initial
Contributor attached to the Source Code the notice described in
Exhibit A of this Agreement, then the Program may be made available
under the terms of such Secondary Licenses, and
b) a copy of this Agreement must be included with each copy of
the Program.
3.3 Contributors may not remove or alter any copyright, patent,
trademark, attribution notices, disclaimers of warranty, or limitations
of liability ("notices") contained within the Program from any copy of
the Program which they Distribute, provided that Contributors may add
their own appropriate notices.
4. COMMERCIAL DISTRIBUTION
Commercial distributors of software may accept certain responsibilities
with respect to end users, business partners and the like. While this
license is intended to facilitate the commercial use of the Program,
the Contributor who includes the Program in a commercial product
offering should do so in a manner which does not create potential
liability for other Contributors. Therefore, if a Contributor includes
the Program in a commercial product offering, such Contributor
("Commercial Contributor") hereby agrees to defend and indemnify every
other Contributor ("Indemnified Contributor") against any losses,
damages and costs (collectively "Losses") arising from claims, lawsuits
and other legal actions brought by a third party against the Indemnified
Contributor to the extent caused by the acts or omissions of such
Commercial Contributor in connection with its distribution of the Program
in a commercial product offering. The obligations in this section do not
apply to any claims or Losses relating to any actual or alleged
intellectual property infringement. In order to qualify, an Indemnified
Contributor must: a) promptly notify the Commercial Contributor in
writing of such claim, and b) allow the Commercial Contributor to control,
and cooperate with the Commercial Contributor in, the defense and any
related settlement negotiations. The Indemnified Contributor may
participate in any such claim at its own expense.
For example, a Contributor might include the Program in a commercial
product offering, Product X. That Contributor is then a Commercial
Contributor. If that Commercial Contributor then makes performance
claims, or offers warranties related to Product X, those performance
claims and warranties are such Commercial Contributor's responsibility
alone. Under this section, the Commercial Contributor would have to
defend claims against the other Contributors related to those performance
claims and warranties, and if a court requires any other Contributor to
pay any damages as a result, the Commercial Contributor must pay
those damages.
5. NO WARRANTY
EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS"
BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR
IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF
TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR
PURPOSE. Each Recipient is solely responsible for determining the
appropriateness of using and distributing the Program and assumes all
risks associated with its exercise of rights under this Agreement,
including but not limited to the risks and costs of program errors,
compliance with applicable laws, damage to or loss of data, programs
or equipment, and unavailability or interruption of operations.
6. DISCLAIMER OF LIABILITY
EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS
SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST
PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE
EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
7. GENERAL
If any provision of this Agreement is invalid or unenforceable under
applicable law, it shall not affect the validity or enforceability of
the remainder of the terms of this Agreement, and without further
action by the parties hereto, such provision shall be reformed to the
minimum extent necessary to make such provision valid and enforceable.
If Recipient institutes patent litigation against any entity
(including a cross-claim or counterclaim in a lawsuit) alleging that the
Program itself (excluding combinations of the Program with other software
or hardware) infringes such Recipient's patent(s), then such Recipient's
rights granted under Section 2(b) shall terminate as of the date such
litigation is filed.
All Recipient's rights under this Agreement shall terminate if it
fails to comply with any of the material terms or conditions of this
Agreement and does not cure such failure in a reasonable period of
time after becoming aware of such noncompliance. If all Recipient's
rights under this Agreement terminate, Recipient agrees to cease use
and distribution of the Program as soon as reasonably practicable.
However, Recipient's obligations under this Agreement and any licenses
granted by Recipient relating to the Program shall continue and survive.
Everyone is permitted to copy and distribute copies of this Agreement,
but in order to avoid inconsistency the Agreement is copyrighted and
may only be modified in the following manner. The Agreement Steward
reserves the right to publish new versions (including revisions) of
this Agreement from time to time. No one other than the Agreement
Steward has the right to modify this Agreement. The Eclipse Foundation
is the initial Agreement Steward. The Eclipse Foundation may assign the
responsibility to serve as the Agreement Steward to a suitable separate
entity. Each new version of the Agreement will be given a distinguishing
version number. The Program (including Contributions) may always be
Distributed subject to the version of the Agreement under which it was
received. In addition, after a new version of the Agreement is published,
Contributor may elect to Distribute the Program (including its
Contributions) under the new version.
Except as expressly stated in Sections 2(a) and 2(b) above, Recipient
receives no rights or licenses to the intellectual property of any
Contributor under this Agreement, whether expressly, by implication,
estoppel or otherwise. All rights in the Program not expressly granted
under this Agreement are reserved. Nothing in this Agreement is intended
to be enforceable by any entity that is not a Contributor or Recipient.
No third-party beneficiary rights are created under this Agreement.
Exhibit A - Form of Secondary Licenses Notice
"This Source Code may also be made available under the following
Secondary Licenses when the conditions for such availability set forth
in the Eclipse Public License, v. 2.0 are satisfied: {name license(s),
version(s), and exceptions or additional permissions here}."
Simply including a copy of this Agreement, including this Exhibit A
is not sufficient to license the Source Code under Secondary Licenses.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to
look for such a notice.
You may add additional accurate notices of copyright ownership.
+167
View File
@@ -0,0 +1,167 @@
# Huly Core
[![GitHub License](https://img.shields.io/github/license/hcengineering/huly.core?style=for-the-badge)](LICENSE)
⭐️ Your star shines on us. Star us on GitHub!
## About
Huly Core is a collection of core packages extracted from the [Huly Platform](https://github.com/hcengineering/platform). This repository contains fundamental building blocks and libraries that power the Huly ecosystem, including core data models, client libraries, text processing engines, and platform utilities.
These packages are designed to be reusable, modular, and framework-agnostic, making them suitable for building custom applications on top of the Huly Platform or integrating Huly functionality into existing projects.
## Packages
This repository includes the following core packages:
### Core Packages
- **[@hcengineering/core](packages/core)** - Core data models, types, and fundamental platform abstractions
- **[@hcengineering/platform](packages/platform)** - Platform runtime, plugin system, and dependency injection
- **[@hcengineering/model](packages/model)** - Data model definitions and schema management
### Client Libraries
- **[@hcengineering/client](packages/client)** - Client-side data access and synchronization layer
- **[@hcengineering/client-resources](packages/client-resources)** - Shared client resources and utilities
- **[@hcengineering/api-client](packages/api-client)** - API client for programmatic access to Huly Platform (WebSocket and REST)
- **[@hcengineering/account-client](packages/account-client)** - Account management client
- **[@hcengineering/collaborator-client](packages/collaborator-client)** - Real-time collaboration client
- **[@hcengineering/hulylake-client](packages/hulylake-client)** - HulyLake data warehouse client
- **[@hcengineering/analytics](packages/analytics)** - Analytics and tracking
- **[@hcengineering/analytics-service](packages/analytics-service)** - Analytics service implementation
### Text Processing
- **[@hcengineering/text](packages/text)** - High-level text processing utilities
- **[@hcengineering/text-core](packages/text-core)** - Core text processing engine
- **[@hcengineering/text-html](packages/text-html)** - HTML text rendering and parsing
- **[@hcengineering/text-markdown](packages/text-markdown)** - Markdown support
- **[@hcengineering/text-ydoc](packages/text-ydoc)** - Yjs document integration for collaborative editing
### Utilities
- **[@hcengineering/query](packages/query)** - Query language and execution engine
- **[@hcengineering/storage](packages/storage)** - Storage abstractions and implementations
- **[@hcengineering/rank](packages/rank)** - Ranking and ordering utilities
- **[@hcengineering/retry](packages/retry)** - Retry logic and resilience patterns
- **[@hcengineering/rpc](packages/rpc)** - RPC communication layer
- **[@hcengineering/token](packages/token)** - Token management and authentication utilities
## Pre-requisites
Before proceeding, ensure that your system meets the following requirements:
- [Node.js](https://nodejs.org/en/download/) (v20.11.0 or higher is required)
- [Rush](https://rushjs.io/) - Microsoft's scalable monorepo manager
## Installation
You need Microsoft's [rush](https://rushjs.io/) to install the application.
1. Install Rush globally using the command:
```bash
npm install -g @microsoft/rush
```
1. Navigate to the repository root and run the following commands:
```bash
rush install
rush build
```
## Build
To build all packages:
```bash
rush build
```
To rebuild (ignoring cache):
```bash
rush rebuild
```
## Build & Watch
For development purposes, `rush build:watch` action could be used:
```bash
rush build:watch
```
It includes build and validate phases in watch mode.
## Update project structure
If the project's structure is updated, it may be necessary to relink and rebuild the projects:
```bash
rush update
rush build
```
## Troubleshooting
If a build fails, but the code is correct, try to delete the [build cache](https://rushjs.io/pages/maintainer/build_cache/) and retry:
```bash
rm -rf common/temp/build-cache
rush rebuild
```
## Tests
To execute all tests:
```bash
rush test
```
For individual test execution inside a package directory:
```bash
rushx test
```
## Package Publishing
To bump a package version:
```bash
node ./common/scripts/bump.js -p projectName
```
## API Client Usage
If you want to interact with Huly programmatically, check out the [API Client](packages/api-client/README.md) documentation. The API client provides a typed interface for all Huly operations and can be used to build integrations and custom applications.
You can find API usage examples in the [Huly examples](https://github.com/hcengineering/huly-examples) repository.
## Related Projects
- **[Huly Platform](https://github.com/hcengineering/platform)** - The main Huly Platform repository
- **[Huly Self-Host](https://github.com/hcengineering/huly-selfhost)** - Self-hosting solution for Huly
- **[Huly Examples](https://github.com/hcengineering/huly-examples)** - API usage examples
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
Licensed under the [EPL-2.0](LICENSE) license.
## Additional Links
- [Huly Website](https://huly.io/)
- [Documentation](https://docs.huly.io/)
- [Community](https://github.com/hcengineering/platform/discussions)
---
© 2025 [Hardcore Engineering Inc](https://hardcoreeng.com/).
@@ -0,0 +1,33 @@
# Rush uses this file to configure the NPM package registry during installation. It is applicable
# to PNPM, NPM, and Yarn package managers. It is used by operations such as "rush install",
# "rush update", and the "install-run.js" scripts.
#
# NOTE: The "rush publish" command uses .npmrc-publish instead.
#
# Before invoking the package manager, Rush will generate an .npmrc in the folder where installation
# is performed. This generated file will omit any config lines that reference environment variables
# that are undefined in that session; this avoids problems that would otherwise result due to
# a missing variable being replaced by an empty string.
#
# If "subspacesEnabled" is true in subspaces.json, the generated file will merge settings from
# "common/config/rush/.npmrc" and "common/config/subspaces/<name>/.npmrc", with the latter taking
# precedence.
#
# * * * SECURITY WARNING * * *
#
# It is NOT recommended to store authentication tokens in a text file on a lab machine, because
# other unrelated processes may be able to read that file. Also, the file may persist indefinitely,
# for example if the machine loses power. A safer practice is to pass the token via an
# environment variable, which can be referenced from .npmrc using ${} expansion. For example:
#
# //registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN}
#
# Explicitly specify the NPM registry that "rush install" and "rush update" will use by default:
registry=https://registry.npmjs.org/
# Optionally provide an authentication token for the above registry URL (if it is a private registry):
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
# Change this to "true" if your registry requires authentication for read-only operations:
always-auth=false
@@ -0,0 +1,29 @@
# This config file is very similar to common/config/rush/.npmrc, except that .npmrc-publish
# is used by the "rush publish" command, as publishing often involves different credentials
# and registries than other operations.
#
# Before invoking the package manager, Rush will copy this file to "common/temp/publish-home/.npmrc"
# and then temporarily map that folder as the "home directory" for the current user account.
# This enables the same settings to apply for each project folder that gets published. The copied file
# will omit any config lines that reference environment variables that are undefined in that session;
# this avoids problems that would otherwise result due to a missing variable being replaced by
# an empty string.
#
# * * * SECURITY WARNING * * *
#
# It is NOT recommended to store authentication tokens in a text file on a lab machine, because
# other unrelated processes may be able to read the file. Also, the file may persist indefinitely,
# for example if the machine loses power. A safer practice is to pass the token via an
# environment variable, which can be referenced from .npmrc using ${} expansion. For example:
#
# //registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN}
#
# Explicitly specify the NPM registry that "rush publish" will use by default:
registry=https://registry.npmjs.org/
# Optionally provide an authentication token for the above registry URL (if it is a private registry):
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
# Change this to "true" if your registry requires authentication for read-only operations:
always-auth=false
@@ -0,0 +1,38 @@
'use strict';
/**
* When using the PNPM package manager, you can use pnpmfile.js to workaround
* dependencies that have mistakes in their package.json file. (This feature is
* functionally similar to Yarn's "resolutions".)
*
* For details, see the PNPM documentation:
* https://pnpm.io/pnpmfile#hooks
*
* IMPORTANT: SINCE THIS FILE CONTAINS EXECUTABLE CODE, MODIFYING IT IS LIKELY TO INVALIDATE
* ANY CACHED DEPENDENCY ANALYSIS. After any modification to pnpmfile.js, it's recommended to run
* "rush update --full" so that PNPM will recalculate all version selections.
*/
module.exports = {
hooks: {
readPackage
}
};
/**
* This hook is invoked during installation before a package's dependencies
* are selected.
* The `packageJson` parameter is the deserialized package.json
* contents for the package that is about to be installed.
* The `context` parameter provides a log() function.
* The return value is the updated object.
*/
function readPackage(packageJson, context) {
// // The karma types have a missing dependency on typings from the log4js package.
// if (packageJson.name === '@types/karma') {
// context.log('Fixed up dependencies for @types/karma');
// packageJson.dependencies['log4js'] = '0.6.38';
// }
return packageJson;
}
@@ -0,0 +1,109 @@
/**
* This configuration file manages Rush integration with JFrog Artifactory services.
* More documentation is available on the Rush website: https://rushjs.io
*/
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/artifactory.schema.json",
"packageRegistry": {
/**
* (Required) Set this to "true" to enable Rush to manage tokens for an Artifactory NPM registry.
* When enabled, "rush install" will automatically detect when the user's ~/.npmrc
* authentication token is missing or expired. And "rush setup" will prompt the user to
* renew their token.
*
* The default value is false.
*/
"enabled": false,
/**
* (Required) Specify the URL of your NPM registry. This is the same URL that appears in
* your .npmrc file. It should look something like this example:
*
* https://your-company.jfrog.io/your-project/api/npm/npm-private/
*/
"registryUrl": "",
/**
* A list of custom strings that "rush setup" should add to the user's ~/.npmrc file at the time
* when the token is updated. This could be used for example to configure the company registry
* to be used whenever NPM is invoked as a standalone command (but it's not needed for Rush
* operations like "rush add" and "rush install", which get their mappings from the monorepo's
* common/config/rush/.npmrc file).
*
* NOTE: The ~/.npmrc settings are global for the user account on a given machine, so be careful
* about adding settings that may interfere with other work outside the monorepo.
*/
"userNpmrcLinesToAdd": [
// "@example:registry=https://your-company.jfrog.io/your-project/api/npm/npm-private/"
],
/**
* (Required) Specifies the URL of the Artifactory control panel where the user can generate
* an API key. This URL is printed after the "visitWebsite" message.
* It should look something like this example: https://your-company.jfrog.io/
* Specify an empty string to suppress this line entirely.
*/
"artifactoryWebsiteUrl": "",
/**
* Uncomment this line to specify the type of credential to save in the user's ~/.npmrc file.
* The default is "password", which means the user's API token will be traded in for an
* npm password specific to that registry. Optionally you can specify "authToken", which
* will save the user's API token as credentials instead.
*/
// "credentialType": "password",
/**
* These settings allow the "rush setup" interactive prompts to be customized, for
* example with messages specific to your team or configuration. Specify an empty string
* to suppress that message entirely.
*/
"messageOverrides": {
/**
* Overrides the message that normally says:
* "This monorepo consumes packages from an Artifactory private NPM registry."
*/
// "introduction": "",
/**
* Overrides the message that normally says:
* "Please contact the repository maintainers for help with setting up an Artifactory user account."
*/
// "obtainAnAccount": "",
/**
* Overrides the message that normally says:
* "Please open this URL in your web browser:"
*
* The "artifactoryWebsiteUrl" string is printed after this message.
*/
// "visitWebsite": "",
/**
* Overrides the message that normally says:
* "Your user name appears in the upper-right corner of the JFrog website."
*/
// "locateUserName": "",
/**
* Overrides the message that normally says:
* "Click 'Edit Profile' on the JFrog website. Click the 'Generate API Key'
* button if you haven't already done so previously."
*/
// "locateApiKey": ""
/**
* Overrides the message that normally prompts:
* "What is your Artifactory user name?"
*/
// "userNamePrompt": ""
/**
* Overrides the message that normally prompts:
* "What is your Artifactory API key?"
*/
// "apiKeyPrompt": ""
}
}
}
@@ -0,0 +1,160 @@
/**
* This configuration file manages Rush's build cache feature.
* More documentation is available on the Rush website: https://rushjs.io
*/
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/build-cache.schema.json",
/**
* (Required) EXPERIMENTAL - Set this to true to enable the build cache feature.
*
* See https://rushjs.io/pages/maintainer/build_cache/ for details about this experimental feature.
*/
"buildCacheEnabled": false,
/**
* (Required) Choose where project build outputs will be cached.
*
* Possible values: "local-only", "azure-blob-storage", "amazon-s3"
*/
"cacheProvider": "local-only",
/**
* Setting this property overrides the cache entry ID. If this property is set, it must contain
* a [hash] token.
*
* Other available tokens:
* - [projectName] Example: "@my-scope/my-project"
* - [projectName:normalize] Example: "my-scope+my-project"
* - [phaseName] Example: "_phase:test/api"
* - [phaseName:normalize] Example: "_phase:test+api"
* - [phaseName:trimPrefix] Example: "test/api"
* - [os] Example: "win32"
* - [arch] Example: "x64"
*/
// "cacheEntryNamePattern": "[projectName:normalize]-[phaseName:normalize]-[hash]"
/**
* (Optional) Salt to inject during calculation of the cache key. This can be used to invalidate the cache for all projects when the salt changes.
*/
// "cacheHashSalt": "1",
/**
* Use this configuration with "cacheProvider"="azure-blob-storage"
*/
"azureBlobStorageConfiguration": {
/**
* (Required) The name of the the Azure storage account to use for build cache.
*/
// "storageAccountName": "example",
/**
* (Required) The name of the container in the Azure storage account to use for build cache.
*/
// "storageContainerName": "my-container",
/**
* The Azure environment the storage account exists in. Defaults to AzurePublicCloud.
*
* Possible values: "AzurePublicCloud", "AzureChina", "AzureGermany", "AzureGovernment"
*/
// "azureEnvironment": "AzurePublicCloud",
/**
* An optional prefix for cache item blob names.
*/
// "blobPrefix": "my-prefix",
/**
* If set to true, allow writing to the cache. Defaults to false.
*/
// "isCacheWriteAllowed": true,
/**
* The Entra ID login flow to use. Defaults to 'AdoCodespacesAuth' on GitHub Codespaces, 'InteractiveBrowser' otherwise.
*/
// "loginFlow": "InteractiveBrowser",
/**
* If set to true, reading the cache requires authentication. Defaults to false.
*/
// "readRequiresAuthentication": true
},
/**
* Use this configuration with "cacheProvider"="amazon-s3"
*/
"amazonS3Configuration": {
/**
* (Required unless s3Endpoint is specified) The name of the bucket to use for build cache.
* Example: "my-bucket"
*/
// "s3Bucket": "my-bucket",
/**
* (Required unless s3Bucket is specified) The Amazon S3 endpoint of the bucket to use for build cache.
* This should not include any path; use the s3Prefix to set the path.
* Examples: "my-bucket.s3.us-east-2.amazonaws.com" or "http://localhost:9000"
*/
// "s3Endpoint": "https://my-bucket.s3.us-east-2.amazonaws.com",
/**
* (Required) The Amazon S3 region of the bucket to use for build cache.
* Example: "us-east-1"
*/
// "s3Region": "us-east-1",
/**
* An optional prefix ("folder") for cache items. It should not start with "/".
*/
// "s3Prefix": "my-prefix",
/**
* If set to true, allow writing to the cache. Defaults to false.
*/
// "isCacheWriteAllowed": true
},
/**
* Use this configuration with "cacheProvider"="http"
*/
"httpConfiguration": {
/**
* (Required) The URL of the server that stores the caches.
* Example: "https://build-cacches.example.com/"
*/
// "url": "https://build-cacches.example.com/",
/**
* (Optional) The HTTP method to use when writing to the cache (defaults to PUT).
* Should be one of PUT, POST, or PATCH.
* Example: "PUT"
*/
// "uploadMethod": "PUT",
/**
* (Optional) HTTP headers to pass to the cache server.
* Example: { "X-HTTP-Company-Id": "109283" }
*/
// "headers": {},
/**
* (Optional) Shell command that prints the authorization token needed to communicate with the
* cache server, and exits with exit code 0. This command will be executed from the root of
* the monorepo.
* Example: { "exec": "node", "args": ["common/scripts/auth.js"] }
*/
// "tokenHandler": { "exec": "node", "args": ["common/scripts/auth.js"] },
/**
* (Optional) Prefix for cache keys.
* Example: "my-company-"
*/
// "cacheKeyPrefix": "",
/**
* (Optional) If set to true, allow writing to the cache. Defaults to false.
*/
// "isCacheWriteAllowed": true
}
}
@@ -0,0 +1,22 @@
/**
* This configuration file manages Rush's cobuild feature.
* More documentation is available on the Rush website: https://rushjs.io
*/
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/cobuild.schema.json",
/**
* (Required) EXPERIMENTAL - Set this to true to enable the cobuild feature.
* RUSH_COBUILD_CONTEXT_ID should always be specified as an environment variable with an non-empty string,
* otherwise the cobuild feature will be disabled.
*/
"cobuildFeatureEnabled": false,
/**
* (Required) Choose where cobuild lock will be acquired.
*
* The lock provider is registered by the rush plugins.
* For example, @rushstack/rush-redis-cobuild-plugin registers the "redis" lock provider.
*/
"cobuildLockProvider": "redis"
}
@@ -0,0 +1,517 @@
/**
* This configuration file defines custom commands for the "rush" command-line.
* More documentation is available on the Rush website: https://rushjs.io
*/
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/command-line.schema.json",
"phases": [
{
"name": "_phase:build",
"dependencies": {
"upstream": ["_phase:build"]
},
"ignoreMissingScript": true,
"allowWarningsOnSuccess": false
},
{
"name": "_phase:validate",
"dependencies": {
"self": ["_phase:build"],
"upstream": ["_phase:validate", "_phase:build"]
},
"ignoreMissingScript": true,
"allowWarningsOnSuccess": false
},
{
"name": "_phase:test",
"dependencies": {
"self": ["_phase:build"],
"upstream": ["_phase:validate"]
},
"ignoreMissingScript": true,
"allowWarningsOnSuccess": true
},
{
"name": "_phase:lint",
"dependencies": {
"self": ["_phase:build"]
},
"ignoreMissingScript": true,
"allowWarningsOnSuccess": false
},
{
"name": "_phase:bundle",
"dependencies": {
"self": ["_phase:build"]
},
"ignoreMissingScript": true,
"allowWarningsOnSuccess": false
},
{
"name": "_phase:format",
"dependencies": {
"self": ["_phase:build"]
},
"ignoreMissingScript": true,
"allowWarningsOnSuccess": false
},
{
"name": "_phase:svelte-check",
"dependencies": {
"self": ["_phase:build"]
},
"ignoreMissingScript": true,
"allowWarningsOnSuccess": true
},
{
"name": "_phase:package",
"dependencies": {
"self": ["_phase:build"],
"upstream": ["_phase:package"]
},
"ignoreMissingScript": true,
"allowWarningsOnSuccess": false
},
{
"name": "_phase:docker-build",
"dependencies": {
"self": ["_phase:build", "_phase:package", "_phase:bundle"],
"upstream": ["_phase:build", "_phase:bundle", "_phase:package"]
},
"ignoreMissingScript": true,
"allowWarningsOnSuccess": true
},
{
"name": "_phase:docker-staging",
"dependencies": {
"self": ["_phase:build", "_phase:package", "_phase:bundle"]
},
"ignoreMissingScript": true,
"allowWarningsOnSuccess": true
}
],
"commands": [
{
"commandKind": "global",
"name": "coverage",
"summary": "Run tests, merge LCOV and generate HTML coverage",
"description": "Run 'rush test', then merge per-package LCOV files and generate HTML coverage in coverage/html",
"safeForSimultaneousRushProcesses": true,
"shellCommand": "rush test && node scripts/merge-coverage.js && node scripts/generate-coverage-html.js coverage/lcov.info coverage/html"
},
{
"commandKind": "bulk",
"name": "format",
"summary": "Format",
"description": "Perform a formatting",
"enableParallelism": true,
"incremental": false,
"ignoreMissingScript": true,
"safeForSimultaneousRushProcesses": true,
"disableBuildCache": true
},
{
"commandKind": "global",
"name": "doformat",
"summary": "Do a format and show errors after it",
"description": "Do a format and show errors after it",
"safeForSimultaneousRushProcesses": true,
"shellCommand": "(rush format && true) && ./common/scripts/format-show.sh"
},
{
"commandKind": "phased",
"name": "build:watch",
"summary": "Build and watch",
"phases": ["_phase:build", "_phase:validate"],
"description": "Perform build with tsc and watch for changes with rush",
"enableParallelism": true,
"incremental": true,
"safeForSimultaneousRushProcesses": true,
"watchOptions": {
"alwaysWatch": true,
"watchPhases": ["_phase:build", "_phase:validate"]
}
},
{
"summary": "svelte-check",
"commandKind": "phased",
"name": "svelte-check",
"phases": ["_phase:build", "_phase:svelte-check"],
"enableParallelism": true,
"incremental": true
},
{
"commandKind": "phased",
"name": "build",
"summary": "build",
"phases": ["_phase:build"],
"enableParallelism": true,
"incremental": true,
"watchOptions": {
"alwaysWatch": false,
"watchPhases": ["_phase:build"]
}
},
{
"commandKind": "phased",
"name": "validate",
"phases": ["_phase:validate"],
"summary": "validate",
"enableParallelism": true,
"incremental": true
},
{
"commandKind": "phased",
"name": "rebuild",
"summary": "ReBuild and test all projects.",
"phases": ["_phase:build"],
"enableParallelism": true,
"incremental": false
},
{
"commandKind": "phased",
"name": "dorevalidate",
"phases": ["_phase:validate"],
"summary": "dorevalidate",
"enableParallelism": true,
"incremental": false
},
{
"commandKind": "phased",
"summary": "Do testing",
"name": "test",
"phases": ["_phase:build", "_phase:test"],
"enableParallelism": true,
"incremental": true
},
{
"commandKind": "phased",
"name": "retest",
"summary": "Build and test all projects.",
"phases": ["_phase:build", "_phase:test"],
"enableParallelism": true,
"incremental": false
},
{
"commandKind": "phased",
"summary": "Do bundle",
"name": "bundle",
"phases": ["_phase:build", "_phase:bundle"],
"enableParallelism": true,
"incremental": true
},
{
"commandKind": "phased",
"summary": "Do packaging",
"name": "package",
"phases": ["_phase:build", "_phase:package"],
"enableParallelism": true,
"incremental": true
},
{
"summary": "docker:build",
"commandKind": "phased",
"name": "docker:build",
"phases": ["_phase:build", "_phase:bundle", "_phase:package", "_phase:docker-build"],
"enableParallelism": true,
"incremental": true
},
{
"summary": "docker:rebuild",
"commandKind": "phased",
"name": "docker:rebuild",
"phases": ["_phase:build", "_phase:bundle", "_phase:package", "_phase:docker-build"],
"enableParallelism": true,
"incremental": false
},
{
"summary": "docker:staging",
"commandKind": "phased",
"name": "docker:staging",
"phases": ["_phase:build", "_phase:bundle", "_phase:package", "_phase:docker-staging"],
"enableParallelism": true,
"incremental": true
},
{
"commandKind": "bulk",
"name": "docker:push",
"summary": "docker:push",
"description": "Push docker release images",
"enableParallelism": true,
"incremental": false,
"ignoreDependencyOrder": false,
"ignoreMissingScript": true,
"disableBuildCache": true,
"allowWarningsInSuccessfulBuild": true
},
{
"commandKind": "global",
"name": "docker",
"summary": "Build docker with platform",
"description": "use to build all docker containers required for platform",
"safeForSimultaneousRushProcesses": true,
"shellCommand": "./common/scripts/docker.sh"
},
{
"commandKind": "global",
"name": "docker:up",
"summary": "Up development build",
"description": "Up development build",
"safeForSimultaneousRushProcesses": true,
"shellCommand": "cd ./dev && docker compose up -d --force-recreate"
},
{
"commandKind": "global",
"name": "docker:local",
"summary": "Up development build",
"description": "Up development build",
"safeForSimultaneousRushProcesses": true,
"shellCommand": "cd ./dev/local-mongo && docker compose -p dev up -d --force-recreate"
},
{
"commandKind": "global",
"name": "tool:upgrade",
"summary": "Upgrade all local models",
"description": "Upgrade all local models",
"safeForSimultaneousRushProcesses": true,
"shellCommand": "cd ./dev/tool && rushx run-local upgrade -f"
},
{
"commandKind": "global",
"name": "apply-templates",
"summary": "Update all package.json according to templates matched from templates folder",
"description": "Use to update all projects to templates",
"safeForSimultaneousRushProcesses": true,
"shellCommand": "node templates/apply.js"
},
{
"commandKind": "global",
"name": "ts-clean",
"summary": "Clean tsconfig.tsbuildinfo",
"description": "Clean typescript incremental cache",
"safeForSimultaneousRushProcesses": true,
"shellCommand": "find .|grep tsconfig.tsbuildinfo | xargs rm | pwd"
},
{
"commandKind": "global",
"name": "revalidate",
"summary": "Clean Validate cache and to validate again",
"description": "Clean typescript incremental cache",
"safeForSimultaneousRushProcesses": true,
"shellCommand": "find .|grep tsBuildInfoFile.info | xargs rm | pwd && rush dorevalidate"
},
{
"commandKind": "bulk",
"name": "remove-ts-types",
"summary": "Clean validate types",
"description": "Clean validate types",
"shellCommand": "rm -rf ./types",
"enableParallelism": true,
"incremental": false,
"ignoreDependencyOrder": true,
"ignoreMissingScript": true,
"disableBuildCache": true,
"allowWarningsInSuccessfulBuild": true
},
{
"commandKind": "global",
"name": "model-version",
"summary": "show model version",
"description": "show model version",
"safeForSimultaneousRushProcesses": true,
"shellCommand": "npx node ./common/scripts/show_version.js"
},
{
"commandKind": "global",
"name": "show-model",
"summary": "show model",
"description": "show model",
"safeForSimultaneousRushProcesses": true,
"shellCommand": "cd ./models/all && rushx show-model"
},
{
"commandKind": "global",
"name": "deps-clean",
"summary": "Clean package-deps-*.json files",
"description": "Clean package-deps-*.json files",
"safeForSimultaneousRushProcesses": true,
"shellCommand": "find .|grep .rush/temp/package-deps_ | xargs rm"
},
{
"commandKind": "global",
"name": "fast-format",
"summary": "Format changed projects",
"description": "Format and autofix linting issues in changed projects",
"safeForSimultaneousRushProcesses": true,
"shellCommand": "./common/scripts/fast-format.sh"
},
{
"commandKind": "global",
"name": "bump-changes",
"summary": "Bump changes from tag",
"description": "Bump changes from previous tag",
"safeForSimultaneousRushProcesses": true,
"shellCommand": "./common/scripts/node_modules/.bin/bump-changes-from-tag"
}
],
/**
* Custom "parameters" introduce new parameters for specified Rush command-line commands.
* For example, you might define a "--production" parameter for the "rush build" command.
*/
"parameters": [
{
"parameterKind": "flag",
"longName": "--lite",
"shortName": "-l",
"description": "Enable Heft lite building option, will skip some phases.",
"associatedCommands": ["build", "rebuild"]
},
{
"parameterKind": "flag",
"longName": "--clean",
"description": "Enable Heft clean building option",
"associatedCommands": ["build", "rebuild"]
},
{
"parameterKind": "flag",
"longName": "--force",
"shortName": "-f",
"description": "Force formatting",
"associatedCommands": ["format"]
},
{
"parameterKind": "string",
"argumentName": "BRANCH",
"required": false,
"associatedPhases": [],
"shortName": "-b",
"longName": "--branch",
"description": "Force formatting of branch",
"associatedCommands": ["fast-format"]
}
// {
// /**
// * (Required) Determines the type of custom parameter.
// * A "flag" is a custom command-line parameter whose presence acts as an on/off switch.
// */
// "parameterKind": "flag",
//
// /**
// * (Required) The long name of the parameter. It must be lower-case and use dash delimiters.
// */
// "longName": "--my-flag",
//
// /**
// * An optional alternative short name for the parameter. It must be a dash followed by a single
// * lower-case or upper-case letter, which is case-sensitive.
// *
// * NOTE: The Rush developers recommend that automation scripts should always use the long name
// * to improve readability. The short name is only intended as a convenience for humans.
// * The alphabet letters run out quickly, and are difficult to memorize, so *only* use
// * a short name if you expect the parameter to be needed very often in everyday operations.
// */
// "shortName": "-m",
//
// /**
// * (Required) A long description to be shown in the command-line help.
// *
// * Whenever you introduce commands/parameters, taking a little time to write meaningful
// * documentation can make a big difference for the developer experience in your repo.
// */
// "description": "A custom flag parameter that is passed to the scripts that are invoked when building projects",
//
// /**
// * (Required) A list of custom commands and/or built-in Rush commands that this parameter may
// * be used with. The parameter will be appended to the shell command that Rush invokes.
// */
// "associatedCommands": ["build", "rebuild"]
// },
//
// {
// /**
// * (Required) Determines the type of custom parameter.
// * A "string" is a custom command-line parameter whose value is a simple text string.
// */
// "parameterKind": "string",
// "longName": "--my-string",
// "description": "A custom string parameter for the \"my-global-command\" custom command",
//
// "associatedCommands": ["my-global-command"],
//
// /**
// * The name of the argument, which will be shown in the command-line help.
// *
// * For example, if the parameter name is '--count" and the argument name is "NUMBER",
// * then the command-line help would display "--count NUMBER". The argument name must
// * be comprised of upper-case letters, numbers, and underscores. It should be kept short.
// */
// "argumentName": "SOME_TEXT",
//
// /**
// * If true, this parameter must be included with the command. The default is false.
// */
// "required": false
// },
//
// {
// /**
// * (Required) Determines the type of custom parameter.
// * A "choice" is a custom command-line parameter whose argument must be chosen from a list of
// * allowable alternatives.
// */
// "parameterKind": "choice",
// "longName": "--my-choice",
// "description": "A custom choice parameter for the \"my-global-command\" custom command",
//
// "associatedCommands": ["my-global-command"],
//
// /**
// * If true, this parameter must be included with the command. The default is false.
// */
// "required": false,
//
// /**
// * Normally if a parameter is omitted from the command line, it will not be passed
// * to the shell command. this value will be inserted by default. Whereas if a "defaultValue"
// * is defined, the parameter will always be passed to the shell command, and will use the
// * default value if unspecified. The value must be one of the defined alternatives.
// */
// "defaultValue": "vanilla",
//
// /**
// * (Required) A list of alternative argument values that can be chosen for this parameter.
// */
// "alternatives": [
// {
// /**
// * A token that is one of the alternatives that can be used with the choice parameter,
// * e.g. "vanilla" in "--flavor vanilla".
// */
// "name": "vanilla",
//
// /**
// * A detailed description for the alternative that can be shown in the command-line help.
// *
// * Whenever you introduce commands/parameters, taking a little time to write meaningful
// * documentation can make a big difference for the developer experience in your repo.
// */
// "description": "Use the vanilla flavor (the default)"
// },
//
// {
// "name": "chocolate",
// "description": "Use the chocolate flavor"
// },
//
// {
// "name": "strawberry",
// "description": "Use the strawberry flavor"
// }
// ]
// }
]
}
@@ -0,0 +1,77 @@
/**
* This configuration file specifies NPM dependency version selections that affect all projects
* in a Rush repo. More documentation is available on the Rush website: https://rushjs.io
*/
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/common-versions.schema.json",
/**
* A table that specifies a "preferred version" for a given NPM package. This feature is typically used
* to hold back an indirect dependency to a specific older version, or to reduce duplication of indirect dependencies.
*
* The "preferredVersions" value can be any SemVer range specifier (e.g. "~1.2.3"). Rush injects these values into
* the "dependencies" field of the top-level common/temp/package.json, which influences how the package manager
* will calculate versions. The specific effect depends on your package manager. Generally it will have no
* effect on an incompatible or already constrained SemVer range. If you are using PNPM, similar effects can be
* achieved using the pnpmfile.js hook. See the Rush documentation for more details.
*
* After modifying this field, it's recommended to run "rush update --full" so that the package manager
* will recalculate all version selections.
*/
"preferredVersions": {
/**
* When someone asks for "^1.0.0" make sure they get "1.2.3" when working in this repo,
* instead of the latest version.
*/
// "some-library": "1.2.3"
},
/**
* When set to true, for all projects in the repo, all dependencies will be automatically added as preferredVersions,
* except in cases where different projects specify different version ranges for a given dependency. For older
* package managers, this tended to reduce duplication of indirect dependencies. However, it can sometimes cause
* trouble for indirect dependencies with incompatible peerDependencies ranges.
*
* The default value is true. If you're encountering installation errors related to peer dependencies,
* it's recommended to set this to false.
*
* After modifying this field, it's recommended to run "rush update --full" so that the package manager
* will recalculate all version selections.
*/
// "implicitlyPreferredVersions": false,
/**
* If you would like the version specifiers for your dependencies to be consistent, then
* uncomment this line. This is effectively similar to running "rush check" before any
* of the following commands:
*
* rush install, rush update, rush link, rush version, rush publish
*
* In some cases you may want this turned on, but need to allow certain packages to use a different
* version. In those cases, you will need to add an entry to the "allowedAlternativeVersions"
* section of the common-versions.json.
*
* In the case that subspaces is enabled, this setting will take effect at a subspace level.
*/
// "ensureConsistentVersions": true,
/**
* The "rush check" command can be used to enforce that every project in the repo must specify
* the same SemVer range for a given dependency. However, sometimes exceptions are needed.
* The allowedAlternativeVersions table allows you to list other SemVer ranges that will be
* accepted by "rush check" for a given dependency.
*
* IMPORTANT: THIS TABLE IS FOR *ADDITIONAL* VERSION RANGES THAT ARE ALTERNATIVES TO THE
* USUAL VERSION (WHICH IS INFERRED BY LOOKING AT ALL PROJECTS IN THE REPO).
* This design avoids unnecessary churn in this file.
*/
"allowedAlternativeVersions": {
/**
* For example, allow some projects to use an older TypeScript compiler
* (in addition to whatever "usual" version is being used by other projects in the repo):
*/
// "typescript": [
// "~2.4.0"
// ]
}
}
@@ -0,0 +1,29 @@
/**
* This configuration file allows repo maintainers to configure extra details to be
* printed alongside certain Rush messages. More documentation is available on the
* Rush website: https://rushjs.io
*/
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/custom-tips.schema.json",
/**
* Custom tips allow you to annotate Rush's console messages with advice tailored for
* your specific monorepo.
*/
"customTips": [
// {
// /**
// * (REQUIRED) An identifier indicating a message that may be printed by Rush.
// * If that message is printed, then this custom tip will be shown.
// * The list of available tip identifiers can be found on this page:
// * https://rushjs.io/pages/maintainer/custom_tips/
// */
// "tipId": "TIP_RUSH_INCONSISTENT_VERSIONS",
//
// /**
// * (REQUIRED) The message text to be displayed for this tip.
// */
// "message": "For additional troubleshooting information, refer this wiki article:\n\nhttps://intranet.contoso.com/docs/pnpm-mismatch"
// }
]
}
@@ -0,0 +1,121 @@
/**
* This configuration file allows repo maintainers to enable and disable experimental
* Rush features. More documentation is available on the Rush website: https://rushjs.io
*/
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/experiments.schema.json",
/**
* By default, 'rush install' passes --no-prefer-frozen-lockfile to 'pnpm install'.
* Set this option to true to pass '--frozen-lockfile' instead for faster installs.
*/
// "usePnpmFrozenLockfileForRushInstall": true,
/**
* By default, 'rush update' passes --no-prefer-frozen-lockfile to 'pnpm install'.
* Set this option to true to pass '--prefer-frozen-lockfile' instead to minimize shrinkwrap changes.
*/
// "usePnpmPreferFrozenLockfileForRushUpdate": true,
/**
* By default, 'rush update' runs as a single operation.
* Set this option to true to instead update the lockfile with `--lockfile-only`, then perform a `--frozen-lockfile` install.
* Necessary when using the `afterAllResolved` hook in .pnpmfile.cjs.
*/
// "usePnpmLockfileOnlyThenFrozenLockfileForRushUpdate": true,
/**
* If using the 'preventManualShrinkwrapChanges' option, restricts the hash to only include the layout of external dependencies.
* Used to allow links between workspace projects or the addition/removal of references to existing dependency versions to not
* cause hash changes.
*/
// "omitImportersFromPreventManualShrinkwrapChanges": true,
/**
* If true, the chmod field in temporary project tar headers will not be normalized.
* This normalization can help ensure consistent tarball integrity across platforms.
*/
// "noChmodFieldInTarHeaderNormalization": true,
/**
* If true, build caching will respect the allowWarningsInSuccessfulBuild flag and cache builds with warnings.
* This will not replay warnings from the cached build.
*/
// "buildCacheWithAllowWarningsInSuccessfulBuild": true,
/**
* If true, build skipping will respect the allowWarningsInSuccessfulBuild flag and skip builds with warnings.
* This will not replay warnings from the skipped build.
*/
// "buildSkipWithAllowWarningsInSuccessfulBuild": true,
/**
* If true, perform a clean install after when running `rush install` or `rush update` if the
* `.npmrc` file has changed since the last install.
*/
// "cleanInstallAfterNpmrcChanges": true,
/**
* If true, print the outputs of shell commands defined in event hooks to the console.
*/
// "printEventHooksOutputToConsole": true,
/**
* If true, Rush will not allow node_modules in the repo folder or in parent folders.
*/
// "forbidPhantomResolvableNodeModulesFolders": true,
/**
* (UNDER DEVELOPMENT) For certain installation problems involving peer dependencies, PNPM cannot
* correctly satisfy versioning requirements without installing duplicate copies of a package inside the
* node_modules folder. This poses a problem for "workspace:*" dependencies, as they are normally
* installed by making a symlink to the local project source folder. PNPM's "injected dependencies"
* feature provides a model for copying the local project folder into node_modules, however copying
* must occur AFTER the dependency project is built and BEFORE the consuming project starts to build.
* The "pnpm-sync" tool manages this operation; see its documentation for details.
* Enable this experiment if you want "rush" and "rushx" commands to resync injected dependencies
* by invoking "pnpm-sync" during the build.
*/
// "usePnpmSyncForInjectedDependencies": true,
/**
* If set to true, Rush will generate a `project-impact-graph.yaml` file in the repository root during `rush update`.
*/
// "generateProjectImpactGraphDuringRushUpdate": true,
/**
* If true, when running in watch mode, Rush will check for phase scripts named `_phase:<name>:ipc` and run them instead
* of `_phase:<name>` if they exist. The created child process will be provided with an IPC channel and expected to persist
* across invocations.
*/
// "useIPCScriptsInWatchMode": true,
/**
* (UNDER DEVELOPMENT) The Rush alerts feature provides a way to send announcements to engineers
* working in the monorepo, by printing directly in the user's shell window when they invoke Rush commands.
* This ensures that important notices will be seen by anyone doing active development, since people often
* ignore normal discussion group messages or don't know to subscribe.
*/
// "rushAlerts": true,
/**
* When using cobuilds, this experiment allows uncacheable operations to benefit from cobuild orchestration without using the build cache.
*/
// "allowCobuildWithoutCache": true,
/**
* By default, rush perform a full scan of the entire repository. For example, Rush runs `git status` to check for local file changes.
* When this toggle is enabled, Rush will only scan specific paths, significantly speeding up Git operations.
*/
// "enableSubpathScan": true,
/**
* Rush has a policy that normally requires Rush projects to specify `workspace:*` in package.json when depending
* on other projects in the workspace, unless they are explicitly declared as `decoupledLocalDependencies`
* in rush.json. Enabling this experiment will remove that requirement for dependencies belonging to a different
* subspace. This is useful for large product groups who work in separate subspaces and generally prefer to consume
* each other's packages via the NPM registry.
*/
// "exemptDecoupledDependenciesBetweenSubspaces": false
}
@@ -0,0 +1,329 @@
/**
* This configuration file provides settings specific to the PNPM package manager.
* More documentation is available on the Rush website: https://rushjs.io
*
* Rush normally looks for this file in `common/config/rush/pnpm-config.json`. However,
* if `subspacesEnabled` is true in subspaces.json, then Rush will instead first look
* for `common/config/subspaces/<name>/pnpm-config.json`. (If the file exists in both places,
* then the file under `common/config/rush` is ignored.)
*/
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json",
/**
* If true, then `rush install` and `rush update` will use the PNPM workspaces feature
* to perform the install, instead of the old model where Rush generated the symlinks
* for each projects's node_modules folder.
*
* When using workspaces, Rush will generate a `common/temp/pnpm-workspace.yaml` file referencing
* all local projects to install. Rush will also generate a `.pnpmfile.cjs` shim which implements
* Rush-specific features such as preferred versions. The user's `common/config/rush/.pnpmfile.cjs`
* is invoked by the shim.
*
* This option is strongly recommended. The default value is false.
*/
"useWorkspaces": true,
/**
* This setting determines how PNPM chooses version numbers during `rush update`.
* For example, suppose `lib-x@3.0.0` depends on `"lib-y": "^1.2.3"` whose latest major
* releases are `1.8.9` and `2.3.4`. The resolution mode `lowest-direct` might choose
* `lib-y@1.2.3`, wheres `highest` will choose 1.8.9, and `time-based` will pick the
* highest compatible version at the time when `lib-x@3.0.0` itself was published (ensuring
* that the version could have been tested by the maintainer of "lib-x"). For local workspace
* projects, `time-based` instead works like `lowest-direct`, avoiding upgrades unless
* they are explicitly requested. Although `time-based` is the most robust option, it may be
* slightly slower with registries such as npmjs.com that have not implemented an optimization.
*
* IMPORTANT: Be aware that PNPM 8.0.0 initially defaulted to `lowest-direct` instead of
* `highest`, but PNPM reverted this decision in 8.6.12 because it caused confusion for users.
* Rush version 5.106.0 and newer avoids this confusion by consistently defaulting to
* `highest` when `resolutionMode` is not explicitly set in pnpm-config.json or .npmrc,
* regardless of your PNPM version.
*
* PNPM documentation: https://pnpm.io/npmrc#resolution-mode
*
* Possible values are: `highest`, `time-based`, and `lowest-direct`.
* The default is `highest`.
*/
// "resolutionMode": "time-based",
/**
* This setting determines whether PNPM will automatically install (non-optional)
* missing peer dependencies instead of reporting an error. Doing so conveniently
* avoids the need to specify peer versions in package.json, but in a large monorepo
* this often creates worse problems. The reason is that peer dependency behavior
* is inherently complicated, and it is easier to troubleshoot consequences of an explicit
* version than an invisible heuristic. The original NPM RFC discussion pointed out
* some other problems with this feature: https://github.com/npm/rfcs/pull/43
* IMPORTANT: Without Rush, the setting defaults to true for PNPM 8 and newer; however,
* as of Rush version 5.109.0 the default is always false unless `autoInstallPeers`
* is specified in pnpm-config.json or .npmrc, regardless of your PNPM version.
* PNPM documentation: https://pnpm.io/npmrc#auto-install-peers
* The default value is false.
*/
// "autoInstallPeers": false,
/**
* If true, then Rush will add the `--strict-peer-dependencies` command-line parameter when
* invoking PNPM. This causes `rush update` to fail if there are unsatisfied peer dependencies,
* which is an invalid state that can cause build failures or incompatible dependency versions.
* (For historical reasons, JavaScript package managers generally do not treat this invalid
* state as an error.)
*
* PNPM documentation: https://pnpm.io/npmrc#strict-peer-dependencies
*
* The default value is false to avoid legacy compatibility issues.
* It is strongly recommended to set `strictPeerDependencies=true`.
*/
"strictPeerDependencies": true,
/**
* Environment variables that will be provided to PNPM.
*/
// "environmentVariables": {
// "NODE_OPTIONS": {
// "value": "--max-old-space-size=4096",
// "override": false
// }
// },
/**
* Specifies the location of the PNPM store. There are two possible values:
*
* - `local` - use the `pnpm-store` folder in the current configured temp folder:
* `common/temp/pnpm-store` by default.
* - `global` - use PNPM's global store, which has the benefit of being shared
* across multiple repo folders, but the disadvantage of less isolation for builds
* (for example, bugs or incompatibilities when two repos use different releases of PNPM)
*
* In both cases, the store path can be overridden by the environment variable `RUSH_PNPM_STORE_PATH`.
*
* The default value is `local`.
*/
// "pnpmStore": "global",
/**
* If true, then `rush install` will report an error if manual modifications
* were made to the PNPM shrinkwrap file without running `rush update` afterwards.
*
* This feature protects against accidental inconsistencies that may be introduced
* if the PNPM shrinkwrap file (`pnpm-lock.yaml`) is manually edited. When this
* feature is enabled, `rush update` will append a hash to the file as a YAML comment,
* and then `rush update` and `rush install` will validate the hash. Note that this
* does not prohibit manual modifications, but merely requires `rush update` be run
* afterwards, ensuring that PNPM can report or repair any potential inconsistencies.
*
* To temporarily disable this validation when invoking `rush install`, use the
* `--bypass-policy` command-line parameter.
*
* The default value is false.
*/
// "preventManualShrinkwrapChanges": true,
/**
* When a project uses `workspace:` to depend on another Rush project, PNPM normally installs
* it by creating a symlink under `node_modules`. This generally works well, but in certain
* cases such as differing `peerDependencies` versions, symlinking may cause trouble
* such as incorrectly satisfied versions. For such cases, the dependency can be declared
* as "injected", causing PNPM to copy its built output into `node_modules` like a real
* install from a registry. Details here: https://rushjs.io/pages/advanced/injected_deps/
*
* When using Rush subspaces, these sorts of versioning problems are much more likely if
* `workspace:` refers to a project from a different subspace. This is because the symlink
* would point to a separate `node_modules` tree installed by a different PNPM lockfile.
* A comprehensive solution is to enable `alwaysInjectDependenciesFromOtherSubspaces`,
* which automatically treats all projects from other subspaces as injected dependencies
* without having to manually configure them.
*
* NOTE: Use carefully -- excessive file copying can slow down the `rush install` and
* `pnpm-sync` operations if too many dependencies become injected.
*
* The default value is false.
*/
// "alwaysInjectDependenciesFromOtherSubspaces": false,
/**
* Defines the policies to be checked for the `pnpm-lock.yaml` file.
*/
"pnpmLockfilePolicies": {
/**
* This policy will cause "rush update" to report an error if `pnpm-lock.yaml` contains
* any SHA1 integrity hashes.
*
* For each NPM dependency, `pnpm-lock.yaml` normally stores an `integrity` hash. Although
* its main purpose is to detect corrupted or truncated network requests, this hash can also
* serve as a security fingerprint to protect against attacks that would substitute a
* malicious tarball, for example if a misconfigured .npmrc caused a machine to accidentally
* download a matching package name+version from npmjs.com instead of the private NPM registry.
* NPM originally used a SHA1 hash; this was insecure because an attacker can too easily craft
* a tarball with a matching fingerprint. For this reason, NPM later deprecated SHA1 and
* instead adopted a cryptographically strong SHA512 hash. Nonetheless, SHA1 hashes can
* occasionally reappear during "rush update", for example due to missing metadata fallbacks
* (https://github.com/orgs/pnpm/discussions/6194) or an incompletely migrated private registry.
* The `disallowInsecureSha1` policy prevents this, avoiding potential security/compliance alerts.
*/
// "disallowInsecureSha1": {
// /**
// * Enables the "disallowInsecureSha1" policy. The default value is false.
// */
// "enabled": true,
//
// /**
// * In rare cases, a private NPM registry may continue to serve SHA1 hashes for very old
// * package versions, perhaps due to a caching issue or database migration glitch. To avoid
// * having to disable the "disallowInsecureSha1" policy for the entire monorepo, the problematic
// * package versions can be individually ignored. The "exemptPackageVersions" key is the
// * package name, and the array value lists exact version numbers to be ignored.
// */
// "exemptPackageVersions": {
// "example1": ["1.0.0"],
// "example2": ["2.0.0", "2.0.1"]
// }
// }
},
/**
* The "globalOverrides" setting provides a simple mechanism for overriding version selections
* for all dependencies of all projects in the monorepo workspace. The settings are copied
* into the `pnpm.overrides` field of the `common/temp/package.json` file that is generated
* by Rush during installation.
*
* Order of precedence: `.pnpmfile.cjs` has the highest precedence, followed by
* `unsupportedPackageJsonSettings`, `globalPeerDependencyRules`, `globalPackageExtensions`,
* and `globalOverrides` has lowest precedence.
*
* PNPM documentation: https://pnpm.io/package_json#pnpmoverrides
*/
"globalOverrides": {
// "example1": "^1.0.0",
// "example2": "npm:@company/example2@^1.0.0"
},
/**
* The `globalPeerDependencyRules` setting provides various settings for suppressing validation errors
* that are reported during installation with `strictPeerDependencies=true`. The settings are copied
* into the `pnpm.peerDependencyRules` field of the `common/temp/package.json` file that is generated
* by Rush during installation.
*
* Order of precedence: `.pnpmfile.cjs` has the highest precedence, followed by
* `unsupportedPackageJsonSettings`, `globalPeerDependencyRules`, `globalPackageExtensions`,
* and `globalOverrides` has lowest precedence.
*
* https://pnpm.io/package_json#pnpmpeerdependencyrules
*/
"globalPeerDependencyRules": {
// "ignoreMissing": ["@eslint/*"],
// "allowedVersions": { "react": "17" },
// "allowAny": ["@babel/*"]
},
/**
* The `globalPackageExtension` setting provides a way to patch arbitrary package.json fields
* for any PNPM dependency of the monorepo. The settings are copied into the `pnpm.packageExtensions`
* field of the `common/temp/package.json` file that is generated by Rush during installation.
* The `globalPackageExtension` setting has similar capabilities as `.pnpmfile.cjs` but without
* the downsides of an executable script (nondeterminism, unreliable caching, performance concerns).
*
* Order of precedence: `.pnpmfile.cjs` has the highest precedence, followed by
* `unsupportedPackageJsonSettings`, `globalPeerDependencyRules`, `globalPackageExtensions`,
* and `globalOverrides` has lowest precedence.
*
* PNPM documentation: https://pnpm.io/package_json#pnpmpackageextensions
*/
"globalPackageExtensions": {
// "fork-ts-checker-webpack-plugin": {
// "dependencies": {
// "@babel/core": "1"
// },
// "peerDependencies": {
// "eslint": ">= 6"
// },
// "peerDependenciesMeta": {
// "eslint": {
// "optional": true
// }
// }
// }
},
/**
* The `globalNeverBuiltDependencies` setting suppresses the `preinstall`, `install`, and `postinstall`
* lifecycle events for the specified NPM dependencies. This is useful for scripts with poor practices
* such as downloading large binaries without retries or attempting to invoke OS tools such as
* a C++ compiler. (PNPM's terminology refers to these lifecycle events as "building" a package;
* it has nothing to do with build system operations such as `rush build` or `rushx build`.)
* The settings are copied into the `pnpm.neverBuiltDependencies` field of the `common/temp/package.json`
* file that is generated by Rush during installation.
*
* PNPM documentation: https://pnpm.io/package_json#pnpmneverbuiltdependencies
*/
"globalNeverBuiltDependencies": [
// "fsevents"
],
/**
* The `globalIgnoredOptionalDependencies` setting suppresses the installation of optional NPM
* dependencies specified in the list. This is useful when certain optional dependencies are
* not needed in your environment, such as platform-specific packages or dependencies that
* fail during installation but are not critical to your project.
* These settings are copied into the `pnpm.overrides` field of the `common/temp/package.json`
* file that is generated by Rush during installation, instructing PNPM to ignore the specified
* optional dependencies.
*
* PNPM documentation: https://pnpm.io/package_json#pnpmignoredoptionaldependencies
*/
"globalIgnoredOptionalDependencies": [
// "fsevents"
],
/**
* The `globalAllowedDeprecatedVersions` setting suppresses installation warnings for package
* versions that the NPM registry reports as being deprecated. This is useful if the
* deprecated package is an indirect dependency of an external package that has not released a fix.
* The settings are copied into the `pnpm.allowedDeprecatedVersions` field of the `common/temp/package.json`
* file that is generated by Rush during installation.
*
* PNPM documentation: https://pnpm.io/package_json#pnpmalloweddeprecatedversions
*
* If you are working to eliminate a deprecated version, it's better to specify `allowedDeprecatedVersions`
* in the package.json file for individual Rush projects.
*/
"globalAllowedDeprecatedVersions": {
// "request": "*"
},
/**
* (THIS FIELD IS MACHINE GENERATED) The "globalPatchedDependencies" field is updated automatically
* by the `rush-pnpm patch-commit` command. It is a dictionary, where the key is an NPM package name
* and exact version, and the value is a relative path to the associated patch file.
*
* PNPM documentation: https://pnpm.io/package_json#pnpmpatcheddependencies
*/
"globalPatchedDependencies": {},
/**
* (USE AT YOUR OWN RISK) This is a free-form property bag that will be copied into
* the `common/temp/package.json` file that is generated by Rush during installation.
* This provides a way to experiment with new PNPM features. These settings will override
* any other Rush configuration associated with a given JSON field except for `.pnpmfile.cjs`.
*
* USAGE OF THIS SETTING IS NOT SUPPORTED BY THE RUSH MAINTAINERS AND MAY CAUSE RUSH
* TO MALFUNCTION. If you encounter a missing PNPM setting that you believe should
* be supported, please create a GitHub issue or PR. Note that Rush does not aim to
* support every possible PNPM setting, but rather to promote a battle-tested installation
* strategy that is known to provide a good experience for large teams with lots of projects.
*/
"unsupportedPackageJsonSettings": {
// "dependencies": {
// "not-a-good-practice": "*"
// },
// "scripts": {
// "do-something": "echo Also not a good practice"
// },
// "pnpm": { "futurePnpmFeature": true }
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
// DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush.
{
"preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f"
}
@@ -0,0 +1,29 @@
/**
* This configuration file manages Rush's plugin feature.
*/
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush-plugins.schema.json",
"plugins": [
/**
* Each item configures a plugin to be loaded by Rush.
*/
// {
// /**
// * The name of the NPM package that provides the plugin.
// */
// "packageName": "@scope/my-rush-plugin",
// /**
// * The name of the plugin. This can be found in the "pluginName"
// * field of the "rush-plugin-manifest.json" file in the NPM package folder.
// */
// "pluginName": "my-plugin-name",
// /**
// * The name of a Rush autoinstaller that will be used for installation, which
// * can be created using "rush init-autoinstaller". Add the plugin's NPM package
// * to the package.json "dependencies" of your autoinstaller, then run
// * "rush update-autoinstaller".
// */
// "autoinstallerName": "rush-plugins"
// }
]
}
@@ -0,0 +1,35 @@
/**
* This configuration file manages the experimental "subspaces" feature for Rush,
* which allows multiple PNPM lockfiles to be used in a single Rush workspace.
* For full documentation, please see https://rushjs.io
*/
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/subspaces.schema.json",
/**
* Set this flag to "true" to enable usage of subspaces.
*/
"subspacesEnabled": false,
/**
* (DEPRECATED) This is a temporary workaround for migrating from an earlier prototype
* of this feature: https://github.com/microsoft/rushstack/pull/3481
* It allows subspaces with only one project to store their config files in the project folder.
*/
"splitWorkspaceCompatibility": false,
/**
* When a command such as "rush update" is invoked without the "--subspace" or "--to"
* parameters, Rush will install all subspaces. In a huge monorepo with numerous subspaces,
* this would be extremely slow. Set "preventSelectingAllSubspaces" to true to avoid this
* mistake by always requiring selection parameters for commands such as "rush update".
*/
"preventSelectingAllSubspaces": false,
/**
* The list of subspace names, which should be lowercase alphanumeric words separated by
* hyphens, for example "my-subspace". The corresponding config files will have paths
* such as "common/config/subspaces/my-subspace/package-lock.yaml".
*/
"subspaceNames": []
}
@@ -0,0 +1,102 @@
/**
* This is configuration file is used for advanced publishing configurations with Rush.
* More documentation is available on the Rush website: https://rushjs.io
*/
/**
* A list of version policy definitions. A "version policy" is a custom package versioning
* strategy that affects "rush change", "rush version", and "rush publish". The strategy applies
* to a set of projects that are specified using the "versionPolicyName" field in rush.json.
*/
[
// {
// /**
// * (Required) Indicates the kind of version policy being defined ("lockStepVersion" or "individualVersion").
// *
// * The "lockStepVersion" mode specifies that the projects will use "lock-step versioning". This
// * strategy is appropriate for a set of packages that act as selectable components of a
// * unified product. The entire set of packages are always published together, and always share
// * the same NPM version number. When the packages depend on other packages in the set, the
// * SemVer range is usually restricted to a single version.
// */
// "definitionName": "lockStepVersion",
//
// /**
// * (Required) The name that will be used for the "versionPolicyName" field in rush.json.
// * This name is also used command-line parameters such as "--version-policy"
// * and "--to-version-policy".
// */
// "policyName": "MyBigFramework",
//
// /**
// * (Required) The current version. All packages belonging to the set should have this version
// * in the current branch. When bumping versions, Rush uses this to determine the next version.
// * (The "version" field in package.json is NOT considered.)
// */
// "version": "1.0.0",
//
// /**
// * (Required) The type of bump that will be performed when publishing the next release.
// * When creating a release branch in Git, this field should be updated according to the
// * type of release.
// *
// * Valid values are: "prerelease", "preminor", "minor", "patch", "major"
// */
// "nextBump": "prerelease",
//
// /**
// * (Optional) If specified, all packages in the set share a common CHANGELOG.md file.
// * This file is stored with the specified "main" project, which must be a member of the set.
// *
// * If this field is omitted, then a separate CHANGELOG.md file will be maintained for each
// * package in the set.
// */
// "mainProject": "my-app",
//
// /**
// * (Optional) If enabled, the "rush change" command will prompt the user for their email address
// * and include it in the JSON change files. If an organization maintains multiple repos, tracking
// * this contact information may be useful for a service that automatically upgrades packages and
// * needs to notify engineers whose change may be responsible for a downstream build break. It might
// * also be useful for crediting contributors. Rush itself does not do anything with the collected
// * email addresses. The default value is "false".
// */
// // "includeEmailInChangeFile": true
// },
//
{
/**
* (Required) Indicates the kind of version policy being defined ("lockStepVersion" or "individualVersion").
*
* The "individualVersion" mode specifies that the projects will use "individual versioning".
* This is the typical NPM model where each package has an independent version number
* and CHANGELOG.md file. Although a single CI definition is responsible for publishing the
* packages, they otherwise don't have any special relationship. The version bumping will
* depend on how developers answer the "rush change" questions for each package that
* is changed.
*/
"definitionName": "individualVersion",
"policyName": "Huly.core",
/**
* (Optional) This can be used to enforce that all packages in the set must share a common
* major version number, e.g. because they are from the same major release branch.
* It can also be used to discourage people from accidentally making "MAJOR" SemVer changes
* inappropriately. The minor/patch version parts will be bumped independently according
* to the types of changes made to each project, according to the "rush change" command.
*/
"lockedMajor": 0.7,
/**
* (Optional) When publishing is managed by Rush, by default the "rush change" command will
* request changes for any projects that are modified by a pull request. These change entries
* will produce a CHANGELOG.md file. If you author your CHANGELOG.md manually or announce updates
* in some other way, set "exemptFromRushChange" to true to tell "rush change" to ignore the projects
* belonging to this version policy.
*/
"exemptFromRushChange": false
// "includeEmailInChangeFile": true
}
]
@@ -0,0 +1,25 @@
#!/bin/sh
#
# This is an example Git hook for use with Rush. To enable this hook, rename this file
# to "commit-msg" and then run "rush install", which will copy it from common/git-hooks
# to the .git/hooks folder.
#
# TO LEARN MORE ABOUT GIT HOOKS
#
# The Git documentation is here: https://git-scm.com/docs/githooks
# Some helpful resources: https://githooks.com
#
# ABOUT THIS EXAMPLE
#
# The commit-msg hook is called by "git commit" with one argument, the name of the file
# that has the commit message. The hook should exit with non-zero status after issuing
# an appropriate message if it wants to stop the commit. The hook is allowed to edit
# the commit message file.
# This example enforces that commit message should contain a minimum amount of
# description text.
if [ `cat $1 | wc -w` -lt 3 ]; then
echo ""
echo "Invalid commit message: The message must contain at least 3 words."
exit 1
fi
@@ -0,0 +1,153 @@
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
const [, , inFile = 'coverage/lcov.info', outDir = 'coverage/html'] = process.argv
if (!fs.existsSync(inFile)) {
console.error('Input lcov not found:', inFile)
process.exit(1)
}
const lcovParse = require('lcov-parse')
const libCoverage = require('istanbul-lib-coverage')
const reports = require('istanbul-reports')
const libReport = require('istanbul-lib-report')
const data = fs.readFileSync(inFile, 'utf8')
// build repo file index to resolve source files
const root = process.cwd()
const ignoreDirs = new Set(['node_modules', '.git', 'coverage', 'lib', 'dist', 'types', '.rush', 'temp', 'pnpm-store'])
const repoFiles = []
function walk(dir) {
const items = fs.readdirSync(dir, { withFileTypes: true })
for (const it of items) {
if (it.isDirectory()) {
if (ignoreDirs.has(it.name)) continue
if (it.name.startsWith('.')) continue
try {
walk(path.join(dir, it.name))
} catch (e) {}
} else if (it.isFile()) {
repoFiles.push(path.join(dir, it.name))
}
}
}
try {
walk(root)
} catch (e) {}
lcovParse(data, (err, parsed) => {
if (err) {
console.error('lcov-parse error:', err)
process.exit(1)
}
const map = libCoverage.createCoverageMap({})
for (const file of parsed) {
// parsed entries include 'file', 'lines', 'functions', 'branches'
const coverage = {
path: file.file,
statementMap: {},
fnMap: {},
branchMap: {},
s: {},
f: {},
b: {}
}
// The lcov parser gives line coverage data; create synthetic statement entries per line
if (file.lines && file.lines.details) {
let idx = 0
for (const d of file.lines.details) {
idx++
const key = String(idx)
coverage.statementMap[key] = { start: { line: d.line, column: 0 }, end: { line: d.line, column: 0 } }
coverage.s[key] = d.hit
}
}
// functions and branches are ignored for more accurate tools; keep minimal
map.addFileCoverage(coverage)
}
// custom source finder: try absolute, repo-relative, and suffix matches
const sourceFinder = (filePath) => {
try {
if (!global.__seenPaths) global.__seenPaths = []
if (global.__seenPaths.length < 500) global.__seenPaths.push(filePath)
if (global.__seenPaths.length === 500 && !global.__seenLogged) {
console.error('sourceFinder seen paths (sample):\n', global.__seenPaths.join('\n'))
global.__seenLogged = true
}
if (global.__seenPaths.length <= 200) console.error('sourceFinder request:', filePath)
} catch (e) {}
try {
if (path.isAbsolute(filePath) && fs.existsSync(filePath)) return fs.readFileSync(filePath, 'utf8')
const abs1 = path.resolve(root, filePath)
if (fs.existsSync(abs1)) return fs.readFileSync(abs1, 'utf8')
// try suffix match
const found = repoFiles.find((p) => p.endsWith(path.sep + filePath) || p.endsWith(filePath))
if (found) return fs.readFileSync(found, 'utf8')
// debug unresolved
if (!found) {
try {
if (!global.__unresolved) global.__unresolved = new Set()
if (global.__unresolved.size < 200) global.__unresolved.add(filePath)
} catch (e) {}
}
} catch (e) {
// ignore and return null below
}
return null
}
const context = libReport.createContext({ dir: outDir, coverageMap: map, sourceFinder })
const report = reports.create('html', {})
report.execute(context)
if (global.__unresolved && global.__unresolved.size) {
console.error('Unresolved filePath samples:\n', Array.from(global.__unresolved).slice(0, 50).join('\n'))
}
console.log('HTML report generated in', outDir)
// Post-process HTML files: if any report page contains the 'Unable to lookup source' placeholder,
// replace it with the actual source file contents when we can resolve it.
try {
for (const file of parsed) {
const srcAbs = file.file
// normalize key as used by report (from last '/src/' onward) if present
let key
const idx = srcAbs.lastIndexOf(path.sep + 'src' + path.sep)
if (idx !== -1) key = srcAbs.slice(idx + 1)
else key = path.basename(srcAbs)
const htmlPath = path.join(outDir, key + '.html')
if (!fs.existsSync(htmlPath)) continue
let html = fs.readFileSync(htmlPath, 'utf8')
if (!html.includes('Unable to lookup source')) continue
// read source
let src
try {
src = fs.readFileSync(srcAbs, 'utf8')
} catch (e) {
src = null
}
if (!src) {
// try suffix match in repoFiles
const found = repoFiles.find((p) => p.endsWith(path.sep + key) || p.endsWith(key))
if (found) src = fs.readFileSync(found, 'utf8')
}
if (!src) continue
// escape HTML
const esc = src.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// replace the first prettyprint <pre>...</pre> block that contains 'Unable to lookup source'
html = html.replace(
/<pre class="prettyprint[\s\S]*?>[\s\S]*?Unable to lookup source:[\s\S]*?<\/pre>/,
`<pre class="prettyprint lang-js">${esc}</pre>`
)
fs.writeFileSync(htmlPath, html, 'utf8')
}
} catch (e) {
console.error('post-process html error', e)
}
})
@@ -0,0 +1,31 @@
// THIS FILE WAS GENERATED BY A TOOL. ANY MANUAL MODIFICATIONS WILL GET OVERWRITTEN WHENEVER RUSH IS UPGRADED.
//
// This script is intended for usage in an automated build environment where the Rush command may not have
// been preinstalled, or may have an unpredictable version. This script will automatically install the version of Rush
// specified in the rush.json configuration file (if not already installed), and then pass a command-line to the
// rush-pnpm command.
//
// An example usage would be:
//
// node common/scripts/install-run-rush-pnpm.js pnpm-command
//
// For more information, see: https://rushjs.io/pages/maintainer/setup_new_repo/
//
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See the @microsoft/rush package's LICENSE file for details.
/******/ (() => { // webpackBootstrap
/******/ "use strict";
var __webpack_exports__ = {};
/*!*****************************************************!*\
!*** ./lib-esnext/scripts/install-run-rush-pnpm.js ***!
\*****************************************************/
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
require('./install-run-rush');
//# sourceMappingURL=install-run-rush-pnpm.js.map
module.exports = __webpack_exports__;
/******/ })()
;
//# sourceMappingURL=install-run-rush-pnpm.js.map
@@ -0,0 +1,218 @@
// THIS FILE WAS GENERATED BY A TOOL. ANY MANUAL MODIFICATIONS WILL GET OVERWRITTEN WHENEVER RUSH IS UPGRADED.
//
// This script is intended for usage in an automated build environment where the Rush command may not have
// been preinstalled, or may have an unpredictable version. This script will automatically install the version of Rush
// specified in the rush.json configuration file (if not already installed), and then pass a command-line to it.
// An example usage would be:
//
// node common/scripts/install-run-rush.js install
//
// For more information, see: https://rushjs.io/pages/maintainer/setup_new_repo/
//
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See the @microsoft/rush package's LICENSE file for details.
/******/ (() => { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ({
/***/ 16928:
/*!***********************!*\
!*** external "path" ***!
\***********************/
/***/ ((module) => {
module.exports = require("path");
/***/ }),
/***/ 179896:
/*!*********************!*\
!*** external "fs" ***!
\*********************/
/***/ ((module) => {
module.exports = require("fs");
/***/ })
/******/ });
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/************************************************************************/
/******/ /* webpack/runtime/compat get default export */
/******/ (() => {
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = (module) => {
/******/ var getter = module && module.__esModule ?
/******/ () => (module['default']) :
/******/ () => (module);
/******/ __webpack_require__.d(getter, { a: getter });
/******/ return getter;
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/define property getters */
/******/ (() => {
/******/ // define getter functions for harmony exports
/******/ __webpack_require__.d = (exports, definition) => {
/******/ for(var key in definition) {
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ }
/******/ }
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ (() => {
/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ })();
/******/
/******/ /* webpack/runtime/make namespace object */
/******/ (() => {
/******/ // define __esModule on exports
/******/ __webpack_require__.r = (exports) => {
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ }
/******/ Object.defineProperty(exports, '__esModule', { value: true });
/******/ };
/******/ })();
/******/
/************************************************************************/
var __webpack_exports__ = {};
// This entry needs to be wrapped in an IIFE because it needs to be isolated against other modules in the chunk.
(() => {
/*!************************************************!*\
!*** ./lib-esnext/scripts/install-run-rush.js ***!
\************************************************/
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! path */ 16928);
/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_0__);
/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! fs */ 179896);
/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_1__);
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
/* eslint-disable no-console */
const { installAndRun, findRushJsonFolder, RUSH_JSON_FILENAME, runWithErrorAndStatusCode } = require('./install-run');
const PACKAGE_NAME = '@microsoft/rush';
const RUSH_PREVIEW_VERSION = 'RUSH_PREVIEW_VERSION';
const INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE = 'INSTALL_RUN_RUSH_LOCKFILE_PATH';
function _getRushVersion(logger) {
const rushPreviewVersion = process.env[RUSH_PREVIEW_VERSION];
if (rushPreviewVersion !== undefined) {
logger.info(`Using Rush version from environment variable ${RUSH_PREVIEW_VERSION}=${rushPreviewVersion}`);
return rushPreviewVersion;
}
const rushJsonFolder = findRushJsonFolder();
const rushJsonPath = path__WEBPACK_IMPORTED_MODULE_0__.join(rushJsonFolder, RUSH_JSON_FILENAME);
try {
const rushJsonContents = fs__WEBPACK_IMPORTED_MODULE_1__.readFileSync(rushJsonPath, 'utf-8');
// Use a regular expression to parse out the rushVersion value because rush.json supports comments,
// but JSON.parse does not and we don't want to pull in more dependencies than we need to in this script.
const rushJsonMatches = rushJsonContents.match(/\"rushVersion\"\s*\:\s*\"([0-9a-zA-Z.+\-]+)\"/);
return rushJsonMatches[1];
}
catch (e) {
throw new Error(`Unable to determine the required version of Rush from ${RUSH_JSON_FILENAME} (${rushJsonFolder}). ` +
`The 'rushVersion' field is either not assigned in ${RUSH_JSON_FILENAME} or was specified ` +
'using an unexpected syntax.');
}
}
function _getBin(scriptName) {
switch (scriptName.toLowerCase()) {
case 'install-run-rush-pnpm.js':
return 'rush-pnpm';
case 'install-run-rushx.js':
return 'rushx';
default:
return 'rush';
}
}
function _run() {
const [nodePath /* Ex: /bin/node */, scriptPath /* /repo/common/scripts/install-run-rush.js */, ...packageBinArgs /* [build, --to, myproject] */] = process.argv;
// Detect if this script was directly invoked, or if the install-run-rushx script was invokved to select the
// appropriate binary inside the rush package to run
const scriptName = path__WEBPACK_IMPORTED_MODULE_0__.basename(scriptPath);
const bin = _getBin(scriptName);
if (!nodePath || !scriptPath) {
throw new Error('Unexpected exception: could not detect node path or script path');
}
let commandFound = false;
let logger = { info: console.log, error: console.error };
for (const arg of packageBinArgs) {
if (arg === '-q' || arg === '--quiet') {
// The -q/--quiet flag is supported by both `rush` and `rushx`, and will suppress
// any normal informational/diagnostic information printed during startup.
//
// To maintain the same user experience, the install-run* scripts pass along this
// flag but also use it to suppress any diagnostic information normally printed
// to stdout.
logger = {
info: () => { },
error: console.error
};
}
else if (!arg.startsWith('-') || arg === '-h' || arg === '--help') {
// We either found something that looks like a command (i.e. - doesn't start with a "-"),
// or we found the -h/--help flag, which can be run without a command
commandFound = true;
}
}
if (!commandFound) {
console.log(`Usage: ${scriptName} <command> [args...]`);
if (scriptName === 'install-run-rush-pnpm.js') {
console.log(`Example: ${scriptName} pnpm-command`);
}
else if (scriptName === 'install-run-rush.js') {
console.log(`Example: ${scriptName} build --to myproject`);
}
else {
console.log(`Example: ${scriptName} custom-command`);
}
process.exit(1);
}
runWithErrorAndStatusCode(logger, () => {
const version = _getRushVersion(logger);
logger.info(`The ${RUSH_JSON_FILENAME} configuration requests Rush version ${version}`);
const lockFilePath = process.env[INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE];
if (lockFilePath) {
logger.info(`Found ${INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE}="${lockFilePath}", installing with lockfile.`);
}
return installAndRun(logger, PACKAGE_NAME, version, bin, packageBinArgs, lockFilePath);
});
}
_run();
//# sourceMappingURL=install-run-rush.js.map
})();
module.exports = __webpack_exports__;
/******/ })()
;
//# sourceMappingURL=install-run-rush.js.map
@@ -0,0 +1,31 @@
// THIS FILE WAS GENERATED BY A TOOL. ANY MANUAL MODIFICATIONS WILL GET OVERWRITTEN WHENEVER RUSH IS UPGRADED.
//
// This script is intended for usage in an automated build environment where the Rush command may not have
// been preinstalled, or may have an unpredictable version. This script will automatically install the version of Rush
// specified in the rush.json configuration file (if not already installed), and then pass a command-line to the
// rushx command.
//
// An example usage would be:
//
// node common/scripts/install-run-rushx.js custom-command
//
// For more information, see: https://rushjs.io/pages/maintainer/setup_new_repo/
//
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See the @microsoft/rush package's LICENSE file for details.
/******/ (() => { // webpackBootstrap
/******/ "use strict";
var __webpack_exports__ = {};
/*!*************************************************!*\
!*** ./lib-esnext/scripts/install-run-rushx.js ***!
\*************************************************/
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
require('./install-run-rush');
//# sourceMappingURL=install-run-rushx.js.map
module.exports = __webpack_exports__;
/******/ })()
;
//# sourceMappingURL=install-run-rushx.js.map
@@ -0,0 +1,778 @@
// THIS FILE WAS GENERATED BY A TOOL. ANY MANUAL MODIFICATIONS WILL GET OVERWRITTEN WHENEVER RUSH IS UPGRADED.
//
// This script is intended for usage in an automated build environment where a Node tool may not have
// been preinstalled, or may have an unpredictable version. This script will automatically install the specified
// version of the specified tool (if not already installed), and then pass a command-line to it.
// An example usage would be:
//
// node common/scripts/install-run.js qrcode@1.2.2 qrcode https://rushjs.io
//
// For more information, see: https://rushjs.io/pages/maintainer/setup_new_repo/
//
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See the @microsoft/rush package's LICENSE file for details.
/******/ (() => { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ({
/***/ 16928:
/*!***********************!*\
!*** external "path" ***!
\***********************/
/***/ ((module) => {
module.exports = require("path");
/***/ }),
/***/ 179896:
/*!*********************!*\
!*** external "fs" ***!
\*********************/
/***/ ((module) => {
module.exports = require("fs");
/***/ }),
/***/ 370857:
/*!*********************!*\
!*** external "os" ***!
\*********************/
/***/ ((module) => {
module.exports = require("os");
/***/ }),
/***/ 535317:
/*!********************************!*\
!*** external "child_process" ***!
\********************************/
/***/ ((module) => {
module.exports = require("child_process");
/***/ }),
/***/ 832286:
/*!************************************************!*\
!*** ./lib-esnext/utilities/npmrcUtilities.js ***!
\************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ isVariableSetInNpmrcFile: () => (/* binding */ isVariableSetInNpmrcFile),
/* harmony export */ syncNpmrc: () => (/* binding */ syncNpmrc),
/* harmony export */ trimNpmrcFileLines: () => (/* binding */ trimNpmrcFileLines)
/* harmony export */ });
/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! fs */ 179896);
/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_0__);
/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! path */ 16928);
/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_1__);
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
// IMPORTANT - do not use any non-built-in libraries in this file
/**
* This function reads the content for given .npmrc file path, and also trims
* unusable lines from the .npmrc file.
*
* @returns
* The text of the the .npmrc.
*/
// create a global _combinedNpmrc for cache purpose
const _combinedNpmrcMap = new Map();
function _trimNpmrcFile(options) {
const { sourceNpmrcPath, linesToPrepend, linesToAppend, supportEnvVarFallbackSyntax } = options;
const combinedNpmrcFromCache = _combinedNpmrcMap.get(sourceNpmrcPath);
if (combinedNpmrcFromCache !== undefined) {
return combinedNpmrcFromCache;
}
let npmrcFileLines = [];
if (linesToPrepend) {
npmrcFileLines.push(...linesToPrepend);
}
if (fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(sourceNpmrcPath)) {
npmrcFileLines.push(...fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(sourceNpmrcPath).toString().split('\n'));
}
if (linesToAppend) {
npmrcFileLines.push(...linesToAppend);
}
npmrcFileLines = npmrcFileLines.map((line) => (line || '').trim());
const resultLines = trimNpmrcFileLines(npmrcFileLines, process.env, supportEnvVarFallbackSyntax);
const combinedNpmrc = resultLines.join('\n');
//save the cache
_combinedNpmrcMap.set(sourceNpmrcPath, combinedNpmrc);
return combinedNpmrc;
}
/**
*
* @param npmrcFileLines The npmrc file's lines
* @param env The environment variables object
* @param supportEnvVarFallbackSyntax Whether to support fallback values in the form of `${VAR_NAME:-fallback}`
* @returns
*/
function trimNpmrcFileLines(npmrcFileLines, env, supportEnvVarFallbackSyntax) {
var _a;
const resultLines = [];
// This finds environment variable tokens that look like "${VAR_NAME}"
const expansionRegExp = /\$\{([^\}]+)\}/g;
// Comment lines start with "#" or ";"
const commentRegExp = /^\s*[#;]/;
// Trim out lines that reference environment variables that aren't defined
for (let line of npmrcFileLines) {
let lineShouldBeTrimmed = false;
//remove spaces before or after key and value
line = line
.split('=')
.map((lineToTrim) => lineToTrim.trim())
.join('=');
// Ignore comment lines
if (!commentRegExp.test(line)) {
const environmentVariables = line.match(expansionRegExp);
if (environmentVariables) {
for (const token of environmentVariables) {
/**
* Remove the leading "${" and the trailing "}" from the token
*
* ${nameString} -> nameString
* ${nameString-fallbackString} -> name-fallbackString
* ${nameString:-fallbackString} -> name:-fallbackString
*/
const nameWithFallback = token.substring(2, token.length - 1);
let environmentVariableName;
let fallback;
if (supportEnvVarFallbackSyntax) {
/**
* Get the environment variable name and fallback value.
*
* name fallback
* nameString -> nameString undefined
* nameString-fallbackString -> nameString fallbackString
* nameString:-fallbackString -> nameString fallbackString
*/
const matched = nameWithFallback.match(/^([^:-]+)(?:\:?-(.+))?$/);
// matched: [originStr, variableName, fallback]
environmentVariableName = (_a = matched === null || matched === void 0 ? void 0 : matched[1]) !== null && _a !== void 0 ? _a : nameWithFallback;
fallback = matched === null || matched === void 0 ? void 0 : matched[2];
}
else {
environmentVariableName = nameWithFallback;
}
// Is the environment variable and fallback value defined.
if (!env[environmentVariableName] && !fallback) {
// No, so trim this line
lineShouldBeTrimmed = true;
break;
}
}
}
}
if (lineShouldBeTrimmed) {
// Example output:
// "; MISSING ENVIRONMENT VARIABLE: //my-registry.com/npm/:_authToken=${MY_AUTH_TOKEN}"
resultLines.push('; MISSING ENVIRONMENT VARIABLE: ' + line);
}
else {
resultLines.push(line);
}
}
return resultLines;
}
function _copyAndTrimNpmrcFile(options) {
const { logger, sourceNpmrcPath, targetNpmrcPath } = options;
logger.info(`Transforming ${sourceNpmrcPath}`); // Verbose
logger.info(` --> "${targetNpmrcPath}"`);
const combinedNpmrc = _trimNpmrcFile(options);
fs__WEBPACK_IMPORTED_MODULE_0__.writeFileSync(targetNpmrcPath, combinedNpmrc);
return combinedNpmrc;
}
function syncNpmrc(options) {
const { sourceNpmrcFolder, targetNpmrcFolder, useNpmrcPublish, logger = {
// eslint-disable-next-line no-console
info: console.log,
// eslint-disable-next-line no-console
error: console.error
}, createIfMissing = false } = options;
const sourceNpmrcPath = path__WEBPACK_IMPORTED_MODULE_1__.join(sourceNpmrcFolder, !useNpmrcPublish ? '.npmrc' : '.npmrc-publish');
const targetNpmrcPath = path__WEBPACK_IMPORTED_MODULE_1__.join(targetNpmrcFolder, '.npmrc');
try {
if (fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(sourceNpmrcPath) || createIfMissing) {
// Ensure the target folder exists
if (!fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(targetNpmrcFolder)) {
fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(targetNpmrcFolder, { recursive: true });
}
return _copyAndTrimNpmrcFile({
sourceNpmrcPath,
targetNpmrcPath,
logger,
...options
});
}
else if (fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(targetNpmrcPath)) {
// If the source .npmrc doesn't exist and there is one in the target, delete the one in the target
logger.info(`Deleting ${targetNpmrcPath}`); // Verbose
fs__WEBPACK_IMPORTED_MODULE_0__.unlinkSync(targetNpmrcPath);
}
}
catch (e) {
throw new Error(`Error syncing .npmrc file: ${e}`);
}
}
function isVariableSetInNpmrcFile(sourceNpmrcFolder, variableKey, supportEnvVarFallbackSyntax) {
const sourceNpmrcPath = `${sourceNpmrcFolder}/.npmrc`;
//if .npmrc file does not exist, return false directly
if (!fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(sourceNpmrcPath)) {
return false;
}
const trimmedNpmrcFile = _trimNpmrcFile({ sourceNpmrcPath, supportEnvVarFallbackSyntax });
const variableKeyRegExp = new RegExp(`^${variableKey}=`, 'm');
return trimmedNpmrcFile.match(variableKeyRegExp) !== null;
}
//# sourceMappingURL=npmrcUtilities.js.map
/***/ })
/******/ });
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/************************************************************************/
/******/ /* webpack/runtime/compat get default export */
/******/ (() => {
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = (module) => {
/******/ var getter = module && module.__esModule ?
/******/ () => (module['default']) :
/******/ () => (module);
/******/ __webpack_require__.d(getter, { a: getter });
/******/ return getter;
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/define property getters */
/******/ (() => {
/******/ // define getter functions for harmony exports
/******/ __webpack_require__.d = (exports, definition) => {
/******/ for(var key in definition) {
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ }
/******/ }
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ (() => {
/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ })();
/******/
/******/ /* webpack/runtime/make namespace object */
/******/ (() => {
/******/ // define __esModule on exports
/******/ __webpack_require__.r = (exports) => {
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ }
/******/ Object.defineProperty(exports, '__esModule', { value: true });
/******/ };
/******/ })();
/******/
/************************************************************************/
var __webpack_exports__ = {};
// This entry needs to be wrapped in an IIFE because it needs to be isolated against other modules in the chunk.
(() => {
/*!*******************************************!*\
!*** ./lib-esnext/scripts/install-run.js ***!
\*******************************************/
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ RUSH_JSON_FILENAME: () => (/* binding */ RUSH_JSON_FILENAME),
/* harmony export */ findRushJsonFolder: () => (/* binding */ findRushJsonFolder),
/* harmony export */ getNpmPath: () => (/* binding */ getNpmPath),
/* harmony export */ installAndRun: () => (/* binding */ installAndRun),
/* harmony export */ runWithErrorAndStatusCode: () => (/* binding */ runWithErrorAndStatusCode)
/* harmony export */ });
/* harmony import */ var child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! child_process */ 535317);
/* harmony import */ var child_process__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(child_process__WEBPACK_IMPORTED_MODULE_0__);
/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! fs */ 179896);
/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_1__);
/* harmony import */ var os__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! os */ 370857);
/* harmony import */ var os__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(os__WEBPACK_IMPORTED_MODULE_2__);
/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! path */ 16928);
/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_3__);
/* harmony import */ var _utilities_npmrcUtilities__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ../utilities/npmrcUtilities */ 832286);
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
/* eslint-disable no-console */
const RUSH_JSON_FILENAME = 'rush.json';
const RUSH_TEMP_FOLDER_ENV_VARIABLE_NAME = 'RUSH_TEMP_FOLDER';
const INSTALL_RUN_LOCKFILE_PATH_VARIABLE = 'INSTALL_RUN_LOCKFILE_PATH';
const INSTALLED_FLAG_FILENAME = 'installed.flag';
const NODE_MODULES_FOLDER_NAME = 'node_modules';
const PACKAGE_JSON_FILENAME = 'package.json';
/**
* Parse a package specifier (in the form of name\@version) into name and version parts.
*/
function _parsePackageSpecifier(rawPackageSpecifier) {
rawPackageSpecifier = (rawPackageSpecifier || '').trim();
const separatorIndex = rawPackageSpecifier.lastIndexOf('@');
let name;
let version = undefined;
if (separatorIndex === 0) {
// The specifier starts with a scope and doesn't have a version specified
name = rawPackageSpecifier;
}
else if (separatorIndex === -1) {
// The specifier doesn't have a version
name = rawPackageSpecifier;
}
else {
name = rawPackageSpecifier.substring(0, separatorIndex);
version = rawPackageSpecifier.substring(separatorIndex + 1);
}
if (!name) {
throw new Error(`Invalid package specifier: ${rawPackageSpecifier}`);
}
return { name, version };
}
let _npmPath = undefined;
/**
* Get the absolute path to the npm executable
*/
function getNpmPath() {
if (!_npmPath) {
try {
if (_isWindows()) {
// We're on Windows
const whereOutput = child_process__WEBPACK_IMPORTED_MODULE_0__.execSync('where npm', { stdio: [] }).toString();
const lines = whereOutput.split(os__WEBPACK_IMPORTED_MODULE_2__.EOL).filter((line) => !!line);
// take the last result, we are looking for a .cmd command
// see https://github.com/microsoft/rushstack/issues/759
_npmPath = lines[lines.length - 1];
}
else {
// We aren't on Windows - assume we're on *NIX or Darwin
_npmPath = child_process__WEBPACK_IMPORTED_MODULE_0__.execSync('command -v npm', { stdio: [] }).toString();
}
}
catch (e) {
throw new Error(`Unable to determine the path to the NPM tool: ${e}`);
}
_npmPath = _npmPath.trim();
if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(_npmPath)) {
throw new Error('The NPM executable does not exist');
}
}
return _npmPath;
}
function _ensureFolder(folderPath) {
if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(folderPath)) {
const parentDir = path__WEBPACK_IMPORTED_MODULE_3__.dirname(folderPath);
_ensureFolder(parentDir);
fs__WEBPACK_IMPORTED_MODULE_1__.mkdirSync(folderPath);
}
}
/**
* Create missing directories under the specified base directory, and return the resolved directory.
*
* Does not support "." or ".." path segments.
* Assumes the baseFolder exists.
*/
function _ensureAndJoinPath(baseFolder, ...pathSegments) {
let joinedPath = baseFolder;
try {
for (let pathSegment of pathSegments) {
pathSegment = pathSegment.replace(/[\\\/]/g, '+');
joinedPath = path__WEBPACK_IMPORTED_MODULE_3__.join(joinedPath, pathSegment);
if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(joinedPath)) {
fs__WEBPACK_IMPORTED_MODULE_1__.mkdirSync(joinedPath);
}
}
}
catch (e) {
throw new Error(`Error building local installation folder (${path__WEBPACK_IMPORTED_MODULE_3__.join(baseFolder, ...pathSegments)}): ${e}`);
}
return joinedPath;
}
function _getRushTempFolder(rushCommonFolder) {
const rushTempFolder = process.env[RUSH_TEMP_FOLDER_ENV_VARIABLE_NAME];
if (rushTempFolder !== undefined) {
_ensureFolder(rushTempFolder);
return rushTempFolder;
}
else {
return _ensureAndJoinPath(rushCommonFolder, 'temp');
}
}
/**
* Compare version strings according to semantic versioning.
* Returns a positive integer if "a" is a later version than "b",
* a negative integer if "b" is later than "a",
* and 0 otherwise.
*/
function _compareVersionStrings(a, b) {
const aParts = a.split(/[.-]/);
const bParts = b.split(/[.-]/);
const numberOfParts = Math.max(aParts.length, bParts.length);
for (let i = 0; i < numberOfParts; i++) {
if (aParts[i] !== bParts[i]) {
return (Number(aParts[i]) || 0) - (Number(bParts[i]) || 0);
}
}
return 0;
}
/**
* Resolve a package specifier to a static version
*/
function _resolvePackageVersion(logger, rushCommonFolder, { name, version }) {
if (!version) {
version = '*'; // If no version is specified, use the latest version
}
if (version.match(/^[a-zA-Z0-9\-\+\.]+$/)) {
// If the version contains only characters that we recognize to be used in static version specifiers,
// pass the version through
return version;
}
else {
// version resolves to
try {
const rushTempFolder = _getRushTempFolder(rushCommonFolder);
const sourceNpmrcFolder = path__WEBPACK_IMPORTED_MODULE_3__.join(rushCommonFolder, 'config', 'rush');
(0,_utilities_npmrcUtilities__WEBPACK_IMPORTED_MODULE_4__.syncNpmrc)({
sourceNpmrcFolder,
targetNpmrcFolder: rushTempFolder,
logger,
supportEnvVarFallbackSyntax: false
});
const npmPath = getNpmPath();
// This returns something that looks like:
// ```
// [
// "3.0.0",
// "3.0.1",
// ...
// "3.0.20"
// ]
// ```
//
// if multiple versions match the selector, or
//
// ```
// "3.0.0"
// ```
//
// if only a single version matches.
const spawnSyncOptions = {
cwd: rushTempFolder,
stdio: [],
shell: _isWindows()
};
const platformNpmPath = _getPlatformPath(npmPath);
const npmVersionSpawnResult = child_process__WEBPACK_IMPORTED_MODULE_0__.spawnSync(platformNpmPath, ['view', `${name}@${version}`, 'version', '--no-update-notifier', '--json'], spawnSyncOptions);
if (npmVersionSpawnResult.status !== 0) {
throw new Error(`"npm view" returned error code ${npmVersionSpawnResult.status}`);
}
const npmViewVersionOutput = npmVersionSpawnResult.stdout.toString();
const parsedVersionOutput = JSON.parse(npmViewVersionOutput);
const versions = Array.isArray(parsedVersionOutput)
? parsedVersionOutput
: [parsedVersionOutput];
let latestVersion = versions[0];
for (let i = 1; i < versions.length; i++) {
const latestVersionCandidate = versions[i];
if (_compareVersionStrings(latestVersionCandidate, latestVersion) > 0) {
latestVersion = latestVersionCandidate;
}
}
if (!latestVersion) {
throw new Error('No versions found for the specified version range.');
}
return latestVersion;
}
catch (e) {
throw new Error(`Unable to resolve version ${version} of package ${name}: ${e}`);
}
}
}
let _rushJsonFolder;
/**
* Find the absolute path to the folder containing rush.json
*/
function findRushJsonFolder() {
if (!_rushJsonFolder) {
let basePath = __dirname;
let tempPath = __dirname;
do {
const testRushJsonPath = path__WEBPACK_IMPORTED_MODULE_3__.join(basePath, RUSH_JSON_FILENAME);
if (fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(testRushJsonPath)) {
_rushJsonFolder = basePath;
break;
}
else {
basePath = tempPath;
}
} while (basePath !== (tempPath = path__WEBPACK_IMPORTED_MODULE_3__.dirname(basePath))); // Exit the loop when we hit the disk root
if (!_rushJsonFolder) {
throw new Error(`Unable to find ${RUSH_JSON_FILENAME}.`);
}
}
return _rushJsonFolder;
}
/**
* Detects if the package in the specified directory is installed
*/
function _isPackageAlreadyInstalled(packageInstallFolder) {
try {
const flagFilePath = path__WEBPACK_IMPORTED_MODULE_3__.join(packageInstallFolder, INSTALLED_FLAG_FILENAME);
if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(flagFilePath)) {
return false;
}
const fileContents = fs__WEBPACK_IMPORTED_MODULE_1__.readFileSync(flagFilePath).toString();
return fileContents.trim() === process.version;
}
catch (e) {
return false;
}
}
/**
* Delete a file. Fail silently if it does not exist.
*/
function _deleteFile(file) {
try {
fs__WEBPACK_IMPORTED_MODULE_1__.unlinkSync(file);
}
catch (err) {
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') {
throw err;
}
}
}
/**
* Removes the following files and directories under the specified folder path:
* - installed.flag
* -
* - node_modules
*/
function _cleanInstallFolder(rushTempFolder, packageInstallFolder, lockFilePath) {
try {
const flagFile = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, INSTALLED_FLAG_FILENAME);
_deleteFile(flagFile);
const packageLockFile = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, 'package-lock.json');
if (lockFilePath) {
fs__WEBPACK_IMPORTED_MODULE_1__.copyFileSync(lockFilePath, packageLockFile);
}
else {
// Not running `npm ci`, so need to cleanup
_deleteFile(packageLockFile);
const nodeModulesFolder = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, NODE_MODULES_FOLDER_NAME);
if (fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(nodeModulesFolder)) {
const rushRecyclerFolder = _ensureAndJoinPath(rushTempFolder, 'rush-recycler');
fs__WEBPACK_IMPORTED_MODULE_1__.renameSync(nodeModulesFolder, path__WEBPACK_IMPORTED_MODULE_3__.join(rushRecyclerFolder, `install-run-${Date.now().toString()}`));
}
}
}
catch (e) {
throw new Error(`Error cleaning the package install folder (${packageInstallFolder}): ${e}`);
}
}
function _createPackageJson(packageInstallFolder, name, version) {
try {
const packageJsonContents = {
name: 'ci-rush',
version: '0.0.0',
dependencies: {
[name]: version
},
description: "DON'T WARN",
repository: "DON'T WARN",
license: 'MIT'
};
const packageJsonPath = path__WEBPACK_IMPORTED_MODULE_3__.join(packageInstallFolder, PACKAGE_JSON_FILENAME);
fs__WEBPACK_IMPORTED_MODULE_1__.writeFileSync(packageJsonPath, JSON.stringify(packageJsonContents, undefined, 2));
}
catch (e) {
throw new Error(`Unable to create package.json: ${e}`);
}
}
/**
* Run "npm install" in the package install folder.
*/
function _installPackage(logger, packageInstallFolder, name, version, command) {
try {
logger.info(`Installing ${name}...`);
const npmPath = getNpmPath();
const platformNpmPath = _getPlatformPath(npmPath);
const result = child_process__WEBPACK_IMPORTED_MODULE_0__.spawnSync(platformNpmPath, [command], {
stdio: 'inherit',
cwd: packageInstallFolder,
env: process.env,
shell: _isWindows()
});
if (result.status !== 0) {
throw new Error(`"npm ${command}" encountered an error`);
}
logger.info(`Successfully installed ${name}@${version}`);
}
catch (e) {
throw new Error(`Unable to install package: ${e}`);
}
}
/**
* Get the ".bin" path for the package.
*/
function _getBinPath(packageInstallFolder, binName) {
const binFolderPath = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, NODE_MODULES_FOLDER_NAME, '.bin');
const resolvedBinName = _isWindows() ? `${binName}.cmd` : binName;
return path__WEBPACK_IMPORTED_MODULE_3__.resolve(binFolderPath, resolvedBinName);
}
/**
* Returns a cross-platform path - windows must enclose any path containing spaces within double quotes.
*/
function _getPlatformPath(platformPath) {
return _isWindows() && platformPath.includes(' ') ? `"${platformPath}"` : platformPath;
}
function _isWindows() {
return os__WEBPACK_IMPORTED_MODULE_2__.platform() === 'win32';
}
/**
* Write a flag file to the package's install directory, signifying that the install was successful.
*/
function _writeFlagFile(packageInstallFolder) {
try {
const flagFilePath = path__WEBPACK_IMPORTED_MODULE_3__.join(packageInstallFolder, INSTALLED_FLAG_FILENAME);
fs__WEBPACK_IMPORTED_MODULE_1__.writeFileSync(flagFilePath, process.version);
}
catch (e) {
throw new Error(`Unable to create installed.flag file in ${packageInstallFolder}`);
}
}
function installAndRun(logger, packageName, packageVersion, packageBinName, packageBinArgs, lockFilePath = process.env[INSTALL_RUN_LOCKFILE_PATH_VARIABLE]) {
const rushJsonFolder = findRushJsonFolder();
const rushCommonFolder = path__WEBPACK_IMPORTED_MODULE_3__.join(rushJsonFolder, 'common');
const rushTempFolder = _getRushTempFolder(rushCommonFolder);
const packageInstallFolder = _ensureAndJoinPath(rushTempFolder, 'install-run', `${packageName}@${packageVersion}`);
if (!_isPackageAlreadyInstalled(packageInstallFolder)) {
// The package isn't already installed
_cleanInstallFolder(rushTempFolder, packageInstallFolder, lockFilePath);
const sourceNpmrcFolder = path__WEBPACK_IMPORTED_MODULE_3__.join(rushCommonFolder, 'config', 'rush');
(0,_utilities_npmrcUtilities__WEBPACK_IMPORTED_MODULE_4__.syncNpmrc)({
sourceNpmrcFolder,
targetNpmrcFolder: packageInstallFolder,
logger,
supportEnvVarFallbackSyntax: false
});
_createPackageJson(packageInstallFolder, packageName, packageVersion);
const command = lockFilePath ? 'ci' : 'install';
_installPackage(logger, packageInstallFolder, packageName, packageVersion, command);
_writeFlagFile(packageInstallFolder);
}
const statusMessage = `Invoking "${packageBinName} ${packageBinArgs.join(' ')}"`;
const statusMessageLine = new Array(statusMessage.length + 1).join('-');
logger.info('\n' + statusMessage + '\n' + statusMessageLine + '\n');
const binPath = _getBinPath(packageInstallFolder, packageBinName);
const binFolderPath = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, NODE_MODULES_FOLDER_NAME, '.bin');
// Windows environment variables are case-insensitive. Instead of using SpawnSyncOptions.env, we need to
// assign via the process.env proxy to ensure that we append to the right PATH key.
const originalEnvPath = process.env.PATH || '';
let result;
try {
// `npm` bin stubs on Windows are `.cmd` files
// Node.js will not directly invoke a `.cmd` file unless `shell` is set to `true`
const platformBinPath = _getPlatformPath(binPath);
process.env.PATH = [binFolderPath, originalEnvPath].join(path__WEBPACK_IMPORTED_MODULE_3__.delimiter);
result = child_process__WEBPACK_IMPORTED_MODULE_0__.spawnSync(platformBinPath, packageBinArgs, {
stdio: 'inherit',
windowsVerbatimArguments: false,
shell: _isWindows(),
cwd: process.cwd(),
env: process.env
});
}
finally {
process.env.PATH = originalEnvPath;
}
if (result.status !== null) {
return result.status;
}
else {
throw result.error || new Error('An unknown error occurred.');
}
}
function runWithErrorAndStatusCode(logger, fn) {
process.exitCode = 1;
try {
const exitCode = fn();
process.exitCode = exitCode;
}
catch (e) {
logger.error('\n\n' + e.toString() + '\n\n');
}
}
function _run() {
const [nodePath /* Ex: /bin/node */, scriptPath /* /repo/common/scripts/install-run-rush.js */, rawPackageSpecifier /* qrcode@^1.2.0 */, packageBinName /* qrcode */, ...packageBinArgs /* [-f, myproject/lib] */] = process.argv;
if (!nodePath) {
throw new Error('Unexpected exception: could not detect node path');
}
if (path__WEBPACK_IMPORTED_MODULE_3__.basename(scriptPath).toLowerCase() !== 'install-run.js') {
// If install-run.js wasn't directly invoked, don't execute the rest of this function. Return control
// to the script that (presumably) imported this file
return;
}
if (process.argv.length < 4) {
console.log('Usage: install-run.js <package>@<version> <command> [args...]');
console.log('Example: install-run.js qrcode@1.2.2 qrcode https://rushjs.io');
process.exit(1);
}
const logger = { info: console.log, error: console.error };
runWithErrorAndStatusCode(logger, () => {
const rushJsonFolder = findRushJsonFolder();
const rushCommonFolder = _ensureAndJoinPath(rushJsonFolder, 'common');
const packageSpecifier = _parsePackageSpecifier(rawPackageSpecifier);
const name = packageSpecifier.name;
const version = _resolvePackageVersion(logger, rushCommonFolder, packageSpecifier);
if (packageSpecifier.version !== version) {
console.log(`Resolved to ${name}@${version}`);
}
return installAndRun(logger, name, version, packageBinName, packageBinArgs);
});
}
_run();
//# sourceMappingURL=install-run.js.map
})();
module.exports = __webpack_exports__;
/******/ })()
;
//# sourceMappingURL=install-run.js.map
@@ -0,0 +1,106 @@
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
const root = process.cwd()
const patterns = ['packages', 'pods', 'tests']
let files = []
for (const p of patterns) {
const dir = path.join(root, p)
if (!fs.existsSync(dir)) continue
const items = fs.readdirSync(dir)
for (const it of items) {
const lcov = path.join(dir, it, 'coverage', 'lcov.info')
if (fs.existsSync(lcov)) files.push(lcov)
}
}
if (files.length === 0) {
console.error('No lcov files found in packages/pods/tests/*/coverage/lcov.info')
process.exit(1)
}
const outDir = path.join(root, 'coverage')
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true })
const outFile = path.join(outDir, 'lcov.info')
let outData = ''
let seenTN = false
// Build a repo file index to help resolve SF entries that are ambiguous
const ignoreDirs = new Set(['node_modules', '.git', 'coverage', 'lib', 'dist', 'types', '.rush', 'temp', 'pnpm-store'])
const repoFiles = []
function walk(dir) {
const items = fs.readdirSync(dir, { withFileTypes: true })
for (const it of items) {
if (it.isDirectory()) {
if (ignoreDirs.has(it.name)) continue
// skip hidden folders except top-level .config maybe
if (it.name.startsWith('.')) continue
try {
walk(path.join(dir, it.name))
} catch (e) {
// ignore permission errors
}
} else if (it.isFile()) {
repoFiles.push(path.join(dir, it.name))
}
}
}
try {
walk(root)
} catch (e) {
/* ignore */
}
for (const f of files) {
const data = fs.readFileSync(f, 'utf8')
const pkgDir = path.dirname(path.dirname(f))
const lines = data.split(/\r?\n/)
const outLines = []
for (const line of lines) {
if (!line) continue
// skip duplicate TN: headers (test name)
if (line.startsWith('TN:')) {
if (seenTN) continue
seenTN = true
outLines.push(line)
continue
}
if (line.startsWith('SF:')) {
const orig = line.slice(3)
// if path is absolute and exists, keep it; otherwise resolve from package dir
if (path.isAbsolute(orig)) {
outLines.push('SF:' + orig)
continue
}
const abs = path.resolve(pkgDir, orig)
if (fs.existsSync(abs)) {
outLines.push('SF:' + abs)
} else {
// try package/src/orig if orig is not already prefixed with src
const alt = path.resolve(pkgDir, orig)
if (fs.existsSync(alt)) {
outLines.push('SF:' + path.relative(root, alt))
} else {
// try to find any file in repo that ends with the orig path
const found = repoFiles.find((p) => p.endsWith(path.sep + orig) || p.endsWith(orig))
if (found) {
outLines.push('SF:' + found)
} else {
// keep original if we can't resolve
outLines.push('SF:' + orig)
}
}
}
continue
}
outLines.push(line)
}
outData += outLines.join('\n') + '\n'
}
fs.writeFileSync(outFile, outData, 'utf8')
console.log('Merged', files.length, 'lcov files into', outFile)
@@ -0,0 +1,19 @@
{
"name": "@hcengineering/scripts",
"version": "0.7.17",
"scripts": {
"format": "echo \"No format specified\""
},
"devDependencies": {
"@hcengineering/platform-rig": "^0.7.19",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-n": "^15.4.0",
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"eslint-plugin-svelte": "^2.35.1"
},
"private": true
}
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
echo "=== FINAL COVERAGE REPORT ==="
echo ""
# Iterate through each package directory
for pkg in packages/*/; do
pkgname=$(basename "$pkg")
echo "📦 Package: $pkgname"
echo "---"
# Change to package directory
cd "$pkg" || continue
# Run tests with coverage and extract summary
npm test -- --coverage --silent 2>&1 | \
grep -A 4 "Coverage summary" | \
grep -E "Statements|Branches|Functions|Lines"
# Return to root directory
cd ../.. || exit
echo ""
done
echo "=== END OF COVERAGE REPORT ==="
@@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
@@ -0,0 +1,4 @@
*
!/lib/**
!CHANGELOG.md
/lib/**/__tests__/
@@ -0,0 +1,152 @@
{
"name": "@hcengineering/account-client",
"entries": [
{
"version": "0.7.20",
"tag": "@hcengineering/account-client_v0.7.20",
"date": "Wed, 26 Nov 2025 15:28:11 GMT",
"comments": {
"patch": [
{
"comment": "Add password aging"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/core\" from `^0.7.22` to `0.7.23`"
},
{
"comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.18` to `0.7.19`"
}
]
}
},
{
"version": "0.7.19",
"tag": "@hcengineering/account-client_v0.7.19",
"date": "Thu, 30 Oct 2025 08:41:42 GMT",
"comments": {
"none": [
{
"comment": "formatting"
}
],
"patch": [
{
"comment": "add workspace usage info"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/core\" from `^0.7.19` to `0.7.20`"
}
]
}
},
{
"version": "0.7.18",
"tag": "@hcengineering/account-client_v0.7.18",
"date": "Tue, 28 Oct 2025 21:50:57 GMT",
"comments": {
"patch": [
{
"comment": "new sub methods"
}
]
}
},
{
"version": "0.7.17",
"tag": "@hcengineering/account-client_v0.7.17",
"date": "Mon, 27 Oct 2025 13:27:12 GMT",
"comments": {
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`"
}
]
}
},
{
"version": "0.7.7",
"tag": "@hcengineering/account-client_v0.7.7",
"date": "Wed, 22 Oct 2025 12:46:09 GMT",
"comments": {
"patch": [
{
"comment": "add subs methods"
}
]
}
},
{
"version": "0.7.6",
"tag": "@hcengineering/account-client_v0.7.6",
"date": "Wed, 15 Oct 2025 18:01:30 GMT",
"comments": {
"patch": [
{
"comment": "added methods to work with profile"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/core\" from `^0.7.7` to `0.7.8`"
}
]
}
},
{
"version": "0.7.5",
"tag": "@hcengineering/account-client_v0.7.5",
"date": "Tue, 14 Oct 2025 04:58:17 GMT",
"comments": {
"patch": [
{
"comment": "update deps"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`"
},
{
"comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.4` to `0.7.5`"
}
]
}
},
{
"version": "0.7.4",
"tag": "@hcengineering/account-client_v0.7.4",
"date": "Sat, 11 Oct 2025 18:20:33 GMT",
"comments": {
"patch": [
{
"comment": "Update to latest platform rig"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`"
},
{
"comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.3` to `0.7.4`"
}
]
}
},
{
"version": "0.7.3",
"tag": "@hcengineering/account-client_v0.7.3",
"date": "Wed, 08 Oct 2025 03:40:53 GMT",
"comments": {
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`"
}
]
}
}
]
}
@@ -0,0 +1,63 @@
# Change Log - @hcengineering/account-client
This log was last generated on Wed, 26 Nov 2025 15:28:11 GMT and should not be manually modified.
## 0.7.20
Wed, 26 Nov 2025 15:28:11 GMT
### Patches
- Add password aging
## 0.7.19
Thu, 30 Oct 2025 08:41:42 GMT
### Patches
- add workspace usage info
## 0.7.18
Tue, 28 Oct 2025 21:50:57 GMT
### Patches
- new sub methods
## 0.7.17
Mon, 27 Oct 2025 13:27:12 GMT
_Version update only_
## 0.7.7
Wed, 22 Oct 2025 12:46:09 GMT
### Patches
- add subs methods
## 0.7.6
Wed, 15 Oct 2025 18:01:30 GMT
### Patches
- added methods to work with profile
## 0.7.5
Tue, 14 Oct 2025 04:58:17 GMT
### Patches
- update deps
## 0.7.4
Sat, 11 Oct 2025 18:20:33 GMT
### Patches
- Update to latest platform rig
## 0.7.3
Wed, 08 Oct 2025 03:40:53 GMT
_Initial release_
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig"
}
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ['./src'],
coverageReporters: ['text-summary', 'html', 'lcov']
}
@@ -0,0 +1,60 @@
{
"name": "@hcengineering/account-client",
"version": "0.7.20",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"files": [
"lib/**/*",
"types/**/*",
"src/**/*",
"!src/**/__test__/**",
"tsconfig.json"
],
"author": "Hardcore Engineering Inc.",
"license": "EPL-2.0",
"scripts": {
"build": "compile",
"build:watch": "compile",
"format": "format src",
"test": "jest --passWithNoTests --silent --coverage",
"_phase:build": "compile transpile src",
"_phase:test": "jest --passWithNoTests --silent --coverage",
"_phase:format": "format src",
"_phase:validate": "compile validate"
},
"devDependencies": {
"cross-env": "~7.0.3",
"@hcengineering/platform-rig": "^0.7.19",
"@types/node": "^22.15.29",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-n": "^15.4.0",
"eslint": "^8.54.0",
"esbuild": "^0.25.9",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"typescript": "^5.9.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"@hcengineering/core": "workspace:^0.7.23",
"@hcengineering/platform": "workspace:^0.7.19"
},
"repository": "https://github.com/hcengineering/huly.core",
"publishConfig": {
"access": "public"
},
"exports": {
".": {
"types": "./types/index.d.ts",
"require": "./lib/index.js",
"import": "./lib/index.js"
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
//
// Copyright © 2024 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.
//
export * from './client'
export * from './types'
export * from './utils'
@@ -0,0 +1,236 @@
import {
type AccountUuid,
PersonId,
WorkspaceDataId,
WorkspaceUuid,
type AccountRole,
type Timestamp,
type SocialId as SocialIdBase,
PersonUuid,
type WorkspaceMode,
Person,
WorkspaceInfo,
AccountInfo,
IntegrationKind
} from '@hcengineering/core'
export interface LoginInfo {
account: AccountUuid
name?: string
socialId?: PersonId
token?: string
}
export interface EndpointInfo {
internalUrl: string
externalUrl: string
region: string
}
export interface WorkspaceVersion {
versionMajor: number
versionMinor: number
versionPatch: number
}
export interface LoginInfoWorkspace {
url: string
dataId?: WorkspaceDataId
mode: WorkspaceMode
version: WorkspaceVersion
endpoint: EndpointInfo
role: AccountRole | null
progress?: number
branding?: string
passwordAgingRule?: number // in days
}
export interface LoginInfoWithWorkspaces extends LoginInfo {
// Information necessary to handle user <--> transactor connectivity.
workspaces: Record<WorkspaceUuid, LoginInfoWorkspace>
socialIds: SocialId[]
}
export type LoginInfoByToken = LoginInfo | WorkspaceLoginInfo | LoginInfoRequest | null
/**
* @public
*/
export interface WorkspaceLoginInfo extends LoginInfo {
workspace: WorkspaceUuid // worspace uuid
workspaceDataId?: WorkspaceDataId
workspaceUrl: string
endpoint: string
token: string
role: AccountRole
allowGuestSignUp?: boolean
}
export interface LoginInfoRequestData {
firstName?: string
lastName?: string
}
export type LoginInfoRequest = {
request: true
} & LoginInfoRequestData
export interface WorkspaceInviteInfo {
workspace: WorkspaceUuid
email?: string
name?: string
}
export interface OtpInfo {
sent: boolean
retryOn: Timestamp
}
export interface RegionInfo {
region: string
name: string
}
export type WorkspaceOperation = 'create' | 'upgrade' | 'all' | 'all+backup'
export interface MailboxOptions {
availableDomains: string[]
minNameLength: number
maxNameLength: number
maxMailboxCount: number
}
export interface MailboxInfo {
mailbox: string
aliases: string[]
appPasswords: string[]
}
export interface MailboxSecret {
mailbox: string
app?: string
secret: string
}
export interface Integration {
socialId: PersonId
kind: IntegrationKind // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
workspaceUuid: WorkspaceUuid | null
data?: Record<string, any>
disabled?: boolean
}
export interface SocialId extends SocialIdBase {
personUuid: PersonUuid
isDeleted?: boolean
}
export type IntegrationKey = Omit<Integration, 'data'>
export interface IntegrationSecret {
socialId: PersonId
kind: IntegrationKind // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
workspaceUuid: WorkspaceUuid | null
key: string // Key for the secret in the integration. Different secrets for the same integration must have different keys. Can be any string. E.g. '', 'user_app_1' etc.
secret: string
}
export type IntegrationSecretKey = Omit<IntegrationSecret, 'secret'>
export interface ProviderInfo {
name: string
displayName?: string
}
export interface AccountAggregatedInfo extends AccountInfo, Person {
uuid: AccountUuid
integrations: Omit<Integration, 'data'>[]
socialIds: SocialId[]
workspaces: Omit<WorkspaceInfo, 'allowReadOnlyGuest' | 'allowGuestSignUp'>[]
}
/**
* User profile with additional information for public sharing
* Stored in accounts database (global, not workspace-specific)
*/
export interface UserProfile {
personUuid: PersonUuid
bio?: string // LinkedIn-style bio (up to ~2000 chars)
city?: string
country?: string
website?: string // Personal website URL
socialLinks?: Record<string, string> // Flexible storage for social links
isPublic: boolean // Public visibility toggle (default: false)
}
export type PersonWithProfile = Person & Omit<UserProfile, 'personUuid'>
/**
* Subscription status enum
* Reflects the subscription lifecycle from active to canceled/expired
*/
export enum SubscriptionStatus {
Active = 'active', // Subscription is active and paid
Trialing = 'trialing', // In trial period (free usage)
PastDue = 'past_due', // Payment failed but subscription not yet canceled
Canceled = 'canceled', // Subscription was canceled by user or admin
Paused = 'paused', // Subscription is temporarily paused (some providers support this)
Expired = 'expired' // Subscription or trial has expired
}
/**
* Subscription type/purpose
* Allows multiple active subscriptions per workspace for different purposes
*/
export enum SubscriptionType {
Tier = 'tier', // Main workspace tier (free, starter, pro, enterprise)
Support = 'support' // Voluntary support/donation subscription
}
/**
* Workspace subscription information
* Provider-agnostic subscription data managed by billing service
* Multiple subscriptions can be active per workspace (tier + addons + support)
* Historical subscriptions are preserved with status: canceled/expired
*/
export interface Subscription {
id: string // Our internal unique subscription ID (UUID)
workspaceUuid: WorkspaceUuid
accountUuid: AccountUuid // Account that paid for the subscription
// Provider details
provider: string // Payment provider identifier (e.g. 'polar', 'stripe', 'manual')
providerSubscriptionId: string // External subscription ID from the provider
providerCheckoutId?: string // External checkout/session ID that created this subscription
// Subscription classification
type: SubscriptionType // What this subscription is for (tier, addon, support)
status: SubscriptionStatus // Current status
plan: string // Plan/product identifier (e.g. 'free', 'pro', 'storage-100gb', 'supporter')
// Amount paid (in cents, e.g. 9999 = $99.99)
// Used primarily for pay-what-you-want/donation subscriptions to track actual payment
amount?: number
// Billing period (optional - not set for free/manual plans)
periodStart?: Timestamp
periodEnd?: Timestamp
// Trial information (optional)
trialEnd?: Timestamp
// Cancellation tracking (optional)
canceledAt?: Timestamp
// Provider-specific data stored as JSONB (optional)
providerData?: Record<string, any>
// Timestamps (managed by database)
createdOn: Timestamp
updatedOn: Timestamp
}
/**
* Subscription data for creating/updating subscriptions (without timestamps)
* Used by billing service to upsert subscription data
*/
export type SubscriptionData = Omit<Subscription, 'createdOn' | 'updatedOn'>
@@ -0,0 +1,33 @@
//
// Copyright © 2024 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 { LoginInfoByToken, LoginInfoRequest, WorkspaceLoginInfo } from './types'
export function isWorkspaceLoginInfo (loginInfo: LoginInfoByToken): loginInfo is WorkspaceLoginInfo {
return !isLoginInfoRequest(loginInfo) && (loginInfo as WorkspaceLoginInfo)?.workspace != null
}
export function isLoginInfoRequest (info: LoginInfoByToken): info is LoginInfoRequest {
return (info as LoginInfoRequest)?.request
}
export function getClientTimezone (): string | undefined {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone
} catch (err: any) {
console.error('Failed to get client timezone', err)
return undefined
}
}
@@ -0,0 +1,12 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "lib", "dist", "types", "bundle"]
}
@@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
@@ -0,0 +1,4 @@
*
!/lib/**
!CHANGELOG.md
/lib/**/__tests__/
@@ -0,0 +1,75 @@
{
"name": "@hcengineering/analytics-service",
"entries": [
{
"version": "0.7.17",
"tag": "@hcengineering/analytics-service_v0.7.17",
"date": "Mon, 27 Oct 2025 13:27:12 GMT",
"comments": {
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`"
}
]
}
},
{
"version": "0.7.5",
"tag": "@hcengineering/analytics-service_v0.7.5",
"date": "Tue, 14 Oct 2025 04:58:17 GMT",
"comments": {
"patch": [
{
"comment": "update deps"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.4` to `0.7.5`"
},
{
"comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`"
},
{
"comment": "Updating dependency \"@hcengineering/analytics\" from `^0.7.4` to `0.7.5`"
}
]
}
},
{
"version": "0.7.4",
"tag": "@hcengineering/analytics-service_v0.7.4",
"date": "Sat, 11 Oct 2025 18:20:33 GMT",
"comments": {
"patch": [
{
"comment": "Update to latest platform rig"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.3` to `0.7.4`"
},
{
"comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`"
},
{
"comment": "Updating dependency \"@hcengineering/analytics\" from `^0.7.3` to `0.7.4`"
}
]
}
},
{
"version": "0.7.3",
"tag": "@hcengineering/analytics-service_v0.7.3",
"date": "Wed, 08 Oct 2025 03:40:53 GMT",
"comments": {
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`"
}
]
}
}
]
}
@@ -0,0 +1,28 @@
# Change Log - @hcengineering/analytics-service
This log was last generated on Mon, 27 Oct 2025 13:27:12 GMT and should not be manually modified.
## 0.7.17
Mon, 27 Oct 2025 13:27:12 GMT
_Version update only_
## 0.7.5
Tue, 14 Oct 2025 04:58:17 GMT
### Patches
- update deps
## 0.7.4
Sat, 11 Oct 2025 18:20:33 GMT
### Patches
- Update to latest platform rig
## 0.7.3
Wed, 08 Oct 2025 03:40:53 GMT
_Initial release_
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig"
}
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ['./src'],
coverageReporters: ['text-summary', 'html', 'lcov']
}
@@ -0,0 +1,61 @@
{
"name": "@hcengineering/analytics-service",
"version": "0.7.17",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"files": [
"lib/**/*",
"types/**/*",
"src/**/*",
"!src/**/__test__/**",
"tsconfig.json"
],
"author": "Anticrm Platform Contributors",
"license": "EPL-2.0",
"scripts": {
"build": "compile",
"build:watch": "compile",
"test": "jest --passWithNoTests --silent --coverage",
"format": "format src",
"_phase:build": "compile transpile src",
"_phase:test": "jest --passWithNoTests --silent --coverage",
"_phase:format": "format src",
"_phase:validate": "compile validate"
},
"devDependencies": {
"@hcengineering/platform-rig": "^0.7.19",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-n": "^15.4.0",
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"typescript": "^5.9.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"@hcengineering/platform": "workspace:^0.7.19",
"@hcengineering/core": "workspace:^0.7.23",
"@hcengineering/analytics": "workspace:^0.7.17",
"@hcengineering/measurements-otlp": "workspace:^0.7.17",
"winston": "^3.11.0",
"winston-daily-rotate-file": "^5.0.0"
},
"repository": "https://github.com/hcengineering/huly.core",
"publishConfig": {
"access": "public"
},
"exports": {
".": {
"types": "./types/index.d.ts",
"require": "./lib/index.js",
"import": "./lib/index.js"
}
}
}
@@ -0,0 +1,42 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
import { AnalyticProvider, Analytics } from '@hcengineering/analytics'
import { initOpenTelemetrySDK, reportOTELError } from '@hcengineering/measurements-otlp'
export * from '@hcengineering/measurements-otlp'
export * from './logging'
class OTELAnalyticsProvider implements AnalyticProvider {
init (config: Record<string, any>): boolean {
return true
}
setUser: (email: string, data: any) => void = (email, data) => {}
setAlias: (distinctId: string, alias: string) => void = (distinctId, alias) => {}
setTag: (key: string, value: string) => void = (key, value) => {}
setWorkspace: (ws: string, guest: boolean) => void = (ws, guest) => {}
handleEvent: (event: string, params: Record<string, string>) => void = (event, params) => {}
handleError (error: Error): void {
reportOTELError(error)
}
navigate (path: string): void {}
logout (): void {}
}
export function configureAnalytics (serviceName: string, serviceVersion: string, config?: Record<string, any>): void {
const providers: AnalyticProvider[] = [new OTELAnalyticsProvider()]
initOpenTelemetrySDK(serviceName, serviceVersion)
for (const provider of providers) {
Analytics.init(provider, config ?? {})
}
}
@@ -0,0 +1,121 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
import { MeasureLogger, ParamsType } from '@hcengineering/core'
import { basename, dirname, join } from 'path'
import winston from 'winston'
import DailyRotateFile from 'winston-daily-rotate-file'
export class SplitLogger implements MeasureLogger {
logger: winston.Logger
constructor (
readonly name: string,
readonly opts: { root?: string, parent?: winston.Logger, pretty?: boolean, enableConsole?: boolean }
) {
const rootDir = this.opts.root ?? 'logs'
this.logger = winston.createLogger({
level: 'info',
exitOnError: false
})
const errorPrinter = ({ message, stack, ...rest }: Error): object => ({
message,
stack,
...rest
})
const jsonOptions: winston.Logform.JsonOptions = {
replacer: (key, value) => {
return value instanceof Error ? errorPrinter(value) : value
}
}
this.logger.add(
new DailyRotateFile({
format: winston.format.combine(
winston.format.timestamp(),
opts.pretty === true ? winston.format.prettyPrint() : winston.format.json(jsonOptions)
),
filename: `${name}-combined-%DATE%.log`,
auditFile: join(rootDir, `${basename(name)}-audit.log`),
dirname: rootDir,
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d'
})
)
this.logger.add(
new DailyRotateFile({
format: winston.format.combine(winston.format.timestamp(), winston.format.prettyPrint()),
filename: `${name}-error-%DATE%.log`,
auditFile: join(rootDir, `${basename(name)}-audit.log`),
level: 'error',
dirname: rootDir,
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d'
})
)
if (opts.parent === undefined && opts.enableConsole === true) {
console.log('Logging also into console', process.env.NODE_ENV, opts.enableConsole)
this.logger.add(
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json(jsonOptions),
winston.format.colorize({ all: true })
)
})
)
}
this.logger.info(
'####################################################################################################################'
)
this.logger.info(
`########################SplitLogger ${this.name} initialized: ${new Date().toISOString()}###########################`
)
}
error (message: string, obj?: Record<string, any>): void {
if (this.opts.parent !== undefined) {
this.opts.parent.error({ message, ...obj })
}
this.logger.error({ message, ...obj })
}
info (message: string, obj?: Record<string, any>): void {
if (this.opts.parent !== undefined && this.opts.enableConsole === true) {
// Only propogate if enable console is true
this.opts.parent.info({ message, ...obj })
}
this.logger.info({ message, ...obj })
}
warn (message: string, obj?: Record<string, any>): void {
if (this.opts.parent !== undefined) {
this.opts.parent.warn({ message, ...obj })
}
this.logger.warn({ message, ...obj })
}
logOperation (operation: string, time: number, params: ParamsType): void {
this.logger.info(operation, { time, ...params })
}
childLogger (name: string, params: Record<string, string>): MeasureLogger {
const dirName = dirname(name)
const { enableConsole, ...otherParams } = params
const child = this.logger.child({ name, ...otherParams })
return new SplitLogger(name, {
...this.opts,
parent: child,
root: join(this.opts.root ?? 'logs', dirName),
enableConsole: enableConsole === 'true'
})
}
async close (): Promise<void> {
this.logger.close()
}
}
@@ -0,0 +1,12 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "lib", "dist", "types", "bundle"]
}
@@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
@@ -0,0 +1,4 @@
*
!/lib/**
!CHANGELOG.md
/lib/**/__tests__/
@@ -0,0 +1,51 @@
{
"name": "@hcengineering/analytics",
"entries": [
{
"version": "0.7.17",
"tag": "@hcengineering/analytics_v0.7.17",
"date": "Fri, 31 Oct 2025 20:11:13 GMT",
"comments": {
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.17` to `0.7.18`"
}
]
}
},
{
"version": "0.7.5",
"tag": "@hcengineering/analytics_v0.7.5",
"date": "Tue, 14 Oct 2025 04:58:17 GMT",
"comments": {
"patch": [
{
"comment": "update deps"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.4` to `0.7.5`"
}
]
}
},
{
"version": "0.7.4",
"tag": "@hcengineering/analytics_v0.7.4",
"date": "Sat, 11 Oct 2025 18:20:33 GMT",
"comments": {
"patch": [
{
"comment": "Update to latest platform rig"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.3` to `0.7.4`"
}
]
}
}
]
}
@@ -0,0 +1,23 @@
# Change Log - @hcengineering/analytics
This log was last generated on Fri, 31 Oct 2025 20:11:13 GMT and should not be manually modified.
## 0.7.17
Fri, 31 Oct 2025 20:11:13 GMT
_Version update only_
## 0.7.5
Tue, 14 Oct 2025 04:58:17 GMT
### Patches
- update deps
## 0.7.4
Sat, 11 Oct 2025 18:20:33 GMT
### Patches
- Update to latest platform rig
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig"
}
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ['./src'],
coverageReporters: ['text-summary', 'html', 'lcov']
}
@@ -0,0 +1,56 @@
{
"name": "@hcengineering/analytics",
"version": "0.7.17",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"files": [
"lib/**/*",
"types/**/*",
"src/**/*",
"!src/**/__test__/**",
"tsconfig.json"
],
"author": "Anticrm Platform Contributors",
"license": "EPL-2.0",
"scripts": {
"build": "compile",
"build:watch": "compile",
"test": "jest --passWithNoTests --silent --coverage",
"format": "format src",
"_phase:build": "compile transpile src",
"_phase:test": "jest --passWithNoTests --silent --coverage",
"_phase:format": "format src",
"_phase:validate": "compile validate"
},
"devDependencies": {
"@hcengineering/platform-rig": "^0.7.19",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-n": "^15.4.0",
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"typescript": "^5.9.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"@hcengineering/platform": "workspace:^0.7.19"
},
"repository": "https://github.com/hcengineering/huly.core",
"publishConfig": {
"access": "public"
},
"exports": {
".": {
"types": "./types/index.d.ts",
"require": "./lib/index.js",
"import": "./lib/index.js"
}
}
}
@@ -0,0 +1,84 @@
//
// Copyright © 2024 Hardcore Engineering Inc
//
import { addEventListener, PlatformEvent, Severity, Status, translate } from '@hcengineering/platform'
export const providers: AnalyticProvider[] = []
export interface AnalyticProvider {
init: (config: Record<string, any>) => boolean
setUser: (email: string, data: any) => void
setAlias: (distinctId: string, alias: string) => void
setTag: (key: string, value: string) => void
setWorkspace: (ws: string, guest: boolean) => void
handleEvent: (event: string, params: Record<string, string>) => void
handleError: (error: Error) => void
navigate: (path: string) => void
logout: () => void
}
export const Analytics = {
data: {},
init (provider: AnalyticProvider, config: Record<string, any>): void {
const res = provider.init(config)
if (res) {
providers.push(provider)
}
},
setUser (email: string, data: any): void {
providers.forEach((provider) => {
provider.setUser(email, data)
})
},
setAlias (distinctId: string, alias: string): void {
providers.forEach((provider) => {
provider.setAlias(distinctId, alias)
})
},
setTag (key: string, value: string): void {
providers.forEach((provider) => {
provider.setTag(key, value)
})
},
setWorkspace (ws: string, guest: boolean): void {
providers.forEach((provider) => {
provider.setWorkspace(ws, guest)
})
},
handleEvent (event: string, params: Record<string, any> = {}): void {
providers.forEach((provider) => {
provider.handleEvent(event, { ...this.data, ...params })
})
},
handleError (error: Error): void {
providers.forEach((provider) => {
provider.handleError(error)
})
},
navigate (path: string): void {
providers.forEach((provider) => {
provider.navigate(path)
})
},
logout (): void {
providers.forEach((provider) => {
provider.logout()
})
}
}
addEventListener(PlatformEvent, async (_event, _status: Status) => {
if (_status.severity === Severity.ERROR) {
const label = await translate(_status.code, _status.params, 'en')
Analytics.handleError(new Error(label))
}
})
@@ -0,0 +1,12 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "lib", "dist", "types", "bundle"]
}
@@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
@@ -0,0 +1,4 @@
*
!/lib/**
!CHANGELOG.md
/lib/**/__tests__/
@@ -0,0 +1,122 @@
{
"name": "@hcengineering/api-client",
"entries": [
{
"version": "0.7.18",
"tag": "@hcengineering/api-client_v0.7.18",
"date": "Mon, 27 Oct 2025 17:09:21 GMT",
"comments": {
"patch": [
{
"comment": "add support for textColor and textStyle marks"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/text\" from `^0.7.17` to `0.7.18`"
}
]
}
},
{
"version": "0.7.17",
"tag": "@hcengineering/api-client_v0.7.17",
"date": "Mon, 27 Oct 2025 13:27:12 GMT",
"comments": {
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`"
}
]
}
},
{
"version": "0.7.5",
"tag": "@hcengineering/api-client_v0.7.5",
"date": "Tue, 14 Oct 2025 04:58:17 GMT",
"comments": {
"patch": [
{
"comment": "update deps"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/account-client\" from `^0.7.4` to `0.7.5`"
},
{
"comment": "Updating dependency \"@hcengineering/client\" from `^0.7.5` to `0.7.6`"
},
{
"comment": "Updating dependency \"@hcengineering/client-resources\" from `^0.7.5` to `0.7.6`"
},
{
"comment": "Updating dependency \"@hcengineering/collaborator-client\" from `^0.7.4` to `0.7.5`"
},
{
"comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`"
},
{
"comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.4` to `0.7.5`"
},
{
"comment": "Updating dependency \"@hcengineering/text\" from `^0.7.4` to `0.7.5`"
},
{
"comment": "Updating dependency \"@hcengineering/text-markdown\" from `^0.7.4` to `0.7.5`"
}
]
}
},
{
"version": "0.7.4",
"tag": "@hcengineering/api-client_v0.7.4",
"date": "Sat, 11 Oct 2025 18:20:33 GMT",
"comments": {
"patch": [
{
"comment": "Update to latest platform rig"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/account-client\" from `^0.7.3` to `0.7.4`"
},
{
"comment": "Updating dependency \"@hcengineering/client\" from `^0.7.4` to `0.7.5`"
},
{
"comment": "Updating dependency \"@hcengineering/client-resources\" from `^0.7.4` to `0.7.5`"
},
{
"comment": "Updating dependency \"@hcengineering/collaborator-client\" from `^0.7.3` to `0.7.4`"
},
{
"comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`"
},
{
"comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.3` to `0.7.4`"
},
{
"comment": "Updating dependency \"@hcengineering/text\" from `^0.7.3` to `0.7.4`"
},
{
"comment": "Updating dependency \"@hcengineering/text-markdown\" from `^0.7.3` to `0.7.4`"
}
]
}
},
{
"version": "0.7.3",
"tag": "@hcengineering/api-client_v0.7.3",
"date": "Wed, 08 Oct 2025 03:40:53 GMT",
"comments": {
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`"
}
]
}
}
]
}
@@ -0,0 +1,35 @@
# Change Log - @hcengineering/api-client
This log was last generated on Mon, 27 Oct 2025 17:09:21 GMT and should not be manually modified.
## 0.7.18
Mon, 27 Oct 2025 17:09:21 GMT
### Patches
- add support for textColor and textStyle marks
## 0.7.17
Mon, 27 Oct 2025 13:27:12 GMT
_Version update only_
## 0.7.5
Tue, 14 Oct 2025 04:58:17 GMT
### Patches
- update deps
## 0.7.4
Sat, 11 Oct 2025 18:20:33 GMT
### Patches
- Update to latest platform rig
## 0.7.3
Wed, 08 Oct 2025 03:40:53 GMT
_Initial release_
@@ -0,0 +1,446 @@
# Huly Platform API Client
A TypeScript client library for interacting with the Huly Platform API.
## Installation
In order to be able to install required packages, you will need to obtain GitHub access token. You can create a token by following the instructions [here](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry#authenticating-with-a-personal-access-token).
```bash
npm install @hcengineering/api-client
```
## WebSocket Client vs REST Client
The api client package provides two main client variants: a WebSocket client and a REST client. The WebSocket client holds persistent connection to the Huly Platform API. The REST client uses standard HTTP requests to perform operations.
### WebSocket Client
```ts
import { connect } from '@hcengineering/api-client'
// Connect to Huly
const client = await connect('https://huly.app', {
email: 'johndoe@example.com',
password: 'password',
workspace: 'my-workspace',
})
// Use the client to perform operations
...
// Close the client when done
await client.close()
```
### REST Client
```ts
import { connectRest } from '@hcengineering/api-client'
// Connect to Huly
const client = await connectRest('https://huly.app', {
email: 'johndoe@example.com',
password: 'password',
workspace: 'my-workspace'
})
// Use the client to perform operations
...
```
## Authentication
The client supports two authentication methods: using email and password, or using a token.
When authenticated, the client will have access to the same resources as the user.
> Note: The examples below use the WebSocket client (`connect`). To use the REST client instead, import and call `connectRest` with the same options.
Parameters:
- `url`: URL of the Huly instance, for Huly Cloud use `https://huly.app`
- `options`: Connection options
- `workspace`: Name of the workspace to connect to, the workspace name can be found in the URL of the workspace: `https://huly.app/workbench/<workspace-name>`
- `token`: Optional authentication token
- `email`: Optional user email
- `password`: Optional user password
### Using Email and Password
```ts
import { connect } from '@hcengineering/api-client'
const client = await connect('https://huly.app', {
email: 'johndoe@example.com',
password: 'password',
workspace: 'my-workspace'
})
...
await client.close()
```
### Using Token
```ts
import { connect } from '@hcengineering/api-client'
const client = await connect('https://huly.app', {
token: '...',
workspace: 'my-workspace'
})
...
await client.close()
```
## Client API
The client provides a set of methods for interacting with the Huly Platform API. This section describes the main methods available in the client.
### Fetch API
The client provides two main methods for retrieving documents: `findOne` and `findAll`.
#### findOne
Retrieves a single document matching the query criteria.
Parameters:
- `_class`: Class of the object to find, results will include all subclasses of the target class
- `query`: Query criteria
- `options`: Find options
- `limit`: Limit the number of results returned
- `sort`: Sorting criteria
- `lookup`: Lookup criteria
- `projection`: Projection criteria
- `total`: If specified total will be returned
Example:
```ts
import contact from '@hcengineering/contact'
...
const person = await client.findOne(
contact.class.Person,
{
_id: 'person-id'
}
)
```
#### findAll
Retrieves multiple document matching the query criteria.
Parameters:
- `_class`: Class of the object to find, results will include all subclasses of the target class
- `query`: Query criteria
- `options`: Find options
- `limit`: Limit the number of results returned
- `sort`: Sorting criteria
- `lookup`: Lookup criteria
- `projection`: Projection criteria
- `total`: If specified total will be returned
Example:
```ts
import { SortingOrder } from '@hcengineering/core'
import contact from '@hcengineering/contact'
..
const persons = await client.findAll(
contact.class.Person,
{
city: 'New York'
},
{
limit: 10,
sort: {
name: SortingOrder.Ascending
}
}
)
```
### Documents API
The client provides three main methods for managing documents: `createDoc`, `updateDoc`, and `removeDoc`. These methods allow you to perform CRUD operations on documents.
#### createDoc
Creates a new document in the specified space.
Parameters:
- `_class`: Class of the object
- `space`: Space of the object
- `attributes`: Attributes of the object
- `id`: Optional id of the object, if not provided, a new id will be generated
Example:
```ts
import contact, { AvatarType } from '@hcengineering/contact'
..
const personId = await client.createDoc(
contact.class.Person,
contact.space.Contacts,
{
name: 'Doe,John',
city: 'New York',
avatarType: AvatarType.COLOR
}
)
```
#### updateDoc
Updates existing document.
Parameters:
- `_class`: Class of the object
- `space`: Space of the object
- `objectId`: Id of the object
- `operations`: Attributes of the object to update
Example:
```ts
import contact from '@hcengineering/contact'
..
await client.updateDoc(
contact.class.Person,
contact.space.Contacts,
personId,
{
city: 'New York',
}
)
```
#### removeDoc
Removes existing document.
Parameters:
- `_class`: Class of the object
- `space`: Space of the object
- `objectId`: Id of the object
Example:
```ts
import contact from '@hcengineering/contact'
..
await client.removeDoc(
contact.class.Person,
contact.space.Contacts,
personId
)
```
### Collections API
#### addCollection
Creates a new attached document in the specified collection.
Parameters:
- `_class`: Class of the object to create
- `space`: Space of the object to create
- `attachedTo`: Id of the object to attach to
- `attachedToClass`: Class of the object to attach to
- `collection`: Name of the collection containing attached documents
- `attributes`: Attributes of the object
- `id`: Optional id of the object, if not provided, a new id will be generated
Example:
```ts
import contact, { AvatarType } from '@hcengineering/contact'
..
const personId = await client.createDoc(
contact.class.Person,
contact.space.Contacts,
{
name: 'Doe,John',
city: 'New York',
avatarType: AvatarType.COLOR
}
)
await client.addCollection(
contact.class.Channel,
contact.space.Contacts,
personId,
contact.class.Person,
'channels',
{
provider: contact.channelProvider.Email,
value: 'john.doe@example.com'
}
)
```
#### updateCollection
Updates existing attached document in collection.
Parameters:
- `_class`: Class of the object to update
- `space`: Space of the object to update
- `objectId`: Space of the object to update
- `attachedTo`: Id of the parent object
- `attachedToClass`: Class of the parent object
- `collection`: Name of the collection containing attached documents
- `attributes`: Attributes of the object to update
Example:
```ts
import contact from '@hcengineering/contact'
..
await client.updateCollection(
contact.class.Channel,
contact.space.Contacts,
channelId,
personId,
contact.class.Person,
'channels',
{
city: 'New York',
}
)
```
#### removeCollection
Removes existing attached document from collection.
Parameters:
- `_class`: Class of the object to remove
- `space`: Space of the object to remove
- `objectId`: Space of the object to remove
- `attachedTo`: Id of the parent object
- `attachedToClass`: Class of the parent object
- `collection`: Name of the collection containing attached documents
Example:
```ts
import contact from '@hcengineering/contact'
..
await client.removeCollection(
contact.class.Channel,
contact.space.Contacts,
channelId,
personId,
contact.class.Person,
'channels'
)
```
### Mixins API
The client provides two methods for managing mixins: `createMixin` and `updateMixin`.
#### createMixin
Creates a new mixin for a specified document.
Parameters:
- `objectId`: Id of the object the mixin is attached to
- `objectClass`: Class of the object the mixin is attached to
- `objectSpace`: Space of the object the mixin is attached to
- `mixin`: Id of the mixin type to update
- `attributes`: Attributes of the mixin
```ts
import contact, { AvatarType } from '@hcengineering/contact'
..
const personId = await client.createDoc(
contact.class.Person,
contact.space.Contacts,
{
name: 'Doe,John',
city: 'New York',
avatarType: AvatarType.COLOR
}
)
await client.createMixin(
personId,
contact.class.Person,
contact.space.Contacts,
contact.mixin.Employee,
{
active: true,
position: 'CEO'
}
)
```
#### updateMixin
Updates an existing mixin.
Parameters:
- `objectId`: Id of the object the mixin is attached to
- `objectClass`: Class of the object the mixin is attached to
- `objectSpace`: Space of the object the mixin is attached to
- `mixin`: Id of the mixin type to update
- `attributes`: Attributes of the mixin to update
```ts
import contact, { AvatarType } from '@hcengineering/contact'
..
const person = await client.findOne(
contact.class.Person,
{
_id: 'person-id'
}
)
await client.updateMixin(
personId,
contact.class.Person,
contact.space.Contacts,
contact.mixin.Employee,
{
active: false
}
)
```
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig"
}
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ['./src'],
coverageReporters: ['text-summary', 'html', 'lcov']
}
@@ -0,0 +1,72 @@
{
"name": "@hcengineering/api-client",
"version": "0.7.18",
"main": "lib/index.js",
"exports": {
".": {
"types": "./types/index.d.ts",
"require": "./lib/index.js",
"import": "./lib/index.js"
}
},
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"files": [
"lib/**/*",
"types/**/*",
"src/**/*",
"!src/**/__test__/**",
"tsconfig.json"
],
"author": "Anticrm Platform Contributors",
"template": "@hcengineering/api-package",
"license": "EPL-2.0",
"scripts": {
"build": "compile",
"build:watch": "compile",
"test": "jest --passWithNoTests --silent --coverage",
"format": "format src",
"_phase:build": "compile transpile src",
"_phase:test": "jest --passWithNoTests --silent --coverage",
"_phase:format": "format src",
"_phase:validate": "compile validate"
},
"devDependencies": {
"@hcengineering/platform-rig": "^0.7.19",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-n": "^15.4.0",
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"typescript": "^5.9.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.8.0",
"@types/node": "^22.15.29",
"@types/jest": "^29.5.5",
"@types/ws": "^8.5.12",
"@types/snappyjs": "^0.7.1",
"eslint-plugin-svelte": "^2.35.1"
},
"dependencies": {
"@hcengineering/account-client": "workspace:^0.7.20",
"@hcengineering/client": "workspace:^0.7.18",
"@hcengineering/client-resources": "workspace:^0.7.18",
"@hcengineering/collaborator-client": "workspace:^0.7.17",
"@hcengineering/core": "workspace:^0.7.23",
"@hcengineering/platform": "workspace:^0.7.19",
"@hcengineering/text": "workspace:^0.7.18",
"@hcengineering/text-markdown": "workspace:^0.7.20",
"snappyjs": "^0.7.0"
},
"repository": "https://github.com/hcengineering/huly.core",
"publishConfig": {
"access": "public"
},
"optionalDependencies": {
"ws": "^8.18.2"
}
}
@@ -0,0 +1,78 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
import { loadServerConfig } from '../config'
describe('loadServerConfig', () => {
const mockFetch = jest.fn()
global.fetch = mockFetch as any
beforeEach(() => {
mockFetch.mockClear()
})
it('should load server config successfully', async () => {
const mockConfig = {
ACCOUNTS_URL: 'https://accounts.example.com',
COLLABORATOR_URL: 'https://collaborator.example.com',
FILES_URL: 'https://files.example.com',
UPLOAD_URL: 'https://upload.example.com'
}
mockFetch.mockResolvedValue({
ok: true,
json: async () => mockConfig
})
const config = await loadServerConfig('https://api.example.com')
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/config.json', { keepalive: true })
expect(config).toEqual(mockConfig)
})
it('should throw error when fetch fails', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404
})
await expect(loadServerConfig('https://api.example.com')).rejects.toThrow('Failed to fetch config')
})
it('should handle network errors', async () => {
mockFetch.mockRejectedValue(new Error('Network error'))
await expect(loadServerConfig('https://api.example.com')).rejects.toThrow('Network error')
})
it('should construct correct config URL', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
ACCOUNTS_URL: '',
COLLABORATOR_URL: '',
FILES_URL: '',
UPLOAD_URL: ''
})
})
await loadServerConfig('https://api.example.com/')
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/config.json', { keepalive: true })
})
it('should handle URL without trailing slash', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
ACCOUNTS_URL: '',
COLLABORATOR_URL: '',
FILES_URL: '',
UPLOAD_URL: ''
})
})
await loadServerConfig('https://api.example.com')
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/config.json', { keepalive: true })
})
})
@@ -0,0 +1,183 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
import { createMarkupOperations } from '../markup/client'
import { getClient } from '@hcengineering/collaborator-client'
import { makeCollabId } from '@hcengineering/core'
// Mock dependencies
jest.mock('@hcengineering/collaborator-client')
jest.mock('@hcengineering/text', () => ({
htmlToJSON: jest.fn((html) => ({ type: 'doc', content: [{ type: 'text', text: html }] })),
jsonToHTML: jest.fn((json) => json.content?.[0]?.text ?? ''),
jsonToMarkup: jest.fn((json) => json.content?.[0]?.text ?? ''),
markupToJSON: jest.fn((markup) => ({ type: 'doc', content: [{ type: 'text', text: markup }] }))
}))
jest.mock('@hcengineering/text-markdown', () => ({
markdownToMarkup: jest.fn((md) => md),
markupToMarkdown: jest.fn((json) => json.content?.[0]?.text ?? '')
}))
describe('MarkupOperations', () => {
const mockConfig = {
ACCOUNTS_URL: 'https://accounts.example.com',
COLLABORATOR_URL: 'https://collaborator.example.com',
FILES_URL: 'https://files.example.com',
UPLOAD_URL: 'https://upload.example.com'
}
const workspace = 'test-workspace' as any
const token = 'test-token'
const url = 'https://api.example.com'
let mockCollaborator: any
let operations: any
beforeEach(() => {
jest.clearAllMocks()
mockCollaborator = {
getMarkup: jest.fn(),
createMarkup: jest.fn()
}
;(getClient as jest.Mock).mockReturnValue(mockCollaborator)
operations = createMarkupOperations(url, workspace, token, mockConfig)
})
describe('fetchMarkup', () => {
const objectClass = 'class:test.Doc' as any
const objectId = 'doc-id-123' as any
const objectAttr = 'content'
const markupRef = 'markup-ref-456' as any
it('should fetch markup in markup format', async () => {
const mockMarkup = 'Test markup content'
mockCollaborator.getMarkup.mockResolvedValue(mockMarkup)
const result = await operations.fetchMarkup(objectClass, objectId, objectAttr, markupRef, 'markup')
const collabId = makeCollabId(objectClass, objectId, objectAttr)
expect(mockCollaborator.getMarkup).toHaveBeenCalledWith(collabId, markupRef)
expect(result).toBe(mockMarkup)
})
it('should fetch markup in HTML format', async () => {
const mockMarkup = '<p>Test content</p>'
mockCollaborator.getMarkup.mockResolvedValue(mockMarkup)
const result = await operations.fetchMarkup(objectClass, objectId, objectAttr, markupRef, 'html')
expect(mockCollaborator.getMarkup).toHaveBeenCalled()
expect(result).toBeDefined()
})
it('should fetch markup in markdown format', async () => {
const mockMarkup = '# Test heading'
mockCollaborator.getMarkup.mockResolvedValue(mockMarkup)
const result = await operations.fetchMarkup(objectClass, objectId, objectAttr, markupRef, 'markdown')
expect(mockCollaborator.getMarkup).toHaveBeenCalled()
expect(result).toBeDefined()
})
it('should throw error for unknown format', async () => {
mockCollaborator.getMarkup.mockResolvedValue('content')
await expect(
operations.fetchMarkup(objectClass, objectId, objectAttr, markupRef, 'unknown-format' as any)
).rejects.toThrow('Unknown content format')
})
it('should handle collaborator errors', async () => {
mockCollaborator.getMarkup.mockRejectedValue(new Error('Collaborator error'))
await expect(operations.fetchMarkup(objectClass, objectId, objectAttr, markupRef, 'markup')).rejects.toThrow(
'Collaborator error'
)
})
})
describe('uploadMarkup', () => {
const objectClass = 'class:test.Doc' as any
const objectId = 'doc-id-123' as any
const objectAttr = 'content'
const mockMarkupRef = 'new-markup-ref-789' as any
beforeEach(() => {
mockCollaborator.createMarkup.mockResolvedValue(mockMarkupRef)
})
it('should upload markup in markup format', async () => {
const content = 'Test markup content'
const result = await operations.uploadMarkup(objectClass, objectId, objectAttr, content, 'markup')
const collabId = makeCollabId(objectClass, objectId, objectAttr)
expect(mockCollaborator.createMarkup).toHaveBeenCalledWith(collabId, content)
expect(result).toBe(mockMarkupRef)
})
it('should upload markup in HTML format', async () => {
const content = '<p>Test HTML content</p>'
const result = await operations.uploadMarkup(objectClass, objectId, objectAttr, content, 'html')
expect(mockCollaborator.createMarkup).toHaveBeenCalled()
expect(result).toBe(mockMarkupRef)
})
it('should upload markup in markdown format', async () => {
const content = '# Test markdown'
const result = await operations.uploadMarkup(objectClass, objectId, objectAttr, content, 'markdown')
expect(mockCollaborator.createMarkup).toHaveBeenCalled()
expect(result).toBe(mockMarkupRef)
})
it('should throw error for unknown format', async () => {
await expect(
operations.uploadMarkup(objectClass, objectId, objectAttr, 'content', 'unknown-format' as any)
).rejects.toThrow('Unknown content format')
})
it('should handle empty content', async () => {
const result = await operations.uploadMarkup(objectClass, objectId, objectAttr, '', 'markup')
const collabId = makeCollabId(objectClass, objectId, objectAttr)
expect(mockCollaborator.createMarkup).toHaveBeenCalledWith(collabId, '')
expect(result).toBe(mockMarkupRef)
})
it('should handle collaborator errors', async () => {
mockCollaborator.createMarkup.mockRejectedValue(new Error('Upload failed'))
await expect(operations.uploadMarkup(objectClass, objectId, objectAttr, 'content', 'markup')).rejects.toThrow(
'Upload failed'
)
})
})
describe('initialization', () => {
it('should initialize collaborator client with correct parameters', () => {
expect(getClient).toHaveBeenCalledWith(workspace, token, mockConfig.COLLABORATOR_URL)
})
it('should handle different workspace IDs', () => {
const differentWorkspace = 'different-workspace' as any
createMarkupOperations(url, differentWorkspace, token, mockConfig)
expect(getClient).toHaveBeenCalledWith(differentWorkspace, token, mockConfig.COLLABORATOR_URL)
})
it('should handle different tokens', () => {
const differentToken = 'different-token'
createMarkupOperations(url, workspace, differentToken, mockConfig)
expect(getClient).toHaveBeenCalledWith(workspace, differentToken, mockConfig.COLLABORATOR_URL)
})
})
})
@@ -0,0 +1,124 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
import { html, markdown, MarkupContent } from '../markup/types'
describe('MarkupContent', () => {
describe('constructor', () => {
it('should create MarkupContent with content and kind', () => {
const content = '<p>Hello World</p>'
const markup = new MarkupContent(content, 'html')
expect(markup.content).toBe(content)
expect(markup.kind).toBe('html')
})
it('should create MarkupContent with markdown kind', () => {
const content = '# Hello World'
const markup = new MarkupContent(content, 'markdown')
expect(markup.content).toBe(content)
expect(markup.kind).toBe('markdown')
})
it('should create MarkupContent with markup kind', () => {
const content = 'plain markup content'
const markup = new MarkupContent(content, 'markup')
expect(markup.content).toBe(content)
expect(markup.kind).toBe('markup')
})
})
describe('html helper', () => {
it('should create HTML MarkupContent', () => {
const content = '<h1>Title</h1><p>Content</p>'
const markup = html(content)
expect(markup).toBeInstanceOf(MarkupContent)
expect(markup.content).toBe(content)
expect(markup.kind).toBe('html')
})
it('should handle empty HTML', () => {
const markup = html('')
expect(markup.content).toBe('')
expect(markup.kind).toBe('html')
})
it('should handle complex HTML with attributes', () => {
const content = '<div class="container"><a href="https://example.com">Link</a></div>'
const markup = html(content)
expect(markup.content).toBe(content)
expect(markup.kind).toBe('html')
})
})
describe('markdown helper', () => {
it('should create Markdown MarkupContent', () => {
const content = '# Heading\n\n* List item 1\n* List item 2'
const markup = markdown(content)
expect(markup).toBeInstanceOf(MarkupContent)
expect(markup.content).toBe(content)
expect(markup.kind).toBe('markdown')
})
it('should handle empty markdown', () => {
const markup = markdown('')
expect(markup.content).toBe('')
expect(markup.kind).toBe('markdown')
})
it('should handle markdown with code blocks', () => {
const content = '```javascript\nconst x = 42;\n```'
const markup = markdown(content)
expect(markup.content).toBe(content)
expect(markup.kind).toBe('markdown')
})
it('should handle markdown with links', () => {
const content = '[Link text](https://example.com)'
const markup = markdown(content)
expect(markup.content).toBe(content)
expect(markup.kind).toBe('markdown')
})
})
describe('edge cases', () => {
it('should handle special characters in content', () => {
const content = '<p>Special chars: & < > " \'</p>'
const markup = html(content)
expect(markup.content).toBe(content)
})
it('should handle Unicode characters', () => {
const content = '# 你好世界 🌍'
const markup = markdown(content)
expect(markup.content).toBe(content)
})
it('should handle very long content', () => {
const content = 'a'.repeat(10000)
const markup = html(content)
expect(markup.content).toBe(content)
expect(markup.content.length).toBe(10000)
})
it('should handle multiline content', () => {
const content = 'Line 1\nLine 2\nLine 3'
const markup = markdown(content)
expect(markup.content).toBe(content)
})
})
})
@@ -0,0 +1,209 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
import { withRetry, extractJson } from '../rest/utils'
describe('withRetry', () => {
it('should return result on first success', async () => {
const fn = jest.fn().mockResolvedValue('success')
const result = await withRetry(fn)
expect(result).toBe('success')
expect(fn).toHaveBeenCalledTimes(1)
})
it('should retry on failure and eventually succeed', async () => {
const fn = jest
.fn()
.mockRejectedValueOnce(new Error('fail 1'))
.mockRejectedValueOnce(new Error('fail 2'))
.mockResolvedValue('success')
const result = await withRetry(fn)
expect(result).toBe('success')
expect(fn).toHaveBeenCalledTimes(3)
})
it('should throw error after max retries', async () => {
const error = new Error('persistent failure')
const fn = jest.fn().mockRejectedValue(error)
await expect(withRetry(fn)).rejects.toThrow('persistent failure')
expect(fn).toHaveBeenCalledTimes(3)
})
it('should use exponential backoff', async () => {
const delays: number[] = []
const startTimes: number[] = []
let lastTime = Date.now()
const fn = jest.fn(async () => {
const now = Date.now()
if (startTimes.length > 0) {
delays.push(now - lastTime)
}
startTimes.push(now)
lastTime = now
throw new Error('fail')
})
await expect(withRetry(fn)).rejects.toThrow('fail')
expect(fn).toHaveBeenCalledTimes(3)
// Delays should be approximately 100ms and 200ms (with some tolerance)
expect(delays.length).toBe(2)
expect(delays[0]).toBeGreaterThanOrEqual(80)
expect(delays[0]).toBeLessThan(150)
expect(delays[1]).toBeGreaterThanOrEqual(180)
expect(delays[1]).toBeLessThan(250)
})
it('should not decrement attempt when ignoreAttemptCheck returns true', async () => {
let callCount = 0
const fn = jest.fn(async () => {
callCount++
if (callCount <= 5) {
throw new Error('ignore')
}
throw new Error('real error')
})
const ignoreCheck = jest.fn((err: any) => err.message === 'ignore')
await expect(withRetry(fn, ignoreCheck)).rejects.toThrow('real error')
// Should have tried more than 3 times because ignored errors don't count
expect(fn.mock.calls.length).toBeGreaterThan(3)
expect(ignoreCheck).toHaveBeenCalled()
})
it('should handle promise rejection', async () => {
const fn = jest.fn(async () => {
throw new Error('async error')
})
await expect(withRetry(fn)).rejects.toThrow('async error')
expect(fn).toHaveBeenCalledTimes(3)
})
it('should handle errors that bypass the check', async () => {
const fn = jest.fn(async () => {
// Always throw error
throw new Error('persistent error')
})
const ignoreCheck = jest.fn(() => false) // Never ignore
await expect(withRetry(fn, ignoreCheck)).rejects.toThrow('persistent error')
expect(fn).toHaveBeenCalledTimes(3)
expect(ignoreCheck).toHaveBeenCalledTimes(3)
})
})
describe('extractJson', () => {
it('should extract plain JSON', async () => {
const mockResponse = {
headers: {
get: jest.fn().mockReturnValue(null)
},
text: jest.fn().mockResolvedValue('{"key":"value"}')
} as any
const result = await extractJson(mockResponse)
expect(result).toEqual({ key: 'value' })
expect(mockResponse.headers.get).toHaveBeenCalledWith('content-encoding')
})
it('should handle TotalArray dataType', async () => {
const jsonString = JSON.stringify({
dataType: 'TotalArray',
value: [{ id: 1 }, { id: 2 }],
total: 10,
lookupMap: { key: 'value' }
})
const mockResponse = {
headers: {
get: jest.fn().mockReturnValue(null)
},
text: jest.fn().mockResolvedValue(jsonString)
} as any
const result = await extractJson(mockResponse)
expect(Array.isArray(result)).toBe(true)
expect(result.total).toBe(10)
expect(result.lookupMap).toEqual({ key: 'value' })
expect(result[0]).toEqual({ id: 1 })
})
it('should handle snappy encoding header', async () => {
// For this test, we'll just verify the snappy path is attempted
// Actual snappy compression/decompression would require valid compressed data
const mockResponse = {
headers: {
get: jest.fn().mockReturnValue('snappy')
},
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(0))
} as any
// This will fail to decompress, but we're testing that the snappy path is taken
await expect(extractJson(mockResponse)).rejects.toThrow()
expect(mockResponse.headers.get).toHaveBeenCalledWith('content-encoding')
expect(mockResponse.arrayBuffer).toHaveBeenCalled()
})
it('should handle empty JSON object', async () => {
const mockResponse = {
headers: {
get: jest.fn().mockReturnValue(null)
},
text: jest.fn().mockResolvedValue('{}')
} as any
const result = await extractJson(mockResponse)
expect(result).toEqual({})
})
it('should handle nested objects', async () => {
const nestedData = {
level1: {
level2: {
dataType: 'TotalArray',
value: [1, 2, 3],
total: 3,
lookupMap: {}
}
}
}
const mockResponse = {
headers: {
get: jest.fn().mockReturnValue(null)
},
text: jest.fn().mockResolvedValue(JSON.stringify(nestedData))
} as any
const result = await extractJson(mockResponse)
expect(result.level1.level2.total).toBe(3)
expect(Array.isArray(result.level1.level2)).toBe(true)
})
it('should throw error on invalid JSON', async () => {
const mockResponse = {
headers: {
get: jest.fn().mockReturnValue(null)
},
text: jest.fn().mockResolvedValue('invalid json{')
} as any
await expect(extractJson(mockResponse)).rejects.toThrow()
})
})
@@ -0,0 +1,202 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
import { getWorkspaceToken } from '../utils'
import { loadServerConfig } from '../config'
import { getClient as getAccountClient } from '@hcengineering/account-client'
// Mock dependencies
jest.mock('../config')
jest.mock('@hcengineering/account-client')
describe('getWorkspaceToken', () => {
const mockConfig = {
ACCOUNTS_URL: 'https://accounts.example.com',
COLLABORATOR_URL: 'https://collaborator.example.com',
FILES_URL: 'https://files.example.com',
UPLOAD_URL: 'https://upload.example.com'
}
const mockWorkspaceInfo = {
endpoint: 'wss://workspace.example.com',
token: 'workspace-token-123',
workspace: 'workspace-id-123' as any,
email: 'user@example.com',
workspaceId: 'workspace-id-123',
workspaceName: 'Test Workspace',
workspaceUrl: 'https://workspace.example.com',
createdOn: Date.now(),
lastVisit: Date.now(),
role: 0,
account: 'account-id-123' as any
}
const mockLoginInfo = {
token: 'login-token-456',
endpoint: 'wss://endpoint.example.com'
}
let mockAccountClient: any
beforeEach(() => {
jest.clearAllMocks()
mockAccountClient = {
login: jest.fn().mockResolvedValue(mockLoginInfo),
selectWorkspace: jest.fn().mockResolvedValue(mockWorkspaceInfo)
}
;(getAccountClient as jest.Mock).mockReturnValue(mockAccountClient)
;(loadServerConfig as jest.Mock).mockResolvedValue(mockConfig)
})
describe('with email/password authentication', () => {
it('should successfully get workspace token with credentials', async () => {
const result = await getWorkspaceToken('https://api.example.com', {
email: 'user@example.com',
password: 'password123',
workspace: 'test-workspace'
})
expect(loadServerConfig).toHaveBeenCalledWith('https://api.example.com')
expect(mockAccountClient.login).toHaveBeenCalledWith('user@example.com', 'password123')
expect(mockAccountClient.selectWorkspace).toHaveBeenCalledWith('test-workspace')
expect(result).toEqual({
endpoint: mockWorkspaceInfo.endpoint,
token: mockWorkspaceInfo.token,
workspaceId: mockWorkspaceInfo.workspace,
info: mockWorkspaceInfo
})
})
it('should use provided config if available', async () => {
await getWorkspaceToken(
'https://api.example.com',
{
email: 'user@example.com',
password: 'password123',
workspace: 'test-workspace'
},
mockConfig
)
expect(loadServerConfig).not.toHaveBeenCalled()
expect(getAccountClient).toHaveBeenCalledWith(mockConfig.ACCOUNTS_URL)
})
it('should throw error when login fails', async () => {
mockAccountClient.login.mockResolvedValue({ token: undefined })
await expect(
getWorkspaceToken('https://api.example.com', {
email: 'user@example.com',
password: 'wrong-password',
workspace: 'test-workspace'
})
).rejects.toThrow('Login failed')
})
it('should throw error when workspace not found', async () => {
mockAccountClient.selectWorkspace.mockResolvedValue(undefined)
await expect(
getWorkspaceToken('https://api.example.com', {
email: 'user@example.com',
password: 'password123',
workspace: 'non-existent-workspace'
})
).rejects.toThrow('Workspace not found')
})
})
describe('with token authentication', () => {
it('should successfully get workspace token with existing token', async () => {
const result = await getWorkspaceToken('https://api.example.com', {
token: 'existing-token-789',
workspace: 'test-workspace'
})
expect(mockAccountClient.login).not.toHaveBeenCalled()
expect(getAccountClient).toHaveBeenCalledWith(mockConfig.ACCOUNTS_URL, 'existing-token-789')
expect(mockAccountClient.selectWorkspace).toHaveBeenCalledWith('test-workspace')
expect(result).toEqual({
endpoint: mockWorkspaceInfo.endpoint,
token: mockWorkspaceInfo.token,
workspaceId: mockWorkspaceInfo.workspace,
info: mockWorkspaceInfo
})
})
it('should throw error when workspace not found with token', async () => {
mockAccountClient.selectWorkspace.mockResolvedValue(undefined)
await expect(
getWorkspaceToken('https://api.example.com', {
token: 'existing-token-789',
workspace: 'non-existent-workspace'
})
).rejects.toThrow('Workspace not found')
})
})
describe('error handling', () => {
it('should propagate config loading errors', async () => {
;(loadServerConfig as jest.Mock).mockRejectedValue(new Error('Config load failed'))
await expect(
getWorkspaceToken('https://api.example.com', {
email: 'user@example.com',
password: 'password123',
workspace: 'test-workspace'
})
).rejects.toThrow('Config load failed')
})
it('should propagate login errors', async () => {
mockAccountClient.login.mockRejectedValue(new Error('Invalid credentials'))
await expect(
getWorkspaceToken('https://api.example.com', {
email: 'user@example.com',
password: 'wrong-password',
workspace: 'test-workspace'
})
).rejects.toThrow('Invalid credentials')
})
it('should propagate workspace selection errors', async () => {
mockAccountClient.selectWorkspace.mockRejectedValue(new Error('Access denied'))
await expect(
getWorkspaceToken('https://api.example.com', {
email: 'user@example.com',
password: 'password123',
workspace: 'test-workspace'
})
).rejects.toThrow('Access denied')
})
})
describe('edge cases', () => {
it('should handle empty workspace name', async () => {
await getWorkspaceToken('https://api.example.com', {
token: 'token',
workspace: ''
})
expect(mockAccountClient.selectWorkspace).toHaveBeenCalledWith('')
})
it('should handle special characters in credentials', async () => {
await getWorkspaceToken('https://api.example.com', {
email: 'user+test@example.com',
password: 'p@ssw0rd!#$%',
workspace: 'test-workspace'
})
expect(mockAccountClient.login).toHaveBeenCalledWith('user+test@example.com', 'p@ssw0rd!#$%')
})
})
})
@@ -0,0 +1,297 @@
//
// Copyright © 2024 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 { getClient as getAccountClient } from '@hcengineering/account-client'
import client, { clientId } from '@hcengineering/client'
import {
type Account,
type Class,
type Client,
type Data,
type Doc,
type DocumentQuery,
type FindOptions,
type FindResult,
type Hierarchy,
type ModelDb,
type Ref,
type Space,
type TxResult,
type WithLookup,
AttachedData,
AttachedDoc,
DocumentUpdate,
Mixin,
MixinData,
MixinUpdate,
TxOperations,
WorkspaceUuid,
generateId,
pickPrimarySocialId
} from '@hcengineering/core'
import { addLocation, getResource } from '@hcengineering/platform'
import { type ServerConfig, loadServerConfig } from './config'
import {
type MarkupFormat,
type MarkupOperations,
type MarkupRef,
MarkupContent,
createMarkupOperations
} from './markup'
import { type ConnectOptions, type PlatformClient, WithMarkup } from './types'
import { getWorkspaceToken } from './utils'
/**
* Create platform client
* @public */
export async function connect (url: string, options: ConnectOptions): Promise<PlatformClient> {
const config = await loadServerConfig(url)
const { endpoint, token } = await getWorkspaceToken(url, options, config)
const accountClient = getAccountClient(config.ACCOUNTS_URL, token)
const socialIds = await accountClient.getSocialIds(true)
const wsLoginInfo = await accountClient.selectWorkspace(options.workspace)
if (wsLoginInfo === undefined) {
throw new Error(`Workspace ${options.workspace} not found`)
}
const account: Account = {
uuid: wsLoginInfo.account,
role: wsLoginInfo.role,
primarySocialId: pickPrimarySocialId(socialIds)._id,
socialIds: socialIds.map((si) => si._id),
fullSocialIds: socialIds
}
return await createClient(url, endpoint, token, wsLoginInfo.workspace, account, config, options)
}
async function createClient (
url: string,
endpoint: string,
token: string,
workspaceUuid: WorkspaceUuid,
account: Account,
config: ServerConfig,
options: ConnectOptions
): Promise<PlatformClient> {
addLocation(clientId, () => import(/* webpackChunkName: "client" */ '@hcengineering/client-resources'))
const { socketFactory, connectionTimeout } = options
const clientFactory = await getResource(client.function.GetClient)
const connection = await clientFactory(token, endpoint, {
socketFactory,
connectionTimeout
})
return new PlatformClientImpl(url, workspaceUuid, token, config, connection, account)
}
class PlatformClientImpl implements PlatformClient {
private readonly client: TxOperations
private readonly markup: MarkupOperations
constructor (
private readonly url: string,
private readonly workspace: WorkspaceUuid,
private readonly token: string,
private readonly config: ServerConfig,
private readonly connection: Client,
private readonly account: Account
) {
this.client = new TxOperations(connection, account.primarySocialId)
this.markup = createMarkupOperations(url, workspace, token, config)
}
// Client
getHierarchy (): Hierarchy {
return this.client.getHierarchy()
}
getModel (): ModelDb {
return this.client.getModel()
}
async getAccount (): Promise<Account> {
return this.account
}
async findOne<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<WithLookup<T> | undefined> {
return await this.client.findOne(_class, query, options)
}
async findAll<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<FindResult<T>> {
return await this.client.findAll(_class, query, options)
}
async close (): Promise<void> {
await this.connection.close()
}
private async processMarkup<T>(_class: Ref<Class<Doc>>, id: Ref<Doc>, data: WithMarkup<T>): Promise<T> {
const result: any = {}
for (const [key, value] of Object.entries(data)) {
if (value instanceof MarkupContent) {
result[key] = this.markup.uploadMarkup(_class, id, key, value.content, value.kind)
} else {
result[key] = value
}
}
return result as T
}
// DocOperations
async createDoc<T extends Doc>(
_class: Ref<Class<T>>,
space: Ref<Space>,
attributes: WithMarkup<Data<T>>,
id?: Ref<T>
): Promise<Ref<T>> {
id ??= generateId()
const data = await this.processMarkup<Data<T>>(_class, id, attributes)
return await this.client.createDoc(_class, space, data, id)
}
async updateDoc<T extends Doc>(
_class: Ref<Class<T>>,
space: Ref<Space>,
objectId: Ref<T>,
operations: WithMarkup<DocumentUpdate<T>>,
retrieve?: boolean
): Promise<TxResult> {
const update = await this.processMarkup<DocumentUpdate<T>>(_class, objectId, operations)
return await this.client.updateDoc(_class, space, objectId, update, retrieve)
}
async removeDoc<T extends Doc>(_class: Ref<Class<T>>, space: Ref<Space>, objectId: Ref<T>): Promise<TxResult> {
return await this.client.removeDoc(_class, space, objectId)
}
// CollectionOperations
async addCollection<T extends Doc, P extends AttachedDoc>(
_class: Ref<Class<P>>,
space: Ref<Space>,
attachedTo: Ref<T>,
attachedToClass: Ref<Class<T>>,
collection: Extract<keyof T, string> | string,
attributes: WithMarkup<AttachedData<P>>,
id?: Ref<P>
): Promise<Ref<P>> {
id ??= generateId()
const data = await this.processMarkup<AttachedData<P>>(_class, id, attributes)
return await this.client.addCollection(_class, space, attachedTo, attachedToClass, collection, data, id)
}
async updateCollection<T extends Doc, P extends AttachedDoc>(
_class: Ref<Class<P>>,
space: Ref<Space>,
objectId: Ref<P>,
attachedTo: Ref<T>,
attachedToClass: Ref<Class<T>>,
collection: Extract<keyof T, string> | string,
operations: WithMarkup<DocumentUpdate<P>>,
retrieve?: boolean
): Promise<Ref<T>> {
const update = await this.processMarkup<DocumentUpdate<P>>(_class, objectId, operations)
return await this.client.updateCollection(
_class,
space,
objectId,
attachedTo,
attachedToClass,
collection,
update,
retrieve
)
}
async removeCollection<T extends Doc, P extends AttachedDoc>(
_class: Ref<Class<P>>,
space: Ref<Space>,
objectId: Ref<P>,
attachedTo: Ref<T>,
attachedToClass: Ref<Class<T>>,
collection: Extract<keyof T, string> | string
): Promise<Ref<T>> {
return await this.client.removeCollection(_class, space, objectId, attachedTo, attachedToClass, collection)
}
// MixinOperations
async createMixin<D extends Doc, M extends D>(
objectId: Ref<D>,
objectClass: Ref<Class<D>>,
objectSpace: Ref<Space>,
mixin: Ref<Mixin<M>>,
attributes: WithMarkup<MixinData<D, M>>
): Promise<TxResult> {
const data = await this.processMarkup<MixinData<D, M>>(objectClass, objectId, attributes)
return await this.client.createMixin(objectId, objectClass, objectSpace, mixin, data)
}
async updateMixin<D extends Doc, M extends D>(
objectId: Ref<D>,
objectClass: Ref<Class<D>>,
objectSpace: Ref<Space>,
mixin: Ref<Mixin<M>>,
attributes: WithMarkup<MixinUpdate<D, M>>
): Promise<TxResult> {
const update = await this.processMarkup<MixinUpdate<D, M>>(objectClass, objectId, attributes)
return await this.client.updateMixin(objectId, objectClass, objectSpace, mixin, update)
}
// Markup
async fetchMarkup (
objectClass: Ref<Class<Doc>>,
objectId: Ref<Doc>,
objectAttr: string,
markup: MarkupRef,
format: MarkupFormat
): Promise<string> {
return await this.markup.fetchMarkup(objectClass, objectId, objectAttr, markup, format)
}
async uploadMarkup (
objectClass: Ref<Class<Doc>>,
objectId: Ref<Doc>,
objectAttr: string,
markup: string,
format: MarkupFormat
): Promise<MarkupRef> {
return await this.markup.uploadMarkup(objectClass, objectId, objectAttr, markup, format)
}
// AsyncDisposable
async [Symbol.asyncDispose] (): Promise<void> {
await this.close()
}
}
@@ -0,0 +1,32 @@
//
// Copyright © 2024 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 { concatLink } from '@hcengineering/core'
export interface ServerConfig {
ACCOUNTS_URL: string
COLLABORATOR_URL: string
FILES_URL: string
UPLOAD_URL: string
}
export async function loadServerConfig (url: string): Promise<ServerConfig> {
const configUrl = concatLink(url, '/config.json')
const res = await fetch(configUrl, { keepalive: true })
if (res.ok) {
return (await res.json()) as ServerConfig
}
throw new Error('Failed to fetch config')
}
@@ -0,0 +1,23 @@
//
// Copyright © 2024 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.
//
export * from './client'
export * from './markup/types'
export * from './socket'
export * from './types'
export * from './rest'
export * from './config'
export * from './utils'
export * from './storage'
@@ -0,0 +1,106 @@
//
// Copyright © 2024 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 Class,
type Doc,
type Markup,
type Ref,
WorkspaceUuid,
concatLink,
makeCollabId
} from '@hcengineering/core'
import { type CollaboratorClient, getClient } from '@hcengineering/collaborator-client'
import { htmlToJSON, jsonToHTML, jsonToMarkup, markupToJSON } from '@hcengineering/text'
import { markdownToMarkup, markupToMarkdown } from '@hcengineering/text-markdown'
import { type ServerConfig } from '../config'
import { type MarkupOperations, type MarkupFormat, type MarkupRef } from './types'
export function createMarkupOperations (
url: string,
workspace: WorkspaceUuid,
token: string,
config: ServerConfig
): MarkupOperations {
return new MarkupOperationsImpl(url, workspace, token, config)
}
class MarkupOperationsImpl implements MarkupOperations {
private readonly collaborator: CollaboratorClient
private readonly imageUrl: string
private readonly refUrl: string
constructor (
private readonly url: string,
private readonly workspace: WorkspaceUuid,
private readonly token: string,
private readonly config: ServerConfig
) {
this.refUrl = concatLink(this.url, `/browse?workspace=${workspace}`)
this.imageUrl = concatLink(this.url, `/files?workspace=${workspace}&file=`)
this.collaborator = getClient(workspace, token, config.COLLABORATOR_URL)
}
async fetchMarkup (
objectClass: Ref<Class<Doc>>,
objectId: Ref<Doc>,
objectAttr: string,
doc: MarkupRef,
format: MarkupFormat
): Promise<string> {
const collabId = makeCollabId(objectClass, objectId, objectAttr)
const markup = await this.collaborator.getMarkup(collabId, doc)
const json = markupToJSON(markup)
switch (format) {
case 'markup':
return markup
case 'html':
return jsonToHTML(json)
case 'markdown':
return markupToMarkdown(json, { refUrl: this.refUrl, imageUrl: this.imageUrl })
default:
throw new Error('Unknown content format')
}
}
async uploadMarkup (
objectClass: Ref<Class<Doc>>,
objectId: Ref<Doc>,
objectAttr: string,
value: string,
format: MarkupFormat
): Promise<MarkupRef> {
let markup: Markup = ''
switch (format) {
case 'markup':
markup = value
break
case 'html':
markup = jsonToMarkup(htmlToJSON(value))
break
case 'markdown':
markup = jsonToMarkup(markdownToMarkup(value, { refUrl: this.refUrl, imageUrl: this.imageUrl }))
break
default:
throw new Error('Unknown content format')
}
const collabId = makeCollabId(objectClass, objectId, objectAttr)
return await this.collaborator.createMarkup(collabId, markup)
}
}
@@ -0,0 +1,17 @@
//
// Copyright © 2024 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.
//
export * from './client'
export * from './types'
@@ -0,0 +1,79 @@
//
// Copyright © 2024 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 { Class, type Blob, type Doc, type Ref } from '@hcengineering/core'
/** @public */
export type MarkupRef = Ref<Blob>
/** @public */
export type MarkupFormat = 'markup' | 'html' | 'markdown'
/** @public */
export class MarkupContent {
constructor (
readonly content: string,
readonly kind: MarkupFormat
) {}
}
/** @public */
export function html (content: string): MarkupContent {
return new MarkupContent(content, 'html')
}
/** @public */
export function markdown (content: string): MarkupContent {
return new MarkupContent(content, 'markdown')
}
/**
* Provides operations for managing markup (rich-text) content.
* @public */
export interface MarkupOperations {
/**
* Retrieves markup content for a specified document object
* * @param objectClass - Reference to the class of the document containing the markup
* @param objectId - Reference to the document containing the markup
* @param objectAttr - The attribute/field name where the markup is stored
* @param id - Unique reference identifying the specific markup content
* @param format - The format of the markup (e.g., HTML, Markdown, etc.)
* @returns Promise containing the markup content as a string
*/
fetchMarkup: (
objectClass: Ref<Class<Doc>>,
objectId: Ref<Doc>,
objectAttr: string,
id: MarkupRef,
format: MarkupFormat
) => Promise<string>
/**
* Saves markup content for a document object
* @param objectClass - Reference to the class of the document where markup should be stored
* @param objectId - Reference to the document where markup should be stored
* @param objectAttr - The attribute/field name where markup should be saved
* @param markup - The actual markup content to be uploaded
* @param format - The format of the provided markup (e.g., HTML, Markdown, etc.)
* @returns Promise containing a reference to the newly saved markup
*/
uploadMarkup: (
objectClass: Ref<Class<Doc>>,
objectId: Ref<Doc>,
objectAttr: string,
markup: string,
format: MarkupFormat
) => Promise<MarkupRef>
}
@@ -0,0 +1,18 @@
//
// Copyright © 2025 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.
//
export { createRestClient, connectRest } from './rest'
export { createRestTxOperations } from './tx'
export * from './types'
@@ -0,0 +1,368 @@
//
// Copyright © 2025 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 Account,
buildModel,
type Class,
concatLink,
type Doc,
type DocumentQuery,
type DomainParams,
type DomainRequestOptions,
type DomainResult,
type FindOptions,
type FindResult,
Hierarchy,
MeasureMetricsContext,
ModelDb,
OperationDomain,
PersonId,
PersonUuid,
type Ref,
type SearchOptions,
type SearchQuery,
type SearchResult,
SocialIdType,
type Tx,
type TxResult,
type WithLookup
} from '@hcengineering/core'
import { PlatformError, type Status, unknownError } from '@hcengineering/platform'
import { AuthOptions } from '../types'
import { getWorkspaceToken } from '../utils'
import type { RestClient } from './types'
import { extractJson, withRetry } from './utils'
export function createRestClient (endpoint: string, workspaceId: string, token: string): RestClient {
return new RestClientImpl(endpoint, workspaceId, token)
}
export async function connectRest (url: string, options: AuthOptions): Promise<RestClient> {
const { endpoint, token, workspaceId } = await getWorkspaceToken(url, options)
return createRestClient(endpoint, workspaceId, token)
}
const rateLimitError = 'rate-limit'
function isRLE (err: any): boolean {
return err.message === rateLimitError
}
export class RestClientImpl implements RestClient {
endpoint: string
slowDownTimer = 0
currentRateLimit: { remaining: number, limit: number } = { remaining: 1000, limit: 1000 }
remaining: number = 1000
limit: number = 1000
constructor (
endpoint: string,
readonly workspace: string,
readonly token: string
) {
this.endpoint = endpoint.replace('ws', 'http')
}
jsonHeaders (): Record<string, string> {
return {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + this.token,
'accept-encoding': 'snappy, gzip'
}
}
requestInit (): RequestInit {
return {
method: 'GET',
keepalive: true,
headers: this.jsonHeaders()
}
}
async findAll<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<FindResult<T>> {
const params = new URLSearchParams()
params.append('class', _class)
if (query !== undefined && Object.keys(query).length > 0) {
params.append('query', JSON.stringify(query))
}
if (options !== undefined && Object.keys(options).length > 0) {
params.append('options', JSON.stringify(options))
}
const requestUrl = concatLink(this.endpoint, `/api/v1/find-all/${this.workspace}?${params.toString()}`)
const result = await withRetry<FindResult<T> & { error?: Status }>(async () => {
const response = await fetch(requestUrl, this.requestInit())
if (!response.ok) {
await this.checkRateLimits(response)
throw new PlatformError(unknownError(response.statusText))
}
this.updateRateLimit(response)
return await extractJson<FindResult<T>>(response)
}, isRLE)
if (result.error !== undefined) {
throw new PlatformError(result.error)
}
if (result.lookupMap !== undefined) {
// We need to extract lookup map to document lookups
for (const d of result) {
if (d.$lookup !== undefined) {
for (const [k, v] of Object.entries(d.$lookup)) {
if (!Array.isArray(v)) {
;(d as any).$lookup[k] = result.lookupMap[v]
} else {
;(d as any).$lookup[k] = v.map((it) => result.lookupMap?.[it])
}
}
}
}
delete result.lookupMap
}
// We need to revert deleted query simple values.
// We need to get rid of simple query parameters matched in documents
for (const doc of result) {
if (doc._class == null) {
doc._class = _class
}
for (const [k, v] of Object.entries(query)) {
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
if ((doc as any)[k] == null) {
;(doc as any)[k] = v
}
}
}
}
return result
}
private async checkRate (): Promise<void> {
if (this.currentRateLimit.remaining < this.currentRateLimit.limit / 3) {
if (this.slowDownTimer < 50) {
this.slowDownTimer += 50
}
this.slowDownTimer++
} else if (this.slowDownTimer > 0) {
this.slowDownTimer--
}
if (this.slowDownTimer > 0) {
// We need to wait a bit to avoid ban.
await new Promise((resolve) => setTimeout(resolve, this.slowDownTimer))
}
}
private updateRateLimit (response: Response): void {
const rateLimitLimit: number = parseInt(response.headers.get('X-RateLimit-Limit') ?? '100')
const remaining: number = parseInt(response.headers.get('X-RateLimit-Remaining') ?? '100')
this.currentRateLimit = { remaining, limit: rateLimitLimit }
}
private async checkRateLimits (response: Response): Promise<void> {
if (response.status === 429) {
// Extract rate limit information from headers
const retryAfter = response.headers.get('Retry-After')
const retryAfterMS = response.headers.get('Retry-After-ms')
const rateLimitReset = response.headers.get('X-RateLimit-Reset')
this.updateRateLimit(response)
const waitTime =
(retryAfterMS != null ? parseInt(retryAfterMS) : undefined) ??
(retryAfter != null
? parseInt(retryAfter) * 1000
: rateLimitReset != null
? new Date(parseInt(rateLimitReset)).getTime() - Date.now()
: 1000) // Default to 1 seconds if no headers are provided
await new Promise((resolve) => setTimeout(resolve, waitTime))
throw new Error(rateLimitError)
}
}
async getAccount (): Promise<Account> {
const requestUrl = concatLink(this.endpoint, `/api/v1/account/${this.workspace}`)
await this.checkRate()
const result = await withRetry<Account & { error?: Status }>(async () => {
const response = await fetch(requestUrl, this.requestInit())
if (!response.ok) {
await this.checkRateLimits(response)
throw new PlatformError(unknownError(response.statusText))
}
this.updateRateLimit(response)
return await extractJson<Account>(response)
})
if (result.error !== undefined) {
throw new PlatformError(result.error)
}
return result
}
async getModel (full: boolean = false): Promise<{ hierarchy: Hierarchy, model: ModelDb }> {
const requestUrl = new URL(concatLink(this.endpoint, `/api/v1/load-model/${this.workspace}`))
if (full) {
requestUrl.searchParams.append('full', 'true')
}
await this.checkRate()
const result = await withRetry<{ hierarchy: Hierarchy, model: ModelDb, error?: Status }>(async () => {
const response = await fetch(requestUrl, this.requestInit())
if (!response.ok) {
await this.checkRateLimits(response)
throw new PlatformError(unknownError(response.statusText))
}
this.updateRateLimit(response)
const modelResponse: Tx[] = await extractJson<Tx[]>(response)
const hierarchy = new Hierarchy()
const model = new ModelDb(hierarchy)
const ctx = new MeasureMetricsContext('loadModel', {})
buildModel(ctx, modelResponse, undefined, hierarchy, model)
return { hierarchy, model }
}, isRLE)
if (result.error !== undefined) {
throw new PlatformError(result.error)
}
return result
}
async findOne<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<WithLookup<T> | undefined> {
return (await this.findAll(_class, query, { ...options, limit: 1 })).shift()
}
async tx (tx: Tx): Promise<TxResult> {
const requestUrl = concatLink(this.endpoint, `/api/v1/tx/${this.workspace}`)
await this.checkRate()
const result = await withRetry<TxResult & { error?: Status }>(async () => {
const response = await fetch(requestUrl, {
method: 'POST',
headers: this.jsonHeaders(),
keepalive: true,
body: JSON.stringify(tx)
})
if (!response.ok) {
await this.checkRateLimits(response)
throw new PlatformError(unknownError(response.statusText))
}
this.updateRateLimit(response)
return await extractJson<TxResult>(response)
}, isRLE)
if (result.error !== undefined) {
throw new PlatformError(result.error)
}
return result
}
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
const result = await withRetry<SearchResult & { error?: Status }>(async () => {
const params = new URLSearchParams()
params.append('query', query.query)
if (query.classes != null && Object.keys(query.classes).length > 0) {
params.append('classes', JSON.stringify(query.classes))
}
if (query.spaces != null && Object.keys(query.spaces).length > 0) {
params.append('spaces', JSON.stringify(query.spaces))
}
if (options.limit != null) {
params.append('limit', `${options.limit}`)
}
const requestUrl = concatLink(this.endpoint, `/api/v1/search-fulltext/${this.workspace}?${params.toString()}`)
const response = await fetch(requestUrl, {
method: 'GET',
headers: this.jsonHeaders(),
keepalive: true
})
if (!response.ok) {
await this.checkRateLimits(response)
throw new PlatformError(unknownError(response.statusText))
}
this.updateRateLimit(response)
return await extractJson<TxResult>(response)
})
if (result.error !== undefined) {
throw new PlatformError(result.error)
}
return result
}
async domainRequest<T>(
domain: OperationDomain,
params: DomainParams,
options?: DomainRequestOptions
): Promise<DomainResult<T>> {
const requestUrl = concatLink(this.endpoint, `/api/v1/request/${domain}/${this.workspace}`)
await this.checkRate()
return await withRetry(async () => {
const response = await fetch(requestUrl, {
method: 'POST',
headers: this.jsonHeaders(),
keepalive: true,
body: JSON.stringify(params)
})
if (!response.ok) {
await this.checkRateLimits(response)
throw new PlatformError(unknownError(response.statusText))
}
this.updateRateLimit(response)
const value = await extractJson<T>(response)
return { domain, value }
}, isRLE)
}
async ensurePerson (
socialType: SocialIdType,
socialValue: string,
firstName: string,
lastName: string
): Promise<{ uuid: PersonUuid, socialId: PersonId, localPerson: string }> {
const requestUrl = concatLink(this.endpoint, `/api/v1/ensure-person/${this.workspace}`)
await this.checkRate()
const result = await withRetry(async () => {
const response = await fetch(requestUrl, {
method: 'POST',
headers: this.jsonHeaders(),
keepalive: true,
body: JSON.stringify({
socialType,
socialValue,
firstName,
lastName
})
})
if (!response.ok) {
await this.checkRateLimits(response)
throw new PlatformError(unknownError(response.statusText))
}
this.updateRateLimit(response)
return await extractJson<{ uuid: PersonUuid, socialId: PersonId, localPerson: string }>(response)
}, isRLE)
if (result.error !== undefined) {
throw new PlatformError(result.error)
}
return result
}
}
@@ -0,0 +1,114 @@
//
// Copyright © 2025 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 Account,
type Class,
type Client,
type Doc,
type DocumentQuery,
type DomainParams,
type DomainRequestOptions,
type DomainResult,
type FindOptions,
type FindResult,
Hierarchy,
ModelDb,
type OperationDomain,
type Ref,
type SearchOptions,
type SearchQuery,
type SearchResult,
toFindResult,
type Tx,
TxOperations,
type TxResult,
type WithLookup
} from '@hcengineering/core'
import { RestClientImpl } from './rest'
export async function createRestTxOperations (
endpoint: string,
workspaceId: string,
token: string,
fullModel: boolean = false
): Promise<TxOperations> {
const restClient = new RestClientImpl(endpoint, workspaceId, token)
const account = await restClient.getAccount()
const { hierarchy, model } = await restClient.getModel(fullModel)
return new TxOperations(new RestTxClient(restClient, hierarchy, model, account), account.socialIds[0])
}
class RestTxClient implements Client {
constructor (
readonly client: RestClientImpl,
readonly hierarchy: Hierarchy,
readonly model: ModelDb,
readonly account: Account
) {}
close (): Promise<void> {
return Promise.resolve()
}
async findAll<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<FindResult<T>> {
const data = await this.client.findAll(_class, query, options)
const result = data.map((v) => {
return this.hierarchy.updateLookupMixin(_class, v, options)
})
return toFindResult(result, data.total)
}
async domainRequest<T>(
domain: OperationDomain,
params: DomainParams,
options?: DomainRequestOptions
): Promise<DomainResult<T>> {
return await this.client.domainRequest(domain, params, options)
}
async findOne<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<WithLookup<T> | undefined> {
const v = await this.client.findOne(_class, query, options)
if (v === undefined) {
return
}
return this.hierarchy.updateLookupMixin(_class, v, options)
}
getHierarchy: () => Hierarchy = () => this.hierarchy
getModel: () => ModelDb = () => this.model
async getAccount (): Promise<Account> {
return this.account
}
async tx (tx: Tx): Promise<TxResult> {
return await this.client.tx(tx)
}
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
return await this.client.searchFulltext(query, options)
}
}
@@ -0,0 +1,60 @@
//
// Copyright © 2025 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 Account,
type Class,
type Doc,
type DocumentQuery,
type DomainParams,
type DomainRequestOptions,
type DomainResult,
type FindOptions,
type FulltextStorage,
type Hierarchy,
type ModelDb,
type OperationDomain,
type PersonId,
type PersonUuid,
type Ref,
type SocialIdType,
type Storage,
type WithLookup
} from '@hcengineering/core'
export interface RestClient extends Storage, FulltextStorage {
getAccount: () => Promise<Account>
findOne: <T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
) => Promise<WithLookup<T> | undefined>
getModel: () => Promise<{ hierarchy: Hierarchy, model: ModelDb }>
domainRequest: <T>(
domain: OperationDomain,
params: DomainParams,
options?: DomainRequestOptions
) => Promise<DomainResult<T>>
ensurePerson: (
socialType: SocialIdType,
socialValue: string,
firstName: string,
lastName: string
) => Promise<{ uuid: PersonUuid, socialId: PersonId, localPerson: string }>
}
@@ -0,0 +1,46 @@
import { uncompress } from 'snappyjs'
export async function withRetry<T> (fn: () => Promise<T>, ignoreAttemptCheck?: (err: any) => boolean): Promise<T> {
const maxRetries = 3
let lastError: any
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn()
} catch (err: any) {
if (ignoreAttemptCheck !== undefined && ignoreAttemptCheck(err)) {
// Do not decrement attempt
attempt--
} else {
lastError = err
}
if (attempt === maxRetries - 1) {
throw lastError
}
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100))
}
}
throw lastError
}
function rpcJSONReceiver (key: string, value: any): any {
if (typeof value === 'object' && value !== null) {
if (value.dataType === 'TotalArray') {
return Object.assign(value.value, { total: value.total, lookupMap: value.lookupMap })
}
}
return value
}
export async function extractJson<T> (response: Response): Promise<any> {
const encoding = response.headers.get('content-encoding')
if (encoding === 'snappy') {
const buffer = await response.arrayBuffer()
const decompressed = uncompress(buffer)
const decoder = new TextDecoder()
const jsonString = decoder.decode(decompressed)
return JSON.parse(jsonString, rpcJSONReceiver) as T
}
const jsonString = await response.text()
return JSON.parse(jsonString, rpcJSONReceiver) as T
}
@@ -0,0 +1,22 @@
//
// Copyright © 2024 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 ClientSocket, type ClientSocketFactory } from '@hcengineering/client'
/** @public */
export const BrowserWebSocketFactory: ClientSocketFactory = (url: string): ClientSocket => {
const ws = new WebSocket(url)
return ws as ClientSocket
}
@@ -0,0 +1,17 @@
//
// Copyright © 2024 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.
//
export * from './browser'
export * from './node'
@@ -0,0 +1,102 @@
//
// Copyright © 2024 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 ClientSocket, type ClientSocketFactory } from '@hcengineering/client'
/** @public */
export const NodeWebSocketFactory: ClientSocketFactory = (url: string): ClientSocket => {
// We need to override default factory with 'ws' one.
// eslint-disable-next-line
let WebSocket
try {
WebSocket = require('ws')
} catch (error) {
throw new Error('The "ws" package is required for NodeWebSocketFactory. ')
}
type WebSocketData = Parameters<typeof ws.on>[1]
const ws = new WebSocket(url)
const client: ClientSocket = {
get readyState (): number {
return ws.readyState
},
send: (data: string | ArrayBufferLike | Blob | ArrayBufferView): void => {
if (data instanceof Blob) {
void data.arrayBuffer().then((buffer) => {
ws.send(buffer)
})
} else {
ws.send(data)
}
},
close: (code?: number): void => {
ws.close(code)
}
}
ws.on('message', (data: WebSocketData) => {
if (client.onmessage != null) {
const event = {
data,
type: 'message',
target: this
} as unknown as MessageEvent
client.onmessage(event)
}
})
ws.on('close', (code: number, reason: string) => {
if (client.onclose != null) {
const closeEvent = {
code,
reason,
wasClean: code === 1000,
type: 'close',
target: this
} as unknown as CloseEvent
client.onclose(closeEvent)
}
})
ws.on('open', () => {
if (client.onopen != null) {
const event = {
type: 'open',
target: this
} as unknown as Event
client.onopen(event)
}
})
ws.on('error', (error: Error) => {
if (client.onerror != null) {
const event = {
type: 'error',
target: this,
error
} as unknown as Event
client.onerror(event)
}
})
return client
}
@@ -0,0 +1,220 @@
//
// Copyright © 2025 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 core, { concatLink, WorkspaceUuid, Blob, Ref } from '@hcengineering/core'
import { Readable } from 'stream'
import { StorageClient } from './types'
import { loadServerConfig, ServerConfig } from '../config'
import { NetworkError, NotFoundError, StorageError } from './error'
import { AuthOptions } from '../types'
import { getWorkspaceToken } from '../utils'
interface ObjectMetadata {
name: string
etag: string
size: number
contentType: string
lastModified: number
cacheControl?: string
}
interface BlobUploadSuccess {
key: string
id: string
metadata: ObjectMetadata
}
interface BlobUploadError {
key: string
error: string
}
type BlobUploadResult = BlobUploadSuccess | BlobUploadError
export class StorageClientImpl implements StorageClient {
private readonly headers: Record<string, string>
constructor (
readonly filesUrl: string,
readonly uploadUrl: string,
token: string,
readonly workspace: WorkspaceUuid
) {
this.headers = {
Authorization: 'Bearer ' + token
}
}
getObjectUrl (objectName: string): string {
return this.filesUrl.replace(':filename', objectName).replace(':blobId', objectName)
}
async stat (objectName: string): Promise<Blob | undefined> {
const url = this.getObjectUrl(objectName)
let response
try {
response = await wrappedFetch(url, { method: 'HEAD', headers: { ...this.headers } })
} catch (error: any) {
if (error instanceof NotFoundError) {
return
}
throw error
}
const headers = response.headers
const lastModified = Date.parse(headers.get('Last-Modified') ?? '')
const size = parseInt(headers.get('Content-Length') ?? '0', 10)
return {
provider: '',
_class: core.class.Blob,
_id: objectName as Ref<Blob>,
contentType: headers.get('Content-Type') ?? '',
size: isNaN(size) ? 0 : (size ?? 0),
etag: headers.get('ETag') ?? '',
space: core.space.Configuration,
modifiedBy: core.account.System,
modifiedOn: isNaN(lastModified) ? 0 : lastModified,
version: null
}
}
async get (objectName: string): Promise<Readable> {
const url = this.getObjectUrl(objectName)
const response = await wrappedFetch(url, { headers: { ...this.headers } })
if (response.body == null) {
throw new StorageError('Missing response body')
}
return Readable.from(response.body)
}
async put (objectName: string, stream: Readable | Buffer | string, contentType: string, size?: number): Promise<Blob> {
const buffer = await toBuffer(stream)
const file = new File([new Uint8Array(buffer)], objectName, { type: contentType })
const formData = new FormData()
formData.append('file', file)
let response
try {
response = await fetch(this.uploadUrl, {
method: 'POST',
body: formData,
headers: { ...this.headers }
})
} catch (error: any) {
throw new NetworkError(`Network error ${error}`)
}
if (!response.ok) {
throw new StorageError(await response.text())
}
const result = (await response.json()) as BlobUploadResult[]
if (Object.hasOwn(result[0], 'id')) {
const fileResult = result[0] as BlobUploadSuccess
return {
_class: core.class.Blob,
_id: fileResult.id as Ref<Blob>,
space: core.space.Configuration,
modifiedOn: fileResult.metadata.lastModified,
modifiedBy: core.account.System,
provider: '',
contentType: fileResult.metadata.contentType,
etag: fileResult.metadata.etag,
version: null,
size: fileResult.metadata.size
}
} else {
const error = (result[0] as BlobUploadError) ?? 'Unknown error'
throw new StorageError(`Storage error ${error.error}`)
}
}
async partial (objectName: string, offset: number, length?: number): Promise<Readable> {
const url = this.getObjectUrl(objectName)
const response = await wrappedFetch(url, {
headers: {
...this.headers,
Range: length !== undefined ? `bytes=${offset}-${offset + length - 1}` : `bytes=${offset}`
}
})
if (response.body == null) {
throw new StorageError('Missing response body')
}
return Readable.from(response.body)
}
async remove (objectName: string): Promise<void> {
const url = this.getObjectUrl(objectName)
await wrappedFetch(url, {
method: 'DELETE',
headers: { ...this.headers }
})
}
}
async function toBuffer (data: Buffer | string | Readable): Promise<Buffer> {
if (Buffer.isBuffer(data)) {
return data
} else if (typeof data === 'string') {
return Buffer.from(data)
} else if (data instanceof Readable) {
const chunks: Buffer[] = []
for await (const chunk of data) {
chunks.push(chunk)
}
return Buffer.concat(chunks as any)
} else {
throw new TypeError('Unsupported data type')
}
}
async function wrappedFetch (url: string | URL, init?: RequestInit): Promise<Response> {
let response: Response
try {
response = await fetch(url, init)
} catch (error: any) {
throw new NetworkError(`Network error ${error}`)
}
if (!response.ok) {
const text = await response.text()
if (response.status === 404) {
throw new NotFoundError(text)
} else {
throw new StorageError(text)
}
}
return response
}
export function createStorageClient (
filesUrl: string,
uploadUrl: string,
token: string,
workspace: WorkspaceUuid
): StorageClient {
return new StorageClientImpl(filesUrl, uploadUrl, token, workspace)
}
export async function connectStorage (url: string, options: AuthOptions, config?: ServerConfig): Promise<StorageClient> {
config ??= await loadServerConfig(url)
const token = await getWorkspaceToken(url, options, config)
const filesUrl = (config.FILES_URL.startsWith('/') ? concatLink(url, config.FILES_URL) : config.FILES_URL).replace(
':workspace',
token.workspaceId
)
const uploadUrl = (
config.UPLOAD_URL.startsWith('/') ? concatLink(url, config.UPLOAD_URL) : config.UPLOAD_URL
).replace(':workspace', token.workspaceId)
return new StorageClientImpl(filesUrl, uploadUrl, token.token, token.workspaceId)
}
@@ -0,0 +1,35 @@
//
// Copyright © 2025 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.
//
export class NetworkError extends Error {
constructor (message: string) {
super(message)
this.name = 'NetworkError'
}
}
export class StorageError extends Error {
constructor (message: string) {
super(message)
this.name = 'StorageError'
}
}
export class NotFoundError extends StorageError {
constructor (message = 'Not Found') {
super(message)
this.name = 'NotFoundError'
}
}
@@ -0,0 +1,18 @@
//
// Copyright © 2025 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.
//
export { createStorageClient, connectStorage } from './client'
export * from './error'
export * from './types'
@@ -0,0 +1,25 @@
//
// Copyright © 2025 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 { Blob } from '@hcengineering/core'
import { Readable } from 'stream'
export interface StorageClient {
stat: (objectName: string) => Promise<Blob | undefined>
get: (objectName: string) => Promise<Readable>
put: (objectName: string, stream: Readable | Buffer | string, contentType: string, size?: number) => Promise<Blob>
partial: (objectName: string, offset: number, length?: number) => Promise<Readable>
remove: (objectName: string) => Promise<void>
}
@@ -0,0 +1,224 @@
//
// Copyright © 2024 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 ClientSocketFactory } from '@hcengineering/client'
import {
CollaborativeDoc,
type Account,
type AttachedData,
type AttachedDoc,
type Class,
type Data,
type Doc,
type DocumentQuery,
type DocumentUpdate,
type FindOptions,
type FindResult,
type Hierarchy,
type Mixin,
type MixinData,
type MixinUpdate,
type ModelDb,
type Ref,
type Space,
type TxResult,
type WithLookup
} from '@hcengineering/core'
import { type MarkupContent, type MarkupOperations } from './markup'
type WithPropertyType<T, X, Y> = {
[P in keyof T]: T[P] extends X ? Y : T[P]
}
/** @public */
export type WithMarkup<T> = WithPropertyType<
WithPropertyType<T, CollaborativeDoc | undefined, MarkupContent | undefined>,
CollaborativeDoc,
MarkupContent
>
/**
* Platform API client
* @public
* */
export type PlatformClient = {
getHierarchy: () => Hierarchy
getModel: () => ModelDb
getAccount: () => Promise<Account>
close: () => Promise<void>
} & FindOperations &
DocOperations &
CollectionOperations &
MixinOperations &
MarkupOperations &
AsyncDisposable
/**
* @public
*/
export interface FindOperations {
findAll: <T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T> | undefined
) => Promise<FindResult<T>>
findOne: <T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T> | undefined
) => Promise<WithLookup<T> | undefined>
}
/**
* @public
*/
export interface DocOperations {
createDoc: <T extends Doc>(
_class: Ref<Class<T>>,
space: Ref<Space>,
attributes: WithMarkup<Data<T>>,
id?: Ref<T>
) => Promise<Ref<T>>
updateDoc: <T extends Doc>(
_class: Ref<Class<T>>,
space: Ref<Space>,
objectId: Ref<T>,
operations: WithMarkup<DocumentUpdate<T>>,
retrieve?: boolean
) => Promise<TxResult>
removeDoc: <T extends Doc>(_class: Ref<Class<T>>, space: Ref<Space>, objectId: Ref<T>) => Promise<TxResult>
}
/**
* @public
*/
export interface CollectionOperations {
addCollection: <T extends Doc, P extends AttachedDoc>(
_class: Ref<Class<P>>,
space: Ref<Space>,
attachedTo: Ref<T>,
attachedToClass: Ref<Class<T>>,
collection: Extract<keyof T, string> | string,
attributes: WithMarkup<AttachedData<P>>,
id?: Ref<P>
) => Promise<Ref<P>>
updateCollection: <T extends Doc, P extends AttachedDoc>(
_class: Ref<Class<P>>,
space: Ref<Space>,
objectId: Ref<P>,
attachedTo: Ref<T>,
attachedToClass: Ref<Class<T>>,
collection: Extract<keyof T, string> | string,
operations: WithMarkup<DocumentUpdate<P>>,
retrieve?: boolean
) => Promise<Ref<T>>
removeCollection: <T extends Doc, P extends AttachedDoc>(
_class: Ref<Class<P>>,
space: Ref<Space>,
objectId: Ref<P>,
attachedTo: Ref<T>,
attachedToClass: Ref<Class<T>>,
collection: Extract<keyof T, string> | string
) => Promise<Ref<T>>
}
/**
* @public
*/
export interface MixinOperations {
createMixin: <D extends Doc, M extends D>(
objectId: Ref<D>,
objectClass: Ref<Class<D>>,
objectSpace: Ref<Space>,
mixin: Ref<Mixin<M>>,
attributes: WithMarkup<MixinData<D, M>>
) => Promise<TxResult>
updateMixin: <D extends Doc, M extends D>(
objectId: Ref<D>,
objectClass: Ref<Class<D>>,
objectSpace: Ref<Space>,
mixin: Ref<Mixin<M>>,
attributes: WithMarkup<MixinUpdate<D, M>>
) => Promise<TxResult>
}
/**
* Configuration options for password-based authentication
* @public
*/
export interface PasswordAuthOptions {
/** User's email address */
email: string
/** User's password */
password: string
/** Workspace URL name */
workspace: string
}
/**
* Configuration options for token-based authentication
* @public
*/
export interface TokenAuthOptions {
/** Authentication token */
token: string
/** Workspace URL name */
workspace: string
}
/**
* Union type representing all authentication options
* Can be either password-based or token-based authentication
* @public
*/
export type AuthOptions = PasswordAuthOptions | TokenAuthOptions
/**
* Configuration options for socket connection
* @public
*/
export interface ConnectSocketOptions {
/**
* Optional factory for creating custom WebSocket implementations
* Particularly useful in Node.js environments where you might need
* to provide a specific WebSocket client implementation
* If not provided, a default WebSocket implementation will be used
*/
socketFactory?: ClientSocketFactory
/**
* Optional timeout duration for the connection attempt in milliseconds
* Specifies how long to wait for a connection before timing out
*/
connectionTimeout?: number
}
/**
* API connect options
* @public
*/
export type ConnectOptions = ConnectSocketOptions & AuthOptions
@@ -0,0 +1,55 @@
//
// Copyright © 2025 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 WorkspaceLoginInfo, getClient as getAccountClient } from '@hcengineering/account-client'
import { WorkspaceUuid } from '@hcengineering/core'
import { AuthOptions } from './types'
import { loadServerConfig, ServerConfig } from './config'
export interface WorkspaceToken {
endpoint: string
token: string
workspaceId: WorkspaceUuid
info: WorkspaceLoginInfo
}
export async function getWorkspaceToken (
url: string,
options: AuthOptions,
config?: ServerConfig
): Promise<WorkspaceToken> {
config ??= await loadServerConfig(url)
let token: string | undefined
if ('token' in options) {
token = options.token
} else {
const { email, password } = options
const loginInfo = await getAccountClient(config.ACCOUNTS_URL).login(email, password)
token = loginInfo.token
}
if (token === undefined) {
throw new Error('Login failed')
}
const ws = await getAccountClient(config.ACCOUNTS_URL, token).selectWorkspace(options.workspace)
if (ws === undefined) {
throw new Error('Workspace not found')
}
return { endpoint: ws.endpoint, token: ws.token, workspaceId: ws.workspace, info: ws }
}
@@ -0,0 +1,12 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declaration": true,
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo",
"types": ["node", "jest"]
}
}
@@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}

Some files were not shown because too many files have changed in this diff Show More