Compare commits

..

2 Commits

Author SHA1 Message Date
0900db5b21 Build standalone without pkg
Change-type: major
2024-12-30 14:32:21 -03:00
4156e71c38 Update oclif to 4.17.0 and @oclif/core 4.1.0
Change-type: patch
2024-12-30 14:27:26 -03:00
58 changed files with 1869 additions and 4319 deletions

2
.eslintignore Normal file
View File

@ -0,0 +1,2 @@
/completion/*
/bin/*

21
.eslintrc.js Normal file
View File

@ -0,0 +1,21 @@
module.exports = {
extends: ['./node_modules/@balena/lint/config/.eslintrc.js'],
parserOptions: {
project: 'tsconfig.dev.json',
},
root: true,
rules: {
ignoreDefinitionFiles: 0,
// to avoid the `warning Forbidden non-null assertion @typescript-eslint/no-non-null-assertion`
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-shadow': 'off',
'@typescript-eslint/no-var-requires': 'off',
'no-restricted-imports': [
'error',
{
paths: ['resin-cli-visuals', 'chalk', 'common-tags', 'resin-cli-form'],
},
],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
};

View File

@ -67,7 +67,7 @@ fixed it.
- **Cloud backend: openBalena or balenaCloud?** If unsure, it will be balenaCloud
- **Operating system version:** e.g. Windows 10, Ubuntu 18.04, macOS 10.14.5
- **32/64 bit OS and processor:** e.g. 32-bit Windows on 64-bit Intel processor
- **Install method:** npm or standalone package or executable installer
- **Install method:** npm or zip package or executable installer
- **If npm install, Node.js and npm version:** e.g. Node v8.16.0 and npm v6.4.1
# Additional References

View File

@ -18,7 +18,7 @@ inputs:
default: 'accounts+apple@balena.io'
NODE_VERSION:
type: string
default: '22.x'
default: '20.x'
VERBOSE:
type: string
default: 'true'
@ -28,7 +28,7 @@ runs:
using: 'composite'
steps:
- name: Download custom source artifact
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
name: custom-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}-${{ runner.os }}-${{ runner.arch }}
path: ${{ runner.temp }}
@ -39,7 +39,7 @@ runs:
run: tar -xf ${{ runner.temp }}/custom.tgz
- name: Setup Node.js
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4
with:
node-version: ${{ inputs.NODE_VERSION }}
cache: npm
@ -48,7 +48,7 @@ runs:
if: runner.os == 'macOS'
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4
with:
python-version: '3.11'
python-version: "3.11"
- name: Install additional tools
if: runner.os == 'Windows'
@ -94,7 +94,7 @@ runs:
runner_arch="$(echo "${RUNNER_ARCH}" | tr '[:upper:]' '[:lower:]')"
if [[ $runner_os =~ darwin|macos|osx ]]; then
CSC_KEY_PASSWORD='${{ fromJSON(inputs.secrets).APPLE_SIGNING_PASSWORD }}'
CSC_KEY_PASSWORD=${{ fromJSON(inputs.secrets).APPLE_SIGNING_PASSWORD }}
CSC_KEYCHAIN=signing_temp
CSC_LINK=${{ fromJSON(inputs.secrets).APPLE_SIGNING }}
@ -112,8 +112,8 @@ runs:
PATH="/c/Program Files/DigiCert/DigiCert One Signing Manager Tools:${PATH}"
smksp_registrar.exe list
smctl.exe keypair ls
smctl.exe windows certsync
/c/Windows/System32/certutil.exe -csp "DigiCert Signing Manager KSP" -key -user
smksp_cert_sync.exe
# (signtool.exe) https://github.com/actions/runner-images/blob/main/images/win/Windows2019-Readme.md#installed-windows-sdks
PATH="/c/Program Files (x86)/Windows Kits/10/bin/${runner_arch}:${PATH}"
@ -135,7 +135,7 @@ runs:
XCODE_APP_LOADER_TEAM_ID: ${{ inputs.XCODE_APP_LOADER_TEAM_ID }}
- name: Upload artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4
with:
name: gh-release-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}-${{ strategy.job-index }}
path: |

View File

@ -15,7 +15,7 @@ inputs:
# --- custom environment
NODE_VERSION:
type: string
default: '22.x'
default: '20.x'
VERBOSE:
type: string
default: "true"
@ -26,7 +26,7 @@ runs:
steps:
# https://github.com/actions/setup-node#caching-global-packages-data
- name: Setup Node.js
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4
with:
node-version: ${{ inputs.NODE_VERSION }}
cache: npm
@ -58,7 +58,7 @@ runs:
run: tar --exclude-vcs -acf ${{ runner.temp }}/custom.tgz .
- name: Upload custom artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4
with:
name: custom-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}-${{ runner.os }}-${{ runner.arch }}
path: ${{ runner.temp }}/custom.tgz

File diff suppressed because it is too large Load Diff

View File

@ -4,385 +4,6 @@ All notable changes to this project will be documented in this file
automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY!
This project adheres to [Semantic Versioning](http://semver.org/).
## 22.1.1 - 2025-06-19
* Deploy: Limit the submitted error_message of images that fail to build to 1000 characters [Thodoris Greasidis]
## 22.1.0 - 2025-06-09
* Add support for node 22 [Otavio Jacobi]
<details>
<summary> Bump etcher-sdk to v10.0.0 [Otavio Jacobi] </summary>
> ### etcher-sdk-10.0.0 - 2025-06-02
>
> * Drop support to node18 and add support to node 22 & 24 [Otavio Jacobi]
>
</details>
## 22.0.6 - 2025-06-02
* Remove `request` dependency [myarmolinsky]
* Replace `request` usage with `got` [myarmolinsky]
## 22.0.5 - 2025-05-29
<details>
<summary> Bump etcher-sdk to v9.1.4 [Otavio Jacobi] </summary>
> ### etcher-sdk-9.1.4 - 2025-05-29
>
> * Run `npm audit fix` which should only do non-breaking changes [Otavio Jacobi]
>
> ### etcher-sdk-9.1.3 - 2025-02-17
>
> * Embed config.json with a fixed timestamp to enable consistent checksums [Pagan Gazzard]
>
> ### etcher-sdk-9.1.2 - 2024-10-09
>
> * Update dependency unzip-stream to v0.3.2 [SECURITY] [Self-hosted Renovate Bot]
>
> ### etcher-sdk-9.1.1 - 2024-10-09
>
> * patch: add EXLOCK flag for windows [Talha Can Havadar]
>
</details>
## 22.0.4 - 2025-05-29
* tests: Replace request with got [Otavio Jacobi]
* deploy-legacy: Replace request with got [Otavio Jacobi]
## 22.0.3 - 2025-05-29
* Bump sentry to v9 [Otavio Jacobi]
## 22.0.2 - 2025-05-28
* Fix balena build to work with --nologs [Otavio Jacobi]
## 22.0.1 - 2025-05-28
* DeviceAPI: Move away from `request` in favor of BalenaSdk request [myarmolinsky]
* Update `nock` to 14.0.4 [myarmolinsky]
## 22.0.0 - 2025-05-26
* Add migration guide to v22 [Otavio Jacobi]
* Build standalone without pkg [Otavio Jacobi]
## 21.1.14 - 2025-05-21
<details>
<summary> Bump balena-preload to 18.0.4 [Otavio Jacobi] </summary>
> ### balena-preload-18.0.4 - 2025-05-21
>
> * Fix balena-preload pip deps [Otavio Jacobi]
>
</details>
## 21.1.13 - 2025-05-21
<details>
<summary> Bump balena-preload to 18.0.3 [Otavio Jacobi] </summary>
> ### balena-preload-18.0.3 - 2025-03-19
>
> * Update dependency sh to v1.14.3 [balena-renovate[bot]]
>
> ### balena-preload-18.0.2 - 2025-03-19
>
> * Update alpine Docker tag to v3.21 [balena-renovate[bot]]
>
</details>
## 21.1.12 - 2025-05-16
* Update @balena/compose dependency to fix error with build secrets. [Ken Bannister]
## 21.1.11 - 2025-05-06
* Fix typos in `os configure` and `config generate` help messages [myarmolinsky]
## 21.1.10 - 2025-05-05
* patch: fix windows signing [Edwin Joassart]
## 21.1.9 - 2025-04-07
<details>
<summary> Update balena-config-json to rely on the balena-image-fs helpers [Thodoris Greasidis] </summary>
> ### balena-config-json-4.2.7 - 2025-04-02
>
> * Fix getBootPartition always warning that the boot partitions were not found [Thodoris Greasidis]
>
> ### balena-config-json-4.2.6 - 2025-04-01
>
> * write: Allow undefined as a value for the deprecated type parameter [Thodoris Greasidis]
>
> <details>
> <summary> Use the balena-image-fs findPartition() helper to find the boot partition [Thodoris Greasidis] </summary>
>
>> #### balena-image-fs-7.5.0 - 2025-03-26
>>
>> * Add function to find a partition by name/label [Ken Bannister]
>>
>> #### balena-image-fs-7.4.1 - 2025-02-21
>>
>> * bump ext2fs to 4.2.4 [Ryan Cooke]
>>
>
> </details>
>
>
> ### balena-config-json-4.2.5 - 2025-03-26
>
> * Update @balena/lint to 9.1.4 [Thodoris Greasidis]
>
> ### balena-config-json-4.2.4 - 2025-03-26
>
> * Switch use ts-mocha instead of manually compiling the tests [Thodoris Greasidis]
> * Update TypeScript to 5.8.2 [Thodoris Greasidis]
>
> ### balena-config-json-4.2.3 - 2025-03-26
>
> * Update chai to v5 [balena-renovate[bot]]
>
> ### balena-image-fs-7.5.2 - 2025-04-02
>
> * Update dependency jsdoc-to-markdown to v9 [balena-renovate[bot]]
>
> ### balena-image-fs-7.5.1 - 2025-03-26
>
> * Update @balena/lint to v9 [Thodoris Greasidis]
> * Remove the no longer needed gpt detection helper [Thodoris Greasidis]
> * Update TypeScript to 5.8.2 [Thodoris Greasidis]
> * Drop the package-lock.json [Thodoris Greasidis]
>
</details>
## 21.1.8 - 2025-04-03
* Update actions/download-artifact action to v4.1.9 [balena-renovate[bot]]
## 21.1.7 - 2025-04-03
* Update actions/upload-artifact digest to ea165f8 [balena-renovate[bot]]
## 21.1.6 - 2025-04-03
* Update actions/setup-node digest to cdca736 [balena-renovate[bot]]
## 21.1.5 - 2025-04-03
* Update dockerode/docker-modem dependencies for fixes [Ken Bannister]
## 21.1.4 - 2025-04-02
* Add comment with secure boot signature file example for preload [Ken Bannister]
## 21.1.3 - 2025-03-28
* Fix device detail for open balena [Otavio Jacobi]
## 21.1.2 - 2025-03-27
* Deny preload for an image with secure boot enabled [Ken Bannister]
## 21.1.1 - 2025-03-26
<details>
<summary> Bump balena-sdk to 21.3.0 [Otavio Jacobi] </summary>
> ### balena-sdk-21.3.0 - 2025-03-26
>
> * device: add `changed_api_heartbeat_state_on__date` to typings [Otavio Jacobi]
>
> ### balena-sdk-21.2.2 - 2025-03-26
>
> * fix linting [Otavio Jacobi]
>
</details>
## 21.1.0 - 2025-03-12
* Add support for new requirement labels feature [Felipe Lalanne]
## 21.0.0 - 2025-03-11
* Drop support for OS versions <2.14.0 [myarmolinsky]
* api-key generate: Add required argument `expiryDate` [myarmolinsky]
* Update `balena-preload` to 18.0.1 [myarmolinsky]
* Add dependency `date-fns` [myarmolinsky]
* Update `balena-sdk` to 21.2.1 [myarmolinsky]
## 20.2.10 - 2025-03-10
* Update TypeScript to 5.8.2 [Thodoris Greasidis]
## 20.2.9 - 2025-02-26
* Fix CORS issue with X-Balena-Client header [Thodoris Greasidis]
## 20.2.8 - 2025-02-26
* Update balena-config-json dependency and fix test [Ken Bannister]
## 20.2.7 - 2025-02-25
* Use the CLI version in the X-Balena-Client header [Thodoris Greasidis]
## 20.2.6 - 2025-02-25
* Update actions/upload-artifact digest to 4cec3d8 [balena-renovate[bot]]
## 20.2.5 - 2025-02-25
* Update actions/setup-node digest to 1d0ff46 [balena-renovate[bot]]
## 20.2.4 - 2025-02-25
* Pin docker-modem and dockerode to avoid regression [Ken Bannister]
## 20.2.3 - 2025-01-15
* Remove unused old eslint version files [Otavio Jacobi]
## 20.2.2 - 2025-01-12
* Use the promises namespace of balena-image-fs [Thodoris Greasidis]
<details>
<summary> Update balena-device-init to 8.1.3 & balena-image-fs to 7.3.0 [Thodoris Greasidis] </summary>
> ### balena-image-fs-7.3.0 - 2025-01-06
>
> * Drop Bluebird from devDependencies [Thodoris Greasidis]
> * flowzone: Remove empty with clause [Thodoris Greasidis]
> * Add the promises namespace as part of the exposed fs [Thodoris Greasidis]
>
> ### balena-image-fs-7.2.2 - 2024-01-02
>
> * Update dependency @types/node to v20 [Self-hosted Renovate Bot]
>
> ### balena-image-fs-7.2.1 - 2023-12-19
>
> * Remove repo config from flowzone.yml [Kyle Harding]
>
> ### balena-image-fs-7.2.0 - 2023-01-20
>
> * Add support for Node 18 [Akis Kesoglou]
>
> ### balena-image-fs-7.1.2 - 2023-01-05
>
> * Update dependencies [ab77]
>
> ### balena-image-fs-7.1.1 - 2022-12-20
>
> * Update dependency jsdoc-to-markdown to 8.0.0 [Renovate Bot]
>
> ### balena-image-fs-7.1.0 - 2022-12-13
>
> * update dependencies [Zane Hitchcox]
>
> ### balena-device-init-8.1.3 - 2025-01-09
>
> * README: Remove the travisci badge [Thodoris Greasidis]
>
> ### balena-device-init-8.1.2 - 2025-01-09
>
>
> <details>
> <summary> Use the promises namespace of balena-image-fs [Thodoris Greasidis] </summary>
>
>> #### balena-image-fs-7.3.0 - 2025-01-06
>>
>> * Drop Bluebird from devDependencies [Thodoris Greasidis]
>> * flowzone: Remove empty with clause [Thodoris Greasidis]
>> * Add the promises namespace as part of the exposed fs [Thodoris Greasidis]
>>
>> #### balena-image-fs-7.2.2 - 2024-01-02
>>
>> * Update dependency @types/node to v20 [Self-hosted Renovate Bot]
>>
>> #### balena-image-fs-7.2.1 - 2023-12-19
>>
>> * Remove repo config from flowzone.yml [Kyle Harding]
>>
>> #### balena-image-fs-7.2.0 - 2023-01-20
>>
>> * Add support for Node 18 [Akis Kesoglou]
>>
>> #### balena-image-fs-7.1.2 - 2023-01-05
>>
>> * Update dependencies [ab77]
>>
>> #### balena-image-fs-7.1.1 - 2022-12-20
>>
>> * Update dependency jsdoc-to-markdown to 8.0.0 [Renovate Bot]
>>
>> #### balena-image-fs-7.1.0 - 2022-12-13
>>
>> * update dependencies [Zane Hitchcox]
>>
>
> </details>
>
>
> ### balena-device-init-8.1.1 - 2025-01-06
>
> * Convert some parts to async await and simplify [Thodoris Greasidis]
> * Avoid unnecessary destructuring [Thodoris Greasidis]
>
</details>
## 20.2.1 - 2025-01-01
<details>
<summary> Update balena-preload to 17.0.0 [Thodoris Greasidis] </summary>
> ### balena-preload-17.0.0 - 2024-10-21
>
> * Improve typings [Thodoris Greasidis]
> * Stop returning Bluebird promises & drop it from the dependencies [Thodoris Greasidis]
>
</details>
## 20.2.0 - 2024-12-31
<details>
<summary> os configure: Give precedence to the boot partition located in the image over the device-type.json contents [Thodoris Greasidis] </summary>
> ### balena-device-init-8.1.0 - Invalid date
>
> * Try to find the boot partition by inspecting the image [Thodoris Greasidis]
>
> ### balena-device-init-8.0.1 - 2024-12-19
>
> * Drop the unnecessary eslint.config.js [Thodoris Greasidis]
> * packacke.json: Explicitly set type commonjs [Thodoris Greasidis]
>
</details>
## 20.1.6 - 2024-12-30
* Add more realistic os configure tests [Thodoris Greasidis]

View File

@ -14,7 +14,7 @@ The balena CLI is an open source project and your contribution is welcome!
In order to ease development:
* `npm run build:fast` skips some of the build steps for interactive testing, or
* `npm run test:source` skips testing the standalone packages (which is rather slow)
* `npm run test:source` skips testing the standalone zip packages (which is rather slow)
* `./bin/balena-dev` uses `ts-node/register` to transpile on the fly.
Before opening a PR, test your changes with `npm test`. Keep compatibility in mind, as the CLI is

View File

@ -8,8 +8,8 @@ There are 3 options to choose from to install balena's CLI:
* [Executable Installer](#executable-installer): the easiest method on Windows and macOS, using the
traditional graphical desktop application installers.
* [Standalone tar.gz Package](#standalone-targz-package): these are plain tar.gz files with the balena CLI
bundled within. Available for all platforms: Linux, Windows, macOS.
* [Standalone Zip Package](#standalone-zip-package): these are plain zip files with the balena CLI
executable in them: extract and run. Available for all platforms: Linux, Windows, macOS.
Recommended also for scripted installation in CI (continuous integration) environments.
* [NPM Installation](#npm-installation): recommended for Node.js developers who may be interested
in integrating the balena CLI in their existing projects or workflow.
@ -30,9 +30,9 @@ instructions:
> If you would like to use WSL, follow the [installations instructions for
> Linux](./INSTALL-LINUX.md) rather than Windows, as WSL consists of a Linux environment.
If you had previously installed the CLI using a standalone tar package, it may be a good idea to
If you had previously installed the CLI using a standalone zip package, it may be a good idea to
check your system's `PATH` environment variable for duplicate entries, as the terminal will use the
entry that comes first. Check the [Standalone tar.gz Package](#standalone-targz-package) instructions
entry that comes first. Check the [Standalone Zip Package](#standalone-zip-package) instructions
for how to modify the PATH variable.
By default, the CLI is installed to the following folders:
@ -42,17 +42,18 @@ OS | Folders
Windows: | `C:\Program Files\balena-cli\`
macOS: | `/usr/local/src/balena-cli/` <br> `/usr/local/bin/balena`
## Standalone tar.gz Package
## Standalone Zip Package
1. Download the latest tar.gz file from the [releases page](https://github.com/balena-io/balena-cli/releases).
1. Download the latest zip file from the [releases page](https://github.com/balena-io/balena-cli/releases).
Look for a file name that ends with the word "standalone", for example:
`balena-cli-vX.Y.Z-linux-x64-standalone.tar.gz`_also for the Windows Subsystem for Linux_
`balena-cli-vX.Y.Z-macOS-x64-standalone.tar.gz`
`balena-cli-vX.Y.Z-windows-x64-standalone.tar.gz`
`balena-cli-vX.Y.Z-linux-x64-standalone.zip`_also for the Windows Subsystem for Linux_
`balena-cli-vX.Y.Z-macOS-x64-standalone.zip`
`balena-cli-vX.Y.Z-windows-x64-standalone.zip`
2. Extract the tar.gz file contents to any folder you choose. The extracted contents will be a `balena` folder containing a `bin` subdirectory.
2. Extract the zip file contents to any folder you choose. The extracted contents will include a
`balena-cli` folder.
3. Add the `balena/bin` folder to the system's `PATH` environment variable.
3. Add the `balena-cli` folder to the system's `PATH` environment variable.
See instructions for:
[Linux](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix) |
[macOS](https://www.architectryan.com/2012/10/02/add-to-the-path-on-mac-os-x-mountain-lion/#.Uydjga1dXDg) |
@ -60,14 +61,14 @@ macOS: | `/usr/local/src/balena-cli/` <br> `/usr/local/bin/balena`
> * If you are using macOS 10.15 or later (Catalina, Big Sur), [check this known issue and
> workaround](https://github.com/balena-io/balena-cli/issues/2244).
> * **Linux Alpine** and **Busybox:** the standalone tar.gz package is not currently compatible with
> * **Linux Alpine** and **Busybox:** the standalone zip package is not currently compatible with
> these "compact" Linux distributions, because of the alternative C libraries they ship with.
> For these, consider the [NPM Installation](#npm-installation) option.
> * Note that moving the `balena/bin/balena` executable out of the extracted `balena` folder on its own
> * Note that moving the `balena` executable out of the extracted `balena-cli` folder on its own
> (e.g. moving it to `/usr/local/bin/balena`) will **not** work, as it depends on the other
> folders and files also present in the `balena` folder.
> folders and files also present in the `balena-cli` folder.
To update the CLI to a new version, download a new release tar.gz file and replace the previous
To update the CLI to a new version, download a new release zip file and replace the previous
installation folder. To uninstall, simply delete the folder and edit the PATH environment variable
as described above.
@ -77,8 +78,8 @@ If you are a Node.js developer, you may wish to install the balena CLI via [npm]
The npm installation involves building native (platform-specific) binary modules, which require
some development tools to be installed first, as follows.
> **The balena CLI currently requires Node.js version >=20.6.0**
> **Versions 23 and later are not yet fully supported.**
> **The balena CLI currently requires Node.js version ^20.6.0**
> **Versions 21 and later are not yet fully supported.**
### Install development tools
@ -88,7 +89,7 @@ some development tools to be installed first, as follows.
$ sudo apt-get update && sudo apt-get -y install curl python3 git make g++
$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash
$ . ~/.bashrc
$ nvm install 22
$ nvm install 20
```
The `curl` command line above uses
@ -105,7 +106,7 @@ recommended.
```sh
$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash
$ . ~/.bashrc
$ nvm install 22
$ nvm install 20
```
#### **Windows** (not WSL)
@ -113,7 +114,7 @@ $ nvm install 22
Install:
* If you'd like the ability to switch between Node.js versions, install
- Node.js v22 from the [Nodejs.org releases page](https://nodejs.org/en/download/releases/).
- Node.js v20 from the [Nodejs.org releases page](https://nodejs.org/en/download/releases/).
[nvm-windows](https://github.com/coreybutler/nvm-windows#node-version-manager-nvm-for-windows)
instead.
* The [MSYS2 shell](https://www.msys2.org/), which provides `git`, `make`, `g++` and more:

View File

@ -7,8 +7,8 @@ Selected operating system: **macOS**
1. Download the installer from the [latest release
page](https://github.com/balena-io/balena-cli/releases/latest).
Look for a file name that ends with "-installer.pkg":
`balena-cli-vX.Y.Z-macOS-x64-installer.pkg`
Look for a file name that ends with "-installer.pkg":
`balena-cli-vX.Y.Z-macOS-x64-installer.pkg`
2. Double click on the downloaded file to run the installer and follow the installer's
instructions.

View File

@ -8,7 +8,7 @@ Selected operating system: **Windows**
1. Download the installer from the [latest release
page](https://github.com/balena-io/balena-cli/releases/latest).
Look for a file name that ends with "-installer.exe":
`balena-cli-vX.Y.Z-windows-x64-installer.exe`
`balena-cli-vX.Y.Z-windows-x64-installer.exe`
2. Double click on the downloaded file to run the installer and follow the installer's
instructions.

View File

@ -1,45 +0,0 @@
## Migrating to balena CLI v22
This guide outlines the changes introduced in balena CLI v22 and provides instructions for when and how to migrate.
---
### For Installer Users (Windows .exe, macOS .pkg)
If you are using the Windows executable (.exe) or macOS package (.pkg) installers, **no changes** are required for this update. You can continue to use the installers as before.
---
### For npm Installation Users
If you installed balena CLI via npm, **no changes** are required for this update. Your existing installation and update process remains the same.
---
### For Standalone Installation Users
Users of the standalone balena CLI will need to make the following adjustments:
1. **Archive Format Change**: The distribution archive format has changed from `.zip` to `.tar.gz`. You will need to use the `tar` command instead of `unzip` to extract the CLI.
* **Previous command (v21.x.x and older):**
```bash
unzip balena-cli-v21.1.12-linux-x64-standalone.zip
```
* **New command (v22.0.0 and newer):**
```bash
tar -xzf balena-cli-v22.0.0-linux-x64-standalone.tar.gz
```
2. **Executable Path Change**: The path to the balena CLI executable within the extracted folder has been updated.
* **Previous path (v21.x.x and older):**
```
balena-cli/balena
```
* **New path (v22.0.0 and newer):**
```
balena/bin/balena
```
Please update your scripts and any aliases to reflect these changes if you are using the standalone version.

View File

@ -20,8 +20,6 @@ GitHub](https://github.com/balena-io/balena-cli/), and your contribution is also
Check the [balena CLI installation instructions on
GitHub](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).
Note for v22 Migration: If you are upgrading to balena CLI v22 from a previous standalone installation, please [see the v22 Migration Guide](https://github.com/balena-io/balena-cli/blob/master/MIGRATIONS.md) for installation changes.
## Choosing a shell (command prompt/terminal)
On **Windows,** the standard Command Prompt (`cmd.exe`) and

View File

@ -115,7 +115,7 @@ If nothing seems to help, consider also using a different client-side terminal a
## "Docker seems to be unavailable" error when using Windows Subsystem for Linux (WSL)
When running on WSL, the recommendation is to install a CLI release for Linux, like the standalone
tar.gz package for Linux. However, commands like "balena build" will, by default, attempt to reach the
zip package for Linux. However, commands like "balena build" will, by default, attempt to reach the
Docker daemon at the Unix socket path `/var/run/docker.sock`, while Docker Desktop for Windows uses
a Windows named pipe at `//./pipe/docker_engine` (which the Linux CLI on WSL cannot use). A
solution is:

View File

@ -75,8 +75,6 @@ const renamedOclifStandalone: PathByPlatform = {
export async function signFilesForNotarization() {
console.log('Signing files for notarization');
// If signFilesForNotarization is called on the test CI environment (which will not set CSC_LINK)
// then we skip the signing process.
if (process.platform !== 'darwin' || !process.env.CSC_LINK) {
console.log('Skipping signing for notarization');
return;
@ -225,7 +223,7 @@ async function renameStandalone() {
*/
async function signWindowsInstaller() {
if (process.env.SM_CODE_SIGNING_CERT_SHA1_HASH) {
const exeName = (await getOclifInstallersOriginalNames())[process.platform];
const exeName = renamedOclifInstallers[process.platform];
console.log(`Signing installer "${exeName}"`);
// trust ...
await execFileAsync('signtool.exe', [
@ -259,14 +257,12 @@ async function notarizeMacInstaller(): Promise<void> {
const appleId =
process.env.XCODE_APP_LOADER_EMAIL || 'accounts+apple@balena.io';
const appleIdPassword = process.env.XCODE_APP_LOADER_PASSWORD;
const appPath = (await getOclifInstallersOriginalNames())[process.platform];
console.log(`Notarizing file "${appPath}"`);
if (appleIdPassword && teamId) {
await notarize({
tool: 'notarytool',
teamId,
appPath,
appPath: renamedOclifInstallers.darwin,
appleId,
appleIdPassword,
});
@ -306,6 +302,7 @@ export async function buildOclifInstaller() {
console.log('=======================================================');
const oclifPath = path.join(ROOT, 'node_modules', 'oclif');
await oclifRun([packCmd].concat(...packOpts), oclifPath);
await renameInstallers();
// The Windows installer is explicitly signed here (oclif doesn't do it).
// The macOS installer is automatically signed by oclif (which runs the
// `pkgbuild` tool), using the certificate name given in package.json
@ -317,7 +314,6 @@ export async function buildOclifInstaller() {
await notarizeMacInstaller(); // Notarize
console.log('Package notarized.');
}
await renameInstallers();
console.log(`oclif installer build completed`);
}
}
@ -353,5 +349,4 @@ export async function testShrinkwrap(): Promise<void> {
if (process.platform !== 'win32') {
await whichSpawn(path.resolve(__dirname, 'test-lock-deduplicated.sh'));
}
await Promise.resolve();
}

View File

@ -13,8 +13,6 @@ GitHub](https://github.com/balena-io/balena-cli/), and your contribution is also
Check the [balena CLI installation instructions on
GitHub](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).
Note for v22 Migration: If you are upgrading to balena CLI v22 from a previous standalone installation, please [see the v22 Migration Guide](https://github.com/balena-io/balena-cli/blob/master/MIGRATIONS.md) for installation changes.
## Choosing a shell (command prompt/terminal)
On **Windows,** the standard Command Prompt (`cmd.exe`) and
@ -328,8 +326,6 @@ or to authenticate requests to the API with an 'Authorization: Bearer <key>' hea
Examples:
$ balena api-key generate "Jenkins Key"
$ balena api-key generate "Jenkins Key" 2025-10-30
$ balena api-key generate "Jenkins Key" never
### Arguments
@ -337,10 +333,6 @@ Examples:
the API key name
#### EXPIRYDATE
the expiry date of the API key as an ISO date string, or "never" for no expiry
## api-key list
### Aliases
@ -576,9 +568,9 @@ Generate a config.json file for a device or fleet.
The target balenaOS version must be specified with the --version option.
The '--dev' option is used to configure balenaOS to operate in development mode,
allowing unauthenticated root ssh access and exposing network ports such as
allowing anauthenticated root ssh access and exposing network ports such as
balenaEngine's 2375 (unencrypted). This option causes `"developmentMode": true`
to be inserted in the 'config.json' file in the image's boot partition. Development
to be inserted in the 'config.json' file in the image's boot partion. Development
mode (as a configurable option) is applicable to balenaOS releases from early
2022. Older releases have separate development and production balenaOS images
that cannot be reconfigured through 'config.json' or the '--dev' option. Do not
@ -2874,9 +2866,9 @@ The --device-type option is used to override the fleet's default device type,
in case of a fleet with mixed device types.
The '--dev' option is used to configure balenaOS to operate in development mode,
allowing unauthenticated root ssh access and exposing network ports such as
allowing anauthenticated root ssh access and exposing network ports such as
balenaEngine's 2375 (unencrypted). This option causes `"developmentMode": true`
to be inserted in the 'config.json' file in the image's boot partition. Development
to be inserted in the 'config.json' file in the image's boot partion. Development
mode (as a configurable option) is applicable to balenaOS releases from early
2022. Older releases have separate development and production balenaOS images
that cannot be reconfigured through 'config.json' or the '--dev' option. Do not

3245
npm-shrinkwrap.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "balena-cli",
"version": "22.1.1",
"version": "20.1.6",
"description": "The official balena Command Line Interface",
"main": "./build/app.js",
"homepage": "https://github.com/balena-io/balena-cli",
@ -38,7 +38,6 @@
"build:completion": "node completion/generate-completion.js",
"build:standalone": "ts-node --transpile-only automation/run.ts build:standalone",
"build:installer": "ts-node --transpile-only automation/run.ts build:installer",
"deduplicate-dependencies": "npm dd && git add npm-shrinkwrap.json && git commit --message \"Deduplicate dependencies\"",
"package": "npm run build:fast && npm run build:standalone && npm run build:installer",
"pretest": "npm run build",
"test": "npm run test:shrinkwrap && npm run test:core",
@ -72,7 +71,7 @@
"author": "Balena Inc. (https://balena.io/)",
"license": "Apache-2.0",
"engines": {
"node": ">=20.6.0 <23"
"node": "^20.6.0"
},
"oclif": {
"bin": "balena",
@ -124,6 +123,7 @@
"@types/node-cleanup": "^2.1.2",
"@types/prettyjson": "^0.0.33",
"@types/progress-stream": "^2.0.2",
"@types/request": "^2.48.7",
"@types/rewire": "^2.5.30",
"@types/rimraf": "^3.0.2",
"@types/semver": "^7.3.9",
@ -153,28 +153,28 @@
"mocha": "^10.6.0",
"mock-fs": "^5.2.0",
"mock-require": "^3.0.3",
"nock": "^14.0.4",
"nock": "^13.2.1",
"oclif": "^4.17.0",
"rewire": "^7.0.0",
"simple-git": "^3.14.1",
"sinon": "^19.0.0",
"string-to-stream": "^3.0.1",
"ts-node": "^10.4.0",
"typescript": "^5.8.2"
"typescript": "^5.7.2"
},
"dependencies": {
"@balena/compose": "^7.0.9",
"@balena/compose": "^6.0.0",
"@balena/dockerignore": "^1.0.2",
"@balena/env-parsing": "^1.1.8",
"@balena/es-version": "^1.0.1",
"@oclif/core": "^4.1.0",
"@sentry/node": "^9.0.0",
"balena-config-json": "^4.2.7",
"balena-device-init": "^8.1.11",
"@sentry/node": "^6.16.1",
"balena-config-json": "^4.2.0",
"balena-device-init": "^8.0.0",
"balena-errors": "^4.7.3",
"balena-image-fs": "^7.5.2",
"balena-preload": "^18.0.4",
"balena-sdk": "^21.3.0",
"balena-image-fs": "^7.0.6",
"balena-preload": "^16.0.0",
"balena-sdk": "^20.8.0",
"balena-semver": "^2.3.0",
"balena-settings-client": "^5.0.2",
"balena-settings-storage": "^8.1.0",
@ -185,13 +185,12 @@
"cli-truncate": "^2.1.0",
"color-hash": "^1.1.1",
"common-tags": "^1.7.2",
"date-fns": "^4.1.0",
"denymount": "^2.3.0",
"docker-modem": "^5.0.6",
"docker-modem": "^5.0.3",
"docker-progress": "^5.1.3",
"dockerode": "^4.0.5",
"dockerode": "^4.0.2",
"ejs": "^3.1.6",
"etcher-sdk": "^10.0.0",
"etcher-sdk": "9.1.0",
"express": "^4.17.2",
"fast-boot2": "^1.1.0",
"fast-levenshtein": "^3.0.0",
@ -219,8 +218,9 @@
"prettyjson": "^1.2.5",
"progress-stream": "^2.0.0",
"reconfix": "^1.0.0-v0-1-0-fork-46760acff4d165f5238bfac5e464256ef1944476",
"resin-cli-form": "^4.0.0",
"resin-cli-visuals": "^3.0.0",
"request": "^2.88.2",
"resin-cli-form": "^3.0.0",
"resin-cli-visuals": "^2.0.1",
"resin-doodles": "^0.2.0",
"resin-stream-logger": "^0.1.2",
"rimraf": "^3.0.2",
@ -248,6 +248,6 @@
}
},
"versionist": {
"publishedAt": "2025-06-19T09:32:53.877Z"
"publishedAt": "2024-12-30T17:16:10.325Z"
}
}

View File

@ -6,8 +6,6 @@ upstream:
url: 'https://github.com/balena-io/balena-sdk'
- repo: 'balena-config-json'
url: 'https://github.com/balena-io-modules/balena-config-json'
- repo: 'balena-image-fs'
url: 'https://github.com/balena-io-modules/balena-image-fs'
- repo: 'balena-device-init'
url: 'https://github.com/balena-io-modules/balena-device-init'
- repo: 'balena-image-manager'

View File

@ -34,14 +34,18 @@ export const setupSentry = onceAsync(async () => {
const config = await import('./config');
const Sentry = await import('@sentry/node');
Sentry.init({
autoSessionTracking: false,
dsn: config.sentryDsn,
release: packageJSON.version,
});
Sentry.getCurrentScope().setExtras({
is_pkg: !!(process as any).pkg,
node_version: process.version,
platform: process.platform,
Sentry.configureScope((scope) => {
scope.setExtras({
is_pkg: !!(process as any).pkg,
node_version: process.version,
platform: process.platform,
});
});
return Sentry.getCurrentHub();
});
async function checkNodeVersion() {

View File

@ -17,16 +17,7 @@
import { Args, Command } from '@oclif/core';
import { ExpectedError } from '../../errors';
import { getBalenaSdk, getCliForm, stripIndent } from '../../utils/lazy';
import {
formatDuration,
intervalToDuration,
isValid,
parseISO,
} from 'date-fns';
// In days
const durations = [1, 7, 30, 90];
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
async function isLoggedInWithJwt() {
const balena = getBalenaSdk();
@ -50,21 +41,13 @@ export default class GenerateCmd extends Command {
This key can be used to log into the CLI using 'balena login --token <key>',
or to authenticate requests to the API with an 'Authorization: Bearer <key>' header.
`;
public static examples = [
'$ balena api-key generate "Jenkins Key"',
'$ balena api-key generate "Jenkins Key" 2025-10-30',
'$ balena api-key generate "Jenkins Key" never',
];
public static examples = ['$ balena api-key generate "Jenkins Key"'];
public static args = {
name: Args.string({
description: 'the API key name',
required: true,
}),
expiryDate: Args.string({
description:
'the expiry date of the API key as an ISO date string, or "never" for no expiry',
}),
};
public static authenticated = true;
@ -72,61 +55,9 @@ export default class GenerateCmd extends Command {
public async run() {
const { args: params } = await this.parse(GenerateCmd);
let expiryDateResponse: string | number | undefined = params.expiryDate;
let key;
try {
if (!expiryDateResponse) {
expiryDateResponse = await getCliForm().ask({
message: 'Please pick an expiry date for the API key',
type: 'list',
choices: [...durations, 'custom', 'never'].map((duration) => ({
name:
duration === 'never'
? 'No expiration'
: typeof duration === 'number'
? formatDuration(
intervalToDuration({
start: 0,
end: duration * 24 * 60 * 60 * 1000,
}),
)
: 'Custom expiration',
value: duration,
})),
});
}
let expiryDate: Date | null;
if (expiryDateResponse === 'never') {
expiryDate = null;
} else if (expiryDateResponse === 'custom') {
do {
expiryDate = parseISO(
await getCliForm().ask({
message:
'Please enter an expiry date for the API key as an ISO date string',
type: 'input',
}),
);
if (!isValid(expiryDate)) {
console.error('Invalid date format');
}
} while (!isValid(expiryDate));
} else if (typeof expiryDateResponse === 'string') {
expiryDate = parseISO(expiryDateResponse);
if (!isValid(expiryDate)) {
throw new Error(
'Invalid date format, please use a valid ISO date string',
);
}
} else {
expiryDate = new Date(
Date.now() + expiryDateResponse * 24 * 60 * 60 * 1000,
);
}
key = await getBalenaSdk().models.apiKey.create({
name: params.name,
expiryDate: expiryDate === null ? null : expiryDate.toISOString(),
});
key = await getBalenaSdk().models.apiKey.create(params.name);
} catch (e) {
if (e.name === 'BalenaNotLoggedIn') {
if (await isLoggedInWithJwt()) {

View File

@ -64,12 +64,7 @@ export default class ConfigInjectCmd extends Command {
);
const config = await import('balena-config-json');
await config.write(
drive,
// Will be removed in the next major of balena-config-json
undefined,
configJSON,
);
await config.write(drive, '', configJSON);
console.info('Done');
}

View File

@ -54,7 +54,7 @@ export default class ConfigReadCmd extends Command {
await safeUmount(drive);
const config = await import('balena-config-json');
const configJSON = await config.read(drive);
const configJSON = await config.read(drive, '');
if (options.json) {
console.log(JSON.stringify(configJSON, null, 4));

View File

@ -62,7 +62,7 @@ export default class ConfigReconfigureCmd extends Command {
await safeUmount(drive);
const config = await import('balena-config-json');
const { uuid } = await config.read(drive);
const { uuid } = await config.read(drive, '');
await safeUmount(drive);
if (!uuid) {

View File

@ -64,19 +64,14 @@ export default class ConfigWriteCmd extends Command {
await safeUmount(drive);
const config = await import('balena-config-json');
const configJSON = await config.read(drive);
const configJSON = await config.read(drive, '');
console.info(`Setting ${params.key} to ${params.value}`);
ConfigWriteCmd.updateConfigJson(configJSON, params.key, params.value);
await denyMount(drive, async () => {
await safeUmount(drive);
await config.write(
drive,
// Will be removed in the next major of balena-config-json
undefined,
configJSON,
);
await config.write(drive, '', configJSON);
});
console.info('Done');

View File

@ -368,7 +368,6 @@ ${dockerignoreHelp}
!opts.shouldUploadLogs,
composeOpts.projectPath,
opts.createAsDraft,
project.descriptors,
);
}

View File

@ -77,59 +77,45 @@ export default class DeviceCmd extends Command {
const balena = getBalenaSdk();
let device: ExtendedDevice;
if (options.json) {
const [deviceBase, deviceComputed] = await Promise.all([
balena.models.device.get(params.uuid, {
$expand: {
device_tag: {
$select: ['tag_key', 'value'],
const device = (await balena.models.device.get(
params.uuid,
options.json
? {
$expand: {
device_tag: {
$select: ['tag_key', 'value'],
},
...expandForAppName.$expand,
},
...expandForAppName.$expand,
}
: {
$select: [
'device_name',
'id',
'overall_status',
'is_online',
'ip_address',
'mac_address',
'last_connectivity_event',
'uuid',
'supervisor_version',
'is_web_accessible',
'note',
'os_version',
'memory_usage',
'memory_total',
'public_address',
'storage_block_device',
'storage_usage',
'storage_total',
'cpu_usage',
'cpu_temp',
'cpu_id',
'is_undervolted',
],
...expandForAppName,
},
}),
balena.models.device.get(params.uuid, {
$select: [
'overall_status',
'overall_progress',
'should_be_running__release',
],
}),
]);
device = {
...deviceBase,
...deviceComputed,
} as ExtendedDevice;
} else {
device = (await balena.models.device.get(params.uuid, {
$select: [
'device_name',
'id',
'overall_status',
'is_online',
'ip_address',
'mac_address',
'last_connectivity_event',
'uuid',
'supervisor_version',
'is_web_accessible',
'note',
'os_version',
'memory_usage',
'memory_total',
'public_address',
'storage_block_device',
'storage_usage',
'storage_total',
'cpu_usage',
'cpu_temp',
'cpu_id',
'is_undervolted',
],
...expandForAppName,
})) as ExtendedDevice;
}
)) as ExtendedDevice;
if (options.view) {
const open = await import('open');

View File

@ -16,6 +16,7 @@
*/
import { Args, Command } from '@oclif/core';
import { promisify } from 'util';
import { stripIndent } from '../../utils/lazy';
export default class LocalConfigureCmd extends Command {
@ -236,7 +237,7 @@ export default class LocalConfigureCmd extends Command {
const bootPartition = await getBootPartition(target);
const files = await imagefs.interact(target, bootPartition, async (_fs) => {
return await _fs.promises.readdir(this.CONNECTIONS_FOLDER);
return await promisify(_fs.readdir)(this.CONNECTIONS_FOLDER);
});
let connectionFileName;
@ -245,14 +246,13 @@ export default class LocalConfigureCmd extends Command {
} else if (_.includes(files, 'resin-sample.ignore')) {
// Fresh image, new mode, accoding to https://github.com/balena-os/meta-balena/pull/770/files
await imagefs.interact(target, bootPartition, async (_fs) => {
const contents = await _fs.promises.readFile(
const readFileAsync = promisify(_fs.readFile);
const writeFileAsync = promisify(_fs.writeFile);
const contents = await readFileAsync(
`${this.CONNECTIONS_FOLDER}/resin-sample.ignore`,
{ encoding: 'utf8' },
);
await _fs.promises.writeFile(
`${this.CONNECTIONS_FOLDER}/resin-wifi`,
contents,
);
await writeFileAsync(`${this.CONNECTIONS_FOLDER}/resin-wifi`, contents);
});
} else if (_.includes(files, 'resin-sample')) {
// Legacy mode, to be removed later
@ -266,7 +266,7 @@ export default class LocalConfigureCmd extends Command {
} else {
// In case there's no file at all (shouldn't happen normally, but the file might have been removed)
await imagefs.interact(target, bootPartition, async (_fs) => {
await _fs.promises.writeFile(
await promisify(_fs.writeFile)(
`${this.CONNECTIONS_FOLDER}/resin-wifi`,
this.CONNECTION_FILE,
);

View File

@ -18,6 +18,7 @@
import { Flags, Args, Command } from '@oclif/core';
import type { Interfaces } from '@oclif/core';
import type * as BalenaSdk from 'balena-sdk';
import { promisify } from 'util';
import * as _ from 'lodash';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
@ -291,7 +292,7 @@ export default class OsConfigureCmd extends Command {
for (const { name, content } of files) {
await imagefs.interact(image, bootPartition, async (_fs) => {
await _fs.promises.writeFile(
await promisify(_fs.writeFile)(
path.join(CONNECTIONS_FOLDER, name),
content,
);

View File

@ -37,7 +37,6 @@ import type {
Release,
} from 'balena-sdk';
import type { Preloader } from 'balena-preload';
import type * as Fs from 'fs';
export default class PreloadCmd extends Command {
public static description = stripIndent`
@ -162,42 +161,6 @@ Can be repeated to add multiple certificates.\
);
}
// Verify that image is not enabled for secure boot. First, confirm it is
// a secure boot image with a .sig file in the /opt directory of the rootA
// partition. For example, below are contents for generic-amd64 device type:
// $ ls -l opt
// total 864696
// -rw-r--r-- 1 root root 2378170368 Mar 26 09:14 balena-image-generic-amd64.balenaos-img
// -rw-r--r-- 1 root root 512 Mar 9 2018 balena-image-generic-amd64.balenaos-img.sig
const { explorePartition, BalenaPartition } = await import(
'../../utils/image-contents'
);
const isSecureBoot = await explorePartition<boolean>(
params.image,
BalenaPartition.ROOTA,
async (fs: typeof Fs): Promise<boolean> => {
try {
const files = await fs.promises.readdir('/opt');
return files.some((el) => el.endsWith('balenaos-img.sig'));
} catch {
// Typically one of:
// - Error: No such file or directory
// - Error: Unsupported filesystem.
// - ErrnoException: node_ext2fs_open ENOENT (44) args: [5261576,5268064,"r",0]
return false;
}
return false;
},
);
// Next verify that config.json enables secureboot.
if (isSecureBoot) {
const { read } = await import('balena-config-json');
const config = await read(params.image);
if (config.installer?.secureboot === true) {
throw new ExpectedError("Can't preload image with secure boot enabled");
}
}
// balena-preload currently does not work with numerical app IDs
// Load app here, and use app slug from hereon
const fleetSlug: string | undefined = options.fleet
@ -332,7 +295,7 @@ Can be repeated to add multiple certificates.\
owns__release: {
$select: ['id', 'commit', 'end_timestamp', 'composition'],
$expand: {
release_image: {
contains__image: {
$select: ['image'],
$expand: {
image: {

View File

@ -38,11 +38,11 @@ import { stripIndent } from './utils/lazy';
export async function trackCommand(commandSignature: string) {
try {
let Sentry: typeof import('@sentry/node');
let scope: import('@sentry/node').Scope;
if (!process.env.BALENARC_NO_SENTRY) {
Sentry = await import('@sentry/node');
scope = Sentry.getCurrentScope();
scope.setExtra('command', commandSignature);
Sentry.configureScope((scope) => {
scope.setExtra('command', commandSignature);
});
}
const { getCachedUsername } = await import('./utils/bootstrap');
let username: string | undefined;
@ -52,9 +52,11 @@ export async function trackCommand(commandSignature: string) {
// ignore
}
if (!process.env.BALENARC_NO_SENTRY) {
scope!.setUser({
id: username,
username,
Sentry!.configureScope((scope) => {
scope.setUser({
id: username,
username,
});
});
}
if (

View File

@ -128,7 +128,6 @@ export const createRelease = async function (
draft: boolean,
semver: string | undefined,
contract: import('@balena/compose/dist/release/models').ReleaseModel['contract'],
imgDescriptors: ImageDescriptor[],
): Promise<Release> {
const _ = require('lodash') as typeof import('lodash');
const crypto = require('crypto') as typeof import('crypto');
@ -168,7 +167,6 @@ export const createRelease = async function (
semver,
is_final: !draft,
contract,
imgDescriptors,
});
return {
@ -242,7 +240,7 @@ export const getPreviousRepos = (
status: 'success',
},
$expand: {
release_image: {
contains__image: {
$select: 'image',
$expand: { image: { $select: 'is_stored_at__image_location' } },
},
@ -254,7 +252,7 @@ export const getPreviousRepos = (
.then(function (release) {
// grab all images from the latest release, return all image locations in the registry
if (release.length > 0) {
const images = release[0].release_image as Array<{
const images = release[0].contains__image as Array<{
image: [SDK.Image];
}>;
const { getRegistryAndName } =

View File

@ -1322,9 +1322,6 @@ async function pushAndUpdateServiceImages(
}
}
// Error messages are limited to 300KB characters in the API, so we truncate longer ones.
const MAX_ERROR_MESSAGE_LENGTH = 300_000;
async function pushServiceImages(
docker: Dockerode,
logger: Logger,
@ -1347,34 +1344,23 @@ async function pushServiceImages(
delete serviceImage.build_log;
}
// These are the only update-able image fields in bC atm, and passing
// the whole image object in v7+ would result the allowlist to reject the request.
const imagePayload = _.pick(serviceImage, [
'end_timestamp',
'project_type',
'error_message',
'build_log',
'push_timestamp',
'status',
'content_hash',
'dockerfile',
'image_size',
]);
if (
typeof imagePayload.error_message === 'string' &&
imagePayload.error_message.length > MAX_ERROR_MESSAGE_LENGTH
) {
logger.logDebug(
`Truncating error message of image ${serviceImage.is_stored_at__image_location} to ${MAX_ERROR_MESSAGE_LENGTH} characters.`,
);
imagePayload.error_message = imagePayload.error_message.substring(
0,
MAX_ERROR_MESSAGE_LENGTH,
);
}
await releaseMod.updateImage(pineClient, serviceImage.id, imagePayload);
await releaseMod.updateImage(
pineClient,
serviceImage.id,
// These are the only update-able image fields in bC atm, and passing
// the whole image object in v7+ would result the allowlist to reject the request.
_.pick(serviceImage, [
'end_timestamp',
'project_type',
'error_message',
'build_log',
'push_timestamp',
'status',
'content_hash',
'dockerfile',
'image_size',
]),
);
},
);
}
@ -1389,7 +1375,6 @@ export async function deployProject(
skipLogUpload: boolean,
projectPath: string,
isDraft: boolean,
imgDescriptors: ImageDescriptor[],
): Promise<import('@balena/compose/dist/release/models').ReleaseModel> {
const releaseMod = await import('@balena/compose/dist/release');
const { createRelease, tagServiceImages } = await import('./compose');
@ -1420,7 +1405,6 @@ export async function deployProject(
isDraft,
contract?.version,
contract,
imgDescriptors,
),
);
const { client: pineClient, release, serviceImages } = $release;
@ -1610,9 +1594,7 @@ function buildProgressAdapter(inline: boolean) {
return;
}
// We want to keep the regex match instead of startsWith as it also works with buffers
// eslint-disable-next-line @typescript-eslint/prefer-string-starts-ends-with
if (!/^Successfully tagged /.test(str)) {
if (!str.startsWith('Successfully tagged ')) {
const match = stepRegex.exec(str);
if (match) {
step = match[1];

View File

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type * as BalenaSdk from 'balena-sdk';
import * as semver from 'balena-semver';
import { getBalenaSdk, stripIndent } from './lazy';
export interface ImgConfig {
@ -121,10 +122,16 @@ export function generateDeviceConfig(
// os.getConfig always returns a config for an app
delete config.apiKey;
config.deviceApiKey =
typeof deviceApiKey === 'string' && deviceApiKey
? deviceApiKey
: await sdk.models.device.generateDeviceKey(device.uuid);
if (deviceApiKey == null && semver.satisfies(options.version, '<2.0.3')) {
config.apiKey = await sdk.models.application.generateApiKey(
application.id,
);
} else {
config.deviceApiKey =
typeof deviceApiKey === 'string' && deviceApiKey
? deviceApiKey
: await sdk.models.device.generateDeviceKey(device.uuid);
}
return config;
})

View File

@ -19,7 +19,7 @@ import { getVisuals } from './lazy';
import { promisify } from 'util';
import type * as Dockerode from 'dockerode';
import type Logger = require('./logger');
import type got from 'got';
import type { Request } from 'request';
const getBuilderPushEndpoint = function (
baseUrl: string,
@ -75,10 +75,7 @@ const showPushProgress = function (message: string) {
return progressBar;
};
const uploadToPromise = (
uploadRequest: ReturnType<typeof got.stream.post>,
logger: Logger,
) =>
const uploadToPromise = (uploadRequest: Request, logger: Logger) =>
new Promise<{ buildId: number }>(function (resolve, reject) {
uploadRequest.on('error', reject).on('data', function handleMessage(data) {
let obj;
@ -109,7 +106,10 @@ const uploadToPromise = (
});
});
const uploadImage = async function (
/**
* @returns {Promise<{ buildId: number }>}
*/
const uploadImage = function (
imageStream: NodeJS.ReadableStream & { length: number },
token: string,
username: string,
@ -117,9 +117,10 @@ const uploadImage = async function (
appName: string,
logger: Logger,
): Promise<{ buildId: number }> {
const { default: got } = await import('got');
const progressStream = await import('progress-stream');
const zlib = await import('zlib');
const request = require('request') as typeof import('request');
const progressStream =
require('progress-stream') as typeof import('progress-stream');
const zlib = require('zlib') as typeof import('zlib');
// Need to strip off the newline
const progressMessage = logger
@ -140,26 +141,25 @@ const uploadImage = async function (
),
);
const uploadRequest = got.stream.post(
getBuilderPushEndpoint(url, username, appName),
{
headers: {
'Content-Encoding': 'gzip',
Authorization: `Bearer ${token}`,
},
body: streamWithProgress.pipe(
zlib.createGzip({
level: 6,
}),
),
throwHttpErrors: false,
const uploadRequest = request.post({
url: getBuilderPushEndpoint(url, username, appName),
headers: {
'Content-Encoding': 'gzip',
},
);
auth: {
bearer: token,
},
body: streamWithProgress.pipe(
zlib.createGzip({
level: 6,
}),
),
});
return uploadToPromise(uploadRequest, logger);
};
const uploadLogs = async function (
const uploadLogs = function (
logs: string,
token: string,
url: string,
@ -167,14 +167,14 @@ const uploadLogs = async function (
username: string,
appName: string,
) {
const { default: got } = await import('got');
return got.post(getBuilderLogPushEndpoint(url, buildId, username, appName), {
headers: {
Authorization: `Bearer ${token}`,
const request = require('request') as typeof import('request');
return request.post({
json: true,
url: getBuilderLogPushEndpoint(url, buildId, username, appName),
auth: {
bearer: token,
},
body: Buffer.from(logs),
responseType: 'json',
throwHttpErrors: false,
});
};
@ -232,7 +232,7 @@ export const deployLegacy = async function (
username,
appName,
]);
await uploadLogs(...args);
uploadLogs(...args);
}
return buildId;

View File

@ -15,12 +15,12 @@
* limitations under the License.
*/
import * as _ from 'lodash';
import * as request from 'request';
import type * as Stream from 'stream';
import { retry } from '../helpers';
import Logger = require('../logger');
import * as ApiErrors from './errors';
import { getBalenaSdk } from '../lazy';
import type { BalenaSDK } from 'balena-sdk';
export interface DeviceResponse {
[key: string]: any;
@ -80,9 +80,9 @@ export class DeviceAPI {
}
// Either return nothing, or throw an error with the info
public async setTargetState(state: Record<string, any>) {
public async setTargetState(state: any): Promise<void> {
const url = this.getUrlForAction('setTargetState');
await DeviceAPI.sendRequest(
return DeviceAPI.promisifiedRequest(
{
method: 'POST',
url,
@ -96,37 +96,37 @@ export class DeviceAPI {
public async getTargetState() {
const url = this.getUrlForAction('getTargetState');
return await DeviceAPI.sendRequest(
return DeviceAPI.promisifiedRequest(
{
method: 'GET',
url,
json: true,
},
this.logger,
).then(({ state }: { state: Record<string, any> }) => {
return state;
).then((body) => {
return body.state;
});
}
public async getDeviceInformation() {
public async getDeviceInformation(): Promise<DeviceInfo> {
const url = this.getUrlForAction('getDeviceInformation');
return await DeviceAPI.sendRequest(
return DeviceAPI.promisifiedRequest(
{
method: 'GET',
url,
json: true,
},
this.logger,
).then(({ info }: { info: DeviceInfo }) => {
return info;
).then((body) => {
return body.info;
});
}
public async getContainerId(serviceName: string): Promise<string> {
const url = this.getUrlForAction('containerId');
const body = await DeviceAPI.sendRequest(
const body = await DeviceAPI.promisifiedRequest(
{
method: 'GET',
url,
@ -146,10 +146,10 @@ export class DeviceAPI {
return body.containerId;
}
public async ping() {
public async ping(): Promise<void> {
const url = this.getUrlForAction('ping');
await DeviceAPI.sendRequest(
return DeviceAPI.promisifiedRequest(
{
method: 'GET',
url,
@ -158,10 +158,10 @@ export class DeviceAPI {
);
}
public async getVersion(): Promise<string> {
public getVersion(): Promise<string> {
const url = this.getUrlForAction('version');
return await DeviceAPI.sendRequest({
return DeviceAPI.promisifiedRequest({
method: 'GET',
url,
json: true,
@ -176,10 +176,10 @@ export class DeviceAPI {
});
}
public async getStatus() {
public getStatus(): Promise<Status> {
const url = this.getUrlForAction('status');
return await DeviceAPI.sendRequest({
return DeviceAPI.promisifiedRequest({
method: 'GET',
url,
json: true,
@ -194,60 +194,96 @@ export class DeviceAPI {
});
}
public async getLogStream() {
public getLogStream(): Promise<Stream.Readable> {
const url = this.getUrlForAction('logs');
const sdk = getBalenaSdk();
const stream = await sdk.request.stream({ url });
stream.on('response', (res) => {
if (res.statusCode !== 200) {
throw new ApiErrors.DeviceAPIError(
'Non-200 response from log streaming endpoint',
);
}
res.socket.setKeepAlive(true, 1000);
// Don't use the promisified version here as we want to stream the output
return new Promise((resolve, reject) => {
const req = request.get(url);
req.on('error', reject).on('response', (res) => {
if (res.statusCode !== 200) {
reject(
new ApiErrors.DeviceAPIError(
'Non-200 response from log streaming endpoint',
),
);
return;
}
try {
res.socket.setKeepAlive(true, 1000);
} catch (error) {
reject(error as Error);
}
resolve(res);
});
});
return stream;
}
private getUrlForAction(action: keyof typeof deviceEndpoints) {
private getUrlForAction(action: keyof typeof deviceEndpoints): string {
return `${this.deviceAddress}${deviceEndpoints[action]}`;
}
// A helper method for promisifying general (non-streaming) requests. Streaming
// requests should use a seperate setup
private static async sendRequest(
opts: Parameters<BalenaSDK['request']['send']>[number],
logger?: Logger,
) {
if (logger != null && opts.url != null) {
logger.logDebug(`Sending request to ${opts.url}`);
private static async promisifiedRequest<
T extends Parameters<typeof request>[0],
>(opts: T, logger?: Logger): Promise<any> {
interface ObjectWithUrl {
url?: string;
}
const sdk = getBalenaSdk();
const doRequest = async () => {
const response = await sdk.request.send(opts);
const bodyError =
typeof response.body === 'string'
? response.body
: response.body.message;
switch (response.statusCode) {
case 200:
return response.body;
case 400:
throw new ApiErrors.BadRequestDeviceAPIError(bodyError);
case 503:
throw new ApiErrors.ServiceUnavailableAPIError(bodyError);
default:
new ApiErrors.DeviceAPIError(bodyError);
if (logger != null) {
let url: string | null = null;
if (_.isObject(opts) && (opts as ObjectWithUrl).url != null) {
// the `as string` shouldn't be necessary, but the type system
// is getting a little confused
url = (opts as ObjectWithUrl).url!;
} else if (typeof opts === 'string') {
url = opts;
}
if (url != null) {
logger.logDebug(`Sending request to ${url}`);
}
}
const doRequest = async () => {
return await new Promise((resolve, reject) => {
return request(opts, (err, response, body) => {
if (err) {
reject(err as Error);
return;
}
switch (response.statusCode) {
case 200: {
resolve(body);
return;
}
case 400: {
reject(new ApiErrors.BadRequestDeviceAPIError(body.message));
return;
}
case 503: {
reject(new ApiErrors.ServiceUnavailableAPIError(body.message));
return;
}
default: {
reject(new ApiErrors.DeviceAPIError(body.message));
return;
}
}
});
});
};
return await retry({
func: doRequest,
initialDelayMs: 2000,
maxAttempts: 6,
label: `Supervisor API (${opts.method} ${opts.url})`,
label: `Supervisor API (${opts.method} ${(opts as any).url})`,
});
}
}
export default DeviceAPI;

View File

@ -603,11 +603,11 @@ function getImageNameFromTask(task: BuildTask): string {
}
export function generateTargetState(
currentTargetState: Record<string, any>,
currentTargetState: any,
composition: Composition,
buildTasks: BuildTask[],
env: ParsedEnvironment,
) {
): any {
const keyedBuildTasks = _.keyBy(buildTasks, 'serviceName');
const services: { [serviceId: string]: any } = {};

View File

@ -28,7 +28,7 @@ import { instanceOf } from '../../errors';
import Logger = require('../logger');
import { Dockerfile } from 'livepush';
import type { DeviceAPI } from './api';
import type DeviceAPI from './api';
import type { DeviceInfo, Status } from './api';
import type { DeviceDeployOptions } from './deploy';
import { generateTargetState, rebuildSingleTask } from './deploy';

View File

@ -110,27 +110,6 @@ export async function getManifest(
const init = await import('balena-device-init');
const sdk = getBalenaSdk();
const manifest = await init.getImageManifest(image);
if (manifest != null) {
const config = manifest.configuration?.config;
if (config?.partition != null) {
const { getBootPartition } = await import('balena-config-json');
// Find the device-type.json property that holds the boot partition number for
// this device type (config.partition or config.partition.primary) and overwrite it
// with the boot partition number that was found by inspecting the image.
// since it's deprecated & no longer updated for newer releases.
if (typeof config.partition === 'number') {
config.partition = await getBootPartition(image);
} else if (config.partition.primary != null) {
config.partition.primary = await getBootPartition(image);
}
// TODO: Add handling for when we no longer include a `config.partition` at all.
}
} else {
// TODO: Change this in the next major to throw, after confirming that this works for all supported OS versions.
console.error(
`[warn] Error while finding a device-type.json on the provided image path. Attempting to fetch from the API.`,
);
}
if (
manifest != null &&
manifest.slug !== deviceType &&

View File

@ -1,69 +0,0 @@
/**
* @license
* Copyright 2025 Balena Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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.
*/
// Utilities to explore the contents in a balenaOS image.
import * as imagefs from 'balena-image-fs';
import * as filedisk from 'file-disk';
import { getPartitions } from 'partitioninfo';
import type * as Fs from 'fs';
/**
* @summary IDs for the standard balenaOS partitions
* @description Values are the base name for a partition on disk
*/
export enum BalenaPartition {
BOOT = 'boot',
ROOTA = 'rootA',
ROOTB = 'rootB',
STATE = 'state',
DATA = 'data',
}
/**
* @summary Allow a provided function to explore the contents of one of the well-known
* partitions of a balenaOS image
*
* @param {string} imagePath - pathname of image for search
* @param {BalenaPartition} partitionId - partition to find
* @param {(fs) => Promise<T>} - function for exploration
* @returns {T}
*/
export async function explorePartition<T>(
imagePath: string,
partitionId: BalenaPartition,
exploreFn: (fs: typeof Fs) => Promise<T>,
): Promise<T> {
return await filedisk.withOpenFile(imagePath, 'r', async (handle) => {
const disk = new filedisk.FileDisk(handle, true, false, false);
const partitionInfo = await getPartitions(disk, {
includeExtended: false,
getLogical: true,
});
const findResult = await imagefs.findPartition(disk, partitionInfo, [
`resin-${partitionId}`,
`flash-${partitionId}`,
`balena-${partitionId}`,
]);
if (findResult == null) {
throw new Error(`Can't find partition for ${partitionId}`);
}
return await imagefs.interact<T>(disk, findResult.index, exploreFn);
});
}

View File

@ -76,28 +76,38 @@ export const getImagePath = async (deviceType: string, version?: string) => {
};
/**
* @summary Determine if a device image is cached
* @summary Determine if a device image is fresh
*
* @description
* If the device image does not exist, return false.
*
* @param {String} deviceType - device type slug or alias
* @param {String} version - the exact balenaOS version number
* @returns {Promise<Boolean>} is image cached
* @returns {Promise<Boolean>} is image fresh
*
* @example
* isImageCached ('raspberry-pi', '1.2.3').then (isCached) ->
* if isCached
* console.log('The Raspberry Pi image v1.2.3 is cached!')
* isImageFresh('raspberry-pi', '1.2.3').then (isFresh) ->
* if isFresh
* console.log('The Raspberry Pi image v1.2.3 is fresh!')
*/
export const isImageCached = async (deviceType: string, version: string) => {
export const isImageFresh = async (deviceType: string, version: string) => {
const imagePath = await getImagePath(deviceType, version);
let createdDate;
try {
const createdDate = await getFileCreatedDate(imagePath);
return createdDate != null;
createdDate = await getFileCreatedDate(imagePath);
} catch {
// Swallow errors from getFileCreatedTime.
}
if (createdDate == null) {
return false;
}
const balena = getBalenaSdk();
const lastModifiedDate = await balena.models.os.getLastModified(
deviceType,
version,
);
return lastModifiedDate < createdDate;
};
/**
@ -276,7 +286,7 @@ export const getStream = async (
versionOrRange = 'latest';
}
const version = await resolveVersion(deviceType, versionOrRange);
const isFresh = await isImageCached(deviceType, version);
const isFresh = await isImageFresh(deviceType, version);
const $stream = isFresh
? await getImage(deviceType, version)
: await doDownload({ ...options, deviceType, version });

View File

@ -21,7 +21,6 @@ import type { Chalk } from 'chalk';
import type * as visuals from 'resin-cli-visuals';
import type * as CliForm from 'resin-cli-form';
import type { ux } from '@oclif/core';
import { version } from '../../package.json';
// Equivalent of _.once but avoiding the need to import lodash for lazy deps
const once = <T>(fn: () => T) => {
@ -44,26 +43,9 @@ export const onceAsync = <T>(fn: () => Promise<T>) => {
};
};
const cliXBalenaClientHeaderInterceptor: BalenaSdk.Interceptor = {
request($request) {
if ($request.headers['X-Balena-Client']) {
// We intentionally overwrite the sdk version string from the header
// to conserve bandwidth. We only do that when the SDK already has specified
// the X-Balena-Client header, since that signals that this is a safe url to
// include the extra header and will not cause CORS errors.
$request.headers['X-Balena-Client'] = `balena-cli/${version}`;
}
return $request;
},
};
export const getBalenaSdk = once(() => {
const sdk = (require('balena-sdk') as typeof BalenaSdk).fromSharedOptions();
if (!sdk.interceptors.includes(cliXBalenaClientHeaderInterceptor)) {
sdk.interceptors.push(cliXBalenaClientHeaderInterceptor);
}
return sdk;
});
export const getBalenaSdk = once(() =>
(require('balena-sdk') as typeof BalenaSdk).fromSharedOptions(),
);
export const getVisuals = once(
() => require('resin-cli-visuals') as typeof visuals,

View File

@ -159,9 +159,9 @@ especially discouraged in scripts (e.g. CI environments).`;
export const devModeInfo = `\
The '--dev' option is used to configure balenaOS to operate in development mode,
allowing unauthenticated root ssh access and exposing network ports such as
allowing anauthenticated root ssh access and exposing network ports such as
balenaEngine's 2375 (unencrypted). This option causes \`"developmentMode": true\`
to be inserted in the 'config.json' file in the image's boot partition. Development
to be inserted in the 'config.json' file in the image's boot partion. Development
mode (as a configurable option) is applicable to balenaOS releases from early
2022. Older releases have separate development and production balenaOS images
that cannot be reconfigured through 'config.json' or the '--dev' option. Do not

View File

@ -50,7 +50,7 @@ export function copyQemu(context: string, arch: string) {
.then(() => getQemuPath(arch))
.then(
(qemu) =>
new Promise<void>(function (resolve, reject) {
new Promise(function (resolve, reject) {
const read = fs.createReadStream(qemu);
const write = fs.createWriteStream(binPath);
@ -94,7 +94,7 @@ async function installQemu(arch: string, qemuPath: string) {
const urlVersion = encodeURIComponent(QEMU_VERSION);
const qemuUrl = `https://github.com/balena-io/qemu/releases/download/${urlVersion}/${urlFile}`;
const { default: got } = await import('got');
const request = await import('request');
const fs = await import('fs');
const zlib = await import('zlib');
const tar = await import('tar-stream');
@ -117,8 +117,7 @@ async function installQemu(arch: string, qemuPath: string) {
reject(err as Error);
}
});
got.stream
.get(qemuUrl)
request(qemuUrl)
.on('error', reject)
.pipe(zlib.createGunzip())
.on('error', reject)

View File

@ -16,8 +16,7 @@ limitations under the License.
import type { BalenaSDK } from 'balena-sdk';
import * as JSONStream from 'JSONStream';
import * as readline from 'readline';
import type { PlainResponse } from 'got';
import type got from 'got';
import * as request from 'request';
import type { RegistrySecrets } from '@balena/compose/dist/multibuild';
import type * as Stream from 'stream';
import streamToPromise = require('stream-to-promise');
@ -120,7 +119,7 @@ export async function startRemoteBuild(
} catch (err) {
console.error(err.message);
} finally {
buildRequest.destroy();
buildRequest.abort();
const sigintErr = new SIGINTError('Build aborted on SIGINT signal');
sigintErr.code = 'SIGINT';
stream.emit('error', sigintErr);
@ -338,29 +337,32 @@ async function getTarStream(build: RemoteBuild): Promise<Stream.Readable> {
/**
* Initiate a POST HTTP request to the remote builder and add some event
* listeners.
*
* ¡! Note: this function must be synchronous because of a bug in the `request`
* library that requires the following two steps to take place in the same
* iteration of Node's event loop: (1) adding a listener for the 'response'
* event and (2) calling request.pipe():
* https://github.com/request/request/issues/887
*/
async function createRemoteBuildRequest(
function createRemoteBuildRequest(
build: RemoteBuild,
tarStream: Stream.Readable,
builderUrl: string,
onError: (error: Error) => void,
) {
const { default: got } = await import('got');
const zlib = await import('zlib');
): request.Request {
const zlib = require('zlib') as typeof import('zlib');
if (DEBUG_MODE) {
console.error(`[debug] Connecting to builder at ${builderUrl}`);
}
return got.stream
.post(builderUrl, {
headers: {
Authorization: `Bearer ${build.auth}`,
'Content-Encoding': 'gzip',
},
return request
.post({
url: builderUrl,
auth: { bearer: build.auth },
headers: { 'Content-Encoding': 'gzip' },
body: tarStream.pipe(zlib.createGzip({ level: 6 })),
throwHttpErrors: false,
})
.once('error', onError) // `.once` because the handler re-emits
.once('response', (response: PlainResponse) => {
.once('response', (response: request.RequestResponse) => {
if (response.statusCode >= 100 && response.statusCode < 400) {
if (DEBUG_MODE) {
console.error(
@ -372,8 +374,8 @@ async function createRemoteBuildRequest(
'Remote builder responded with HTTP error:',
`${response.statusCode} ${response.statusMessage}`,
];
if (response.rawBody) {
msgArr.push(response.rawBody.toString());
if (response.body) {
msgArr.push(response.body);
}
onError(new ExpectedError(msgArr.join('\n')));
}
@ -382,7 +384,7 @@ async function createRemoteBuildRequest(
async function getRemoteBuildStream(
build: RemoteBuild,
): Promise<[ReturnType<typeof got.stream.post>, Stream.Stream]> {
): Promise<[request.Request, Stream.Stream]> {
const builderUrl = await getBuilderEndpoint(
build.baseUrl,
build.appSlug,
@ -410,7 +412,7 @@ async function getRemoteBuildStream(
}
const tarStream = await getTarStream(build);
const buildRequest = await createRemoteBuildRequest(
const buildRequest = createRemoteBuildRequest(
build,
tarStream,
builderUrl,

View File

@ -20,7 +20,7 @@ import * as chaiAsPromised from 'chai-as-promised';
import * as ejs from 'ejs';
import * as fs from 'fs';
import * as path from 'path';
import got from 'got';
import * as request from 'request';
import * as sinon from 'sinon';
import { LoginServer } from '../../build/auth/server';
@ -61,30 +61,38 @@ describe('Login server:', function () {
server.shutdown();
});
async function testLogin({
verb = 'post',
...opt
}: {
async function testLogin(opt: {
expectedBody: string;
expectedErrorMsg?: string;
expectedStatusCode: number;
expectedToken: string;
urlPath?: string;
verb?: 'post' | 'put';
verb?: string;
}) {
opt.urlPath = opt.urlPath ?? addr.urlPath;
const res = await got[verb](
`http://${addr.host}:${addr.port}${opt.urlPath}`,
{
form: {
token: opt.expectedToken,
const post = opt.verb
? ((request as any)[opt.verb] as typeof request.post)
: request.post;
await new Promise<void>((resolve, reject) => {
post(
`http://${addr.host}:${addr.port}${opt.urlPath}`,
{
form: {
token: opt.expectedToken,
},
},
throwHttpErrors: false,
},
);
expect(res.body).to.equal(opt.expectedBody);
expect(res.statusCode).to.equal(opt.expectedStatusCode);
function (error, response, body) {
try {
expect(error).to.not.exist;
expect(response.statusCode).to.equal(opt.expectedStatusCode);
expect(body).to.equal(opt.expectedBody);
resolve();
} catch (err) {
reject(err as Error);
}
},
);
});
try {
const token = await server.awaitForToken();
@ -119,7 +127,7 @@ describe('Login server:', function () {
expectedStatusCode: 404,
expectedToken: tokens.johndoe.token,
expectedErrorMsg: 'Unknown path or verb',
verb: 'put',
verb: 'get',
});
});

View File

@ -442,93 +442,6 @@ describe('balena build', function () {
});
});
it('should create the expected tar stream (docker-compose --nologs)', async () => {
const projectPath = path.join(projectsPath, 'docker-compose', 'basic');
const service1Dockerfile = (
await fs.readFile(
path.join(projectPath, 'service1', 'Dockerfile.template'),
'utf8',
)
).replace('%%BALENA_MACHINE_NAME%%', 'nuc');
const expectedFilesByService: ExpectedTarStreamFilesByService = {
service1: {
Dockerfile: {
contents: service1Dockerfile,
fileSize: service1Dockerfile.length,
type: 'file',
},
'Dockerfile.template': { fileSize: 144, type: 'file' },
'file1.sh': { fileSize: 12, type: 'file' },
},
service2: {
'Dockerfile-alt': { fileSize: 13, type: 'file' },
'file2-crlf.sh': {
fileSize: isWindows ? 12 : 14,
testStream: isWindows ? expectStreamNoCRLF : undefined,
type: 'file',
},
'src/file1.sh': { fileSize: 12, type: 'file' },
},
};
const responseFilename = 'build-POST.json';
const responseBody = await fs.readFile(
path.join(dockerResponsePath, responseFilename),
'utf8',
);
const expectedQueryParamsByService = {
service1: Object.entries(
_.merge({}, commonComposeQueryParams, {
buildargs: {
COMPOSE_ARG: 'A',
barg: 'b',
SERVICE1_VAR: 'This is a service specific variable',
},
cachefrom: ['my/img1', 'my/img2'],
}),
),
service2: Object.entries(
_.merge({}, commonComposeQueryParamsIntel, {
buildargs: {
COMPOSE_ARG: 'A',
barg: 'b',
},
cachefrom: ['my/img1', 'my/img2'],
dockerfile: 'Dockerfile-alt',
}),
),
};
const expectedResponseLines: string[] = [
...commonResponseLines[responseFilename],
...getDockerignoreWarn1(
[path.join(projectPath, 'service2', '.dockerignore')],
'build',
),
];
if (isWindows) {
expectedResponseLines.push(
`[Info] Converting line endings CRLF -> LF for file: ${path.join(
projectPath,
'service2',
'file2-crlf.sh',
)}`,
);
}
docker.expectGetInfo({});
docker.expectGetManifestNucAlpine();
docker.expectGetManifestBusybox();
await testDockerBuildStream({
commandLine: `build ${projectPath} --deviceType nuc --arch amd64 -B COMPOSE_ARG=A -B barg=b --cache-from my/img1,my/img2 --nologs`,
dockerMock: docker,
expectedFilesByService,
expectedQueryParamsByService,
expectedResponseLines,
projectPath,
responseBody,
responseCode: 200,
services: ['service1', 'service2'],
});
});
it('should create the expected tar stream (docker-compose, --multi-dockerignore)', async () => {
const projectPath = path.join(projectsPath, 'docker-compose', 'basic');
const service1Dockerfile = (

View File

@ -27,7 +27,7 @@ import * as sinon from 'sinon';
import { BalenaAPIMock } from '../nock/balena-api-mock';
import { expectStreamNoCRLF, testDockerBuildStream } from '../docker-build';
import { DockerMock, dockerResponsePath } from '../nock/docker-mock';
import { cleanOutput, runCommand } from '../helpers';
import { cleanOutput, runCommand, switchSentry } from '../helpers';
import type {
ExpectedTarStreamFiles,
ExpectedTarStreamFilesByService,
@ -262,6 +262,7 @@ describe('balena deploy', function () {
});
it('should update a release with status="failed" on error (single container)', async () => {
let sentryStatus: boolean | undefined;
const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic');
const expectedFiles: ExpectedTarStreamFiles = {
'src/.dockerignore': { fileSize: 16, type: 'file' },
@ -318,6 +319,7 @@ describe('balena deploy', function () {
api.expectPostImageLabel();
try {
sentryStatus = await switchSentry(false);
sinon.stub(process, 'exit');
await testDockerBuildStream({
@ -335,8 +337,9 @@ describe('balena deploy', function () {
});
expect(failedImagePatchRequests).to.equal(maxRequestRetries);
} finally {
// We mock process.exit and need to force cast it to a SinonStub to restore it
(process.exit as unknown as sinon.SinonStub).restore();
await switchSentry(sentryStatus);
// @ts-expect-error claims restore does not exist
process.exit.restore();
}
});

View File

@ -114,14 +114,6 @@ describe('balena device', function () {
'Content-Type': 'application/json',
});
api.scope
.get(
/^\/v\d+\/device\?.+&\$select=overall_status,overall_progress,should_be_running__release$/,
)
.replyWithFile(200, path.join(apiResponsePath, 'device.json'), {
'Content-Type': 'application/json',
});
const { out, err } = await runCommand('device 27fda508c --json');
expect(err).to.be.empty;
const json = JSON.parse(out.join(''));

View File

@ -21,6 +21,8 @@ import { BalenaAPIMock } from '../../nock/balena-api-mock';
import { cleanOutput, runCommand } from '../../helpers';
import { SupervisorMock } from '../../nock/supervisor-mock';
const itS = process.env.BALENA_CLI_TEST_TYPE === 'standalone' ? it : it.skip;
describe('balena device logs', function () {
let api: BalenaAPIMock;
let supervisor: SupervisorMock;
@ -37,7 +39,10 @@ describe('balena device logs', function () {
supervisor.done();
});
it('should reach the expected endpoints on a local device', async () => {
// skip non-standalone tests because nock's mock socket causes the error:
// "setKeepAliveInterval expects an instance of socket as its first argument"
// in utils/device/api.ts: NetKeepalive.setKeepAliveInterval(sock, 5000);
itS('should reach the expected endpoints on a local device', async () => {
supervisor.expectGetPing();
supervisor.expectGetLogs();
supervisor.expectGetLogs();

View File

@ -22,7 +22,6 @@ import { runCommand } from '../../helpers';
import { promisify } from 'util';
import * as tmp from 'tmp';
import type * as $imagefs from 'balena-image-fs';
import * as stripIndent from 'common-tags/lib/stripIndent';
tmp.setGracefulCleanup();
const tmpNameAsync = promisify(tmp.tmpName);
@ -35,7 +34,6 @@ if (process.platform !== 'win32') {
let api: BalenaAPIMock;
let tmpDummyPath: string;
let tmpMatchingDtJsonPartitionPath: string;
let tmpNonMatchingDtJsonPartitionPath: string;
before(async function () {
// We conditionally import balena-image-fs, since when imported on top level then unrelated tests on win32 failed with:
@ -49,48 +47,6 @@ if (process.platform !== 'win32') {
'./tests/test-data/mock-jetson-nano-6.0.13.with-boot-partition-12.img',
tmpMatchingDtJsonPartitionPath,
);
tmpNonMatchingDtJsonPartitionPath = (await tmpNameAsync()) as string;
// Create an image with a device-type.json that mentions a non matching boot partition.
// We copy the pre-existing image and modify it, since including a separate one
// would add 18MB more to the repository.
await fs.copyFile(
'./tests/test-data/mock-jetson-nano-6.0.13.with-boot-partition-12.img',
tmpNonMatchingDtJsonPartitionPath,
);
await imagefs.interact(
tmpNonMatchingDtJsonPartitionPath,
12,
async (_fs) => {
const dtJson = JSON.parse(
await _fs.promises.readFile('/device-type.json', {
encoding: 'utf8',
}),
);
expect(dtJson).to.have.nested.property(
'configuration.config.partition',
12,
);
dtJson.configuration.config.partition = 999;
await _fs.promises.writeFile(
'/device-type.json',
JSON.stringify(dtJson),
);
await _fs.promises.writeFile(
'/os-release',
stripIndent`
ID="balena-os"
NAME="balenaOS"
VERSION="6.1.25"
VERSION_ID="6.1.25"
PRETTY_NAME="balenaOS 6.1.25"
DISTRO_CODENAME="kirkstone"
MACHINE="jetson-nano"
META_BALENA_VERSION="6.1.25"`,
);
},
);
});
beforeEach(() => {
@ -105,20 +61,20 @@ if (process.platform !== 'win32') {
after(async () => {
await fs.unlink(tmpDummyPath);
await fs.unlink(tmpMatchingDtJsonPartitionPath);
await fs.unlink(tmpNonMatchingDtJsonPartitionPath);
});
it('should detect the OS version and inject a valid config.json file to a 6.0.13 image with partition 12 as boot & matching device-type.json', async () => {
it('should inject a valid config.json file to an image with partition 12 as boot & matching device-type.json ', async () => {
api.expectGetApplication();
api.expectGetDeviceTypes();
// It should not reach to /config or /device-types/v1 but instead find
// TODO: this shouldn't be necessary & the CLI should be able to find
// everything required from the device-type.json in the image.
// api.expectGetConfigDeviceTypes();
api.expectGetConfigDeviceTypes();
api.expectDownloadConfig();
const command: string[] = [
`os configure ${tmpMatchingDtJsonPartitionPath}`,
'--device-type jetson-nano',
'--version 6.0.13',
'--fleet testApp',
'--config-app-update-poll-interval 10',
'--config-network ethernet',
@ -135,65 +91,16 @@ if (process.platform !== 'win32') {
tmpMatchingDtJsonPartitionPath,
12,
async (_fs) => {
const readFileAsync = promisify(_fs.readFile);
const dtJson = JSON.parse(
await _fs.promises.readFile('/device-type.json', {
encoding: 'utf8',
}),
await readFileAsync('/device-type.json', { encoding: 'utf8' }),
);
// confirm that the device-type.json mentions the expected partition
expect(dtJson).to.have.nested.property(
'configuration.config.partition',
12,
);
return await _fs.promises.readFile('/config.json');
},
);
expect(config).to.not.be.empty;
// confirm the image has the correct config.json values...
const configObj = JSON.parse(config.toString('utf8'));
expect(configObj).to.have.property('deviceType', 'jetson-nano');
expect(configObj).to.have.property('initialDeviceName', 'testDeviceName');
});
it('should detect the OS version and inject a valid config.json file to a 6.1.25 image with partition 12 as boot & a non-matching device-type.json', async () => {
api.expectGetApplication();
api.expectGetDeviceTypes();
// It should not reach to /config or /device-types/v1 but instead find
// everything required from the device-type.json in the image.
// api.expectGetConfigDeviceTypes();
api.expectDownloadConfig();
const command: string[] = [
`os configure ${tmpNonMatchingDtJsonPartitionPath}`,
'--device-type jetson-nano',
'--fleet testApp',
'--config-app-update-poll-interval 10',
'--config-network ethernet',
'--initial-device-name testDeviceName',
'--provisioning-key-name testKey',
'--provisioning-key-expiry-date 2050-12-12',
];
const { err } = await runCommand(command.join(' '));
expect(err.join('')).to.equal('');
// confirm the image contains a config.json...
const config = await imagefs.interact(
tmpNonMatchingDtJsonPartitionPath,
12,
async (_fs) => {
const dtJson = JSON.parse(
await _fs.promises.readFile('/device-type.json', {
encoding: 'utf8',
}),
);
// confirm that the device-type.json mentions the expected partition
expect(dtJson).to.have.nested.property(
'configuration.config.partition',
999,
);
return await _fs.promises.readFile('/config.json');
return await readFileAsync('/config.json');
},
);
expect(config).to.not.be.empty;
@ -227,28 +134,11 @@ if (process.platform !== 'win32') {
const { err } = await runCommand(command.join(' '));
// Once we replace the dummy.img with one that includes a os-release & device-type.json
// then we should be able to change this to expect no errors.
expect(
err.flatMap((line) => line.split('\n')).filter((line) => line !== ''),
).to.deep.equal(
stripIndent`
[warn] "${tmpDummyPath}":
[warn] Found partition table with 1 partitions,
[warn] but none with a name/label in ['resin-boot', 'flash-boot', 'balena-boot'].
[warn] Will scan all partitions for contents.
[warn] "${tmpDummyPath}":
[warn] 1 partition(s) found, but none containing file "/device-type.json".
[warn] Assuming default boot partition number '1'.
[warn] "${tmpDummyPath}":
[warn] Could not find a previous "/config.json" file in partition '1'.
[warn] Proceeding anyway, but this is unexpected.
[warn] Error while finding a device-type.json on the provided image path. Attempting to fetch from the API.`.split(
'\n',
),
);
expect(err.join('')).to.equal('');
// confirm the image contains a config.json...
const config = await imagefs.interact(tmpDummyPath, 1, async (_fs) => {
return await _fs.promises.readFile('/config.json');
return await promisify(_fs.readFile)('/config.json');
});
expect(config).to.not.be.empty;

View File

@ -82,7 +82,7 @@ describe('balena release', function () {
expect(err).to.be.empty;
const json = JSON.parse(out.join(''));
expect(json[0].commit).to.equal('90247b54de4fa7a0a3cbc85e73c68039');
expect(json[0].release_image[0].image[0].start_timestamp).to.equal(
expect(json[0].contains__image[0].image[0].start_timestamp).to.equal(
'2020-01-04T01:13:08.583Z',
);
});

View File

@ -174,9 +174,19 @@ async function runCommandInSubprocess(
};
const { exec } = await import('child_process');
// check if standalonePath exists and print its content
if (!fs.existsSync(standalonePath)) {
console.error('standalonePath does not exist');
} else {
console.error('file exists!');
}
await new Promise<void>((resolve) => {
const child = exec(
`${standalonePath} ${cmd}`,
`${standalonePath} ${cmd
.split(' ')
.filter((c) => c)
.join(' ')}`,
{ env: { ...process.env, ...addedEnvs } },
($error, $stdout, $stderr) => {
stderr = $stderr || '';
@ -417,3 +427,15 @@ export function deepJsonParse(data: any): any {
}
return data;
}
export async function switchSentry(
enabled: boolean | undefined,
): Promise<boolean | undefined> {
const balenaCLI = await import('../build/app');
const sentryOpts = (await balenaCLI.setupSentry()).getClient()?.getOptions();
if (sentryOpts) {
const sentryStatus = sentryOpts.enabled;
sentryOpts.enabled = enabled;
return sentryStatus;
}
}

View File

@ -41,18 +41,23 @@ export class BuilderMock extends NockMock {
checkBuildRequestBody: (requestBody: string | Buffer) => Promise<void>;
}) {
this.optPost(/^\/v3\/build($|[(?])/, opts).reply(
async function (uri, requestBody) {
await opts.checkURI(uri);
if (typeof requestBody === 'string') {
const gzipped = Buffer.from(requestBody, 'hex');
const gunzipped = await gunzipAsync(gzipped);
await opts.checkBuildRequestBody(gunzipped);
return [opts.responseCode, opts.responseBody];
} else {
throw new Error(
`unexpected requestBody type "${typeof requestBody}"`,
);
async function (uri, requestBody, callback) {
let error: Error | null = null;
try {
await opts.checkURI(uri);
if (typeof requestBody === 'string') {
const gzipped = Buffer.from(requestBody, 'hex');
const gunzipped = await gunzipAsync(gzipped);
await opts.checkBuildRequestBody(gunzipped);
} else {
throw new Error(
`unexpected requestBody type "${typeof requestBody}"`,
);
}
} catch (err) {
error = err;
}
callback(error, [opts.responseCode, opts.responseBody]);
},
);
}

View File

@ -81,14 +81,21 @@ export class DockerMock extends NockMock {
this.optPost(
new RegExp(`^/build\\?(|.+&)${qs.stringify({ t: opts.tag })}&`),
opts,
).reply(async function (uri, requestBody) {
await opts.checkURI(uri);
if (typeof requestBody === 'string') {
await opts.checkBuildRequestBody(requestBody);
return [opts.responseCode, opts.responseBody];
} else {
throw new Error(`unexpected requestBody type "${typeof requestBody}"`);
).reply(async function (uri, requestBody, cb) {
let error: Error | null = null;
try {
await opts.checkURI(uri);
if (typeof requestBody === 'string') {
await opts.checkBuildRequestBody(requestBody);
} else {
throw new Error(
`unexpected requestBody type "${typeof requestBody}"`,
);
}
} catch (err) {
error = err;
}
cb(error, [opts.responseCode, opts.responseBody]);
});
}

View File

@ -10,7 +10,7 @@
"build_log": null,
"start_timestamp": "2021-08-25T22:18:33.624Z",
"end_timestamp": "2021-08-25T22:18:48.820Z",
"release_image": [
"contains__image": [
{
"image": [
{

View File

@ -63,7 +63,7 @@ describe('detectEncoding() function', function () {
it('should correctly detect the encoding of a few selected files', async () => {
const sampleBinary = [
'drivelist/build/Release/drivelist.node',
'mountutils/prebuilds/linux-x64/mountutils.node',
'mountutils/build/Release/MountUtils.node',
];
const sampleText = [
'node_modules/.bin/mocha',

View File

@ -42,7 +42,7 @@ describe('image-manager', function () {
describe('given the image is fresh', function () {
beforeEach(function () {
this.cacheIsImageFresh = stub(imageManager, 'isImageCached');
this.cacheIsImageFresh = stub(imageManager, 'isImageFresh');
return this.cacheIsImageFresh.resolves(true);
});
@ -56,7 +56,7 @@ describe('image-manager', function () {
void imageManager.getStream('raspberry-pi').then(function (stream) {
let result = '';
stream.on('data', (chunk: string) => (result += chunk.toString()));
stream.on('data', (chunk) => (result += chunk.toString()));
return stream.on('end', function () {
expect(result).to.equal('Cache image');
@ -68,7 +68,7 @@ describe('image-manager', function () {
describe('given the image is not fresh', function () {
beforeEach(function () {
this.cacheIsImageFresh = stub(imageManager, 'isImageCached');
this.cacheIsImageFresh = stub(imageManager, 'isImageFresh');
return this.cacheIsImageFresh.resolves(false);
});
@ -91,7 +91,7 @@ describe('image-manager', function () {
void imageManager.getStream('raspberry-pi').then((stream) => {
let result = '';
stream.on('data', (chunk: string) => (result += chunk));
stream.on('data', (chunk) => (result += chunk));
stream.on('end', async () => {
expect(result).to.equal('Download image');
@ -280,7 +280,7 @@ describe('image-manager', function () {
});
});
describe('.isImageCached()', () => {
describe('.isImageFresh()', () => {
describe('given the raspberry-pi manifest', function () {
beforeEach(function () {
this.getDeviceTypeManifestBySlugStub = stub(
@ -314,8 +314,78 @@ describe('image-manager', function () {
});
it('should return false', async function () {
expect(await imageManager.isImageCached('raspberry-pi', '1.2.3')).to
.be.false;
expect(await imageManager.isImageFresh('raspberry-pi', '1.2.3')).to.be
.false;
});
});
describe('given a fixed created time', function () {
beforeEach(function () {
this.utilsGetFileCreatedDate = stub(
imageManager,
'getFileCreatedDate',
);
this.utilsGetFileCreatedDate.resolves(
new Date('2014-01-01T00:00:00.000Z'),
);
});
afterEach(function () {
this.utilsGetFileCreatedDate.restore();
});
describe('given the file was created before the os last modified time', function () {
beforeEach(function () {
this.osGetLastModified = stub(balena.models.os, 'getLastModified');
this.osGetLastModified.resolves(
new Date('2014-02-01T00:00:00.000Z'),
);
});
afterEach(function () {
this.osGetLastModified.restore();
});
it('should return false', function () {
const promise = imageManager.isImageFresh('raspberry-pi', '1.2.3');
return expect(promise).to.eventually.be.false;
});
});
describe('given the file was created after the os last modified time', function () {
beforeEach(function () {
this.osGetLastModified = stub(balena.models.os, 'getLastModified');
this.osGetLastModified.resolves(
new Date('2013-01-01T00:00:00.000Z'),
);
});
afterEach(function () {
this.osGetLastModified.restore();
});
it('should return true', function () {
const promise = imageManager.isImageFresh('raspberry-pi', '1.2.3');
return expect(promise).to.eventually.be.true;
});
});
describe('given the file was created just at the os last modified time', function () {
beforeEach(function () {
this.osGetLastModified = stub(balena.models.os, 'getLastModified');
this.osGetLastModified.resolves(
new Date('2014-00-01T00:00:00.000Z'),
);
});
afterEach(function () {
this.osGetLastModified.restore();
});
it('should return false', function () {
const promise = imageManager.isImageFresh('raspberry-pi', '1.2.3');
return expect(promise).to.eventually.be.false;
});
});
});
});
@ -342,7 +412,7 @@ describe('image-manager', function () {
.then(function (stream) {
let result = '';
stream.on('data', (chunk) => (result += chunk as string));
stream.on('data', (chunk: string) => (result += chunk));
stream.on('end', function () {
expect(result).to.equal('Lorem ipsum dolor sit amet');