Compare commits

..

2 Commits

Author SHA1 Message Date
ae18df6710 Fix Windows installer build in new balena CI workers (qqjs patch)
Change-type: patch
2020-12-01 15:19:26 +00:00
8101ab38a6 Fix 'balena ssh' test cases when using the Windows built-in ssh tool
Change-type: patch
2020-12-01 15:19:26 +00:00
365 changed files with 35017 additions and 71075 deletions

View File

@ -1,37 +0,0 @@
# Reminders:
# * Matching rules are different to `.gitignore`
# * A pattern without '**' matches in the project's root directory only
# * Leading and trailing '/' are discarded (it is not possible to
# distinguish between files and directories)
# * More details: https://github.com/balena-io-modules/dockerignore
# development and testing tools or IDEs
**/*.log
**/*.pid
**/*.seed
.idea
.lock-wscript
.nvmrc
.nyc_output
.vscode
coverage
lib-cov
logs
pids
# OS cache files
**/.DS_Store
# balena CLI config and build files
**/.balenaconf
**/.fast-boot.json
**/.resinconf
balenarc.yml
build
build-bin
dist
node_modules
oclif.manifest.json
package-lock.json
resinrc.yml
tmp

6
.gitattributes vendored
View File

@ -4,13 +4,9 @@
*.* -eol *.* -eol
*.sh text eol=lf *.sh text eol=lf
.dockerignore eol=lf
Dockerfile eol=lf
Dockerfile.* eol=lf
* text=auto eol=lf
# lf for the docs as it's auto-generated and will otherwise trigger an uncommited error on windows # lf for the docs as it's auto-generated and will otherwise trigger an uncommited error on windows
docs/balena-cli.md text eol=lf doc/cli.markdown text eol=lf
# crlf for the eol conversion test files # crlf for the eol conversion test files
tests/test-data/projects/docker-compose/basic/service2/file2-crlf.sh eol=crlf tests/test-data/projects/docker-compose/basic/service2/file2-crlf.sh eol=crlf
tests/test-data/projects/no-docker-compose/basic/src/windows-crlf.sh eol=crlf tests/test-data/projects/no-docker-compose/basic/src/windows-crlf.sh eol=crlf

1
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1 @@
* @balena-io/balena-cli

View File

@ -32,11 +32,11 @@ Please describe what actually happened instead:
Examples: Examples:
``` ```
balena push myFleet balena push myApp
balena push 192.168.0.12 balena push 192.168.0.12
balena deploy myFleet balena deploy myApp
balena deploy myFleet --build balena deploy myApp --build
balena build . -f myFleet balena build . -a myApp
balena build . -A armv7hf -d raspberrypi3 balena build . -A armv7hf -d raspberrypi3
``` ```
@ -48,7 +48,7 @@ additional information. The `--logs` option reveals additional information for t
``` ```
balena build . --logs balena build . --logs
balena deploy myFleet --build --logs balena deploy myApp --build --logs
``` ```
# Steps to Reproduce the Problem # Steps to Reproduce the Problem

View File

@ -1,143 +0,0 @@
---
name: package and draft GitHub release
# https://github.com/product-os/flowzone/tree/master/.github/actions
inputs:
json:
description: 'JSON stringified object containing all the inputs from the calling workflow'
required: true
secrets:
description: 'JSON stringified object containing all the secrets from the calling workflow'
required: true
variables:
description: 'JSON stringified object containing all the variables from the calling workflow'
required: true
# --- custom environment
XCODE_APP_LOADER_EMAIL:
type: string
default: 'accounts+apple@balena.io'
NODE_VERSION:
type: string
default: '20.x'
VERBOSE:
type: string
default: 'true'
runs:
# https://docs.github.com/en/actions/creating-actions/creating-a-composite-action
using: 'composite'
steps:
- name: Download custom source artifact
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 }}
- name: Extract custom source artifact
shell: pwsh
working-directory: .
run: tar -xf ${{ runner.temp }}/custom.tgz
- name: Setup Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4
with:
node-version: ${{ inputs.NODE_VERSION }}
cache: npm
- name: Set up Python 3.11
if: runner.os == 'macOS'
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4
with:
python-version: "3.11"
- name: Install additional tools
if: runner.os == 'Windows'
shell: bash
run: |
choco install yq
- name: Install additional tools
if: runner.os == 'macOS'
shell: bash
run: |
brew install coreutils
# https://www.electron.build/code-signing.html
# https://github.com/Apple-Actions/import-codesign-certs
- name: Import Apple code signing certificate
if: runner.os == 'macOS'
uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2
with:
p12-file-base64: ${{ fromJSON(inputs.secrets).APPLE_SIGNING }}
p12-password: ${{ fromJSON(inputs.secrets).APPLE_SIGNING_PASSWORD }}
- name: Import Windows code signing certificate
if: runner.os == 'Windows'
shell: powershell
run: |
Set-Content -Path ${{ runner.temp }}/certificate.base64 -Value $env:SM_CLIENT_CERT_FILE_B64
certutil -decode ${{ runner.temp }}/certificate.base64 ${{ runner.temp }}/Certificate_pkcs12.p12
Remove-Item -path ${{ runner.temp }} -include certificate.base64
env:
SM_CLIENT_CERT_FILE_B64: ${{ fromJSON(inputs.secrets).SM_CLIENT_CERT_FILE_B64 }}
# https://github.com/product-os/scripts/tree/master/shared
# https://github.com/product-os/balena-concourse/blob/master/pipelines/github-events/template.yml
- name: Package release
shell: bash
run: |
set -ea
[[ '${{ inputs.VERBOSE }}' =~ on|On|Yes|yes|true|True ]] && set -x
runner_os="$(echo "${RUNNER_OS}" | tr '[:upper:]' '[:lower:]')"
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_KEYCHAIN=signing_temp
CSC_LINK=${{ fromJSON(inputs.secrets).APPLE_SIGNING }}
elif [[ $runner_os =~ windows|win ]]; then
SM_HOST=${{ fromJSON(inputs.secrets).SM_HOST }}
SM_API_KEY=${{ fromJSON(inputs.secrets).SM_API_KEY }}
SM_CLIENT_CERT_FILE='${{ runner.temp }}\Certificate_pkcs12.p12'
SM_CLIENT_CERT_PASSWORD=${{ fromJSON(inputs.secrets).SM_CLIENT_CERT_PASSWORD }}
SM_CODE_SIGNING_CERT_SHA1_HASH=${{ fromJSON(inputs.secrets).SM_CODE_SIGNING_CERT_SHA1_HASH }}
curl --silent --retry 3 --fail https://one.digicert.com/signingmanager/api-ui/v1/releases/smtools-windows-x64.msi/download \
-H "x-api-key:$SM_API_KEY" \
-o smtools-windows-x64.msi
msiexec -i smtools-windows-x64.msi -qn
PATH="/c/Program Files/DigiCert/DigiCert One Signing Manager Tools:${PATH}"
smksp_registrar.exe list
smctl.exe keypair ls
/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}"
fi
npm run package
find dist -type f -maxdepth 1
env:
# https://github.blog/2020-08-03-github-actions-improvements-for-fork-and-pull-request-workflows/#improvements-for-public-repository-forks
# https://docs.github.com/en/actions/managing-workflow-runs/approving-workflow-runs-from-public-forks#about-workflow-runs-from-public-forks
CSC_FOR_PULL_REQUEST: true
# https://docs.digicert.com/es/software-trust-manager/ci-cd-integrations/plugins/github-custom-action-for-keypair-signing.html
TIMESTAMP_SERVER: http://timestamp.digicert.com
# Apple notarization (automation/build-bin.ts)
XCODE_APP_LOADER_EMAIL: ${{ inputs.XCODE_APP_LOADER_EMAIL }}
XCODE_APP_LOADER_PASSWORD: ${{ fromJSON(inputs.secrets).XCODE_APP_LOADER_PASSWORD }}
XCODE_APP_LOADER_TEAM_ID: ${{ inputs.XCODE_APP_LOADER_TEAM_ID }}
- name: Upload artifacts
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: dist
retention-days: 1
if-no-files-found: error

View File

@ -1,65 +0,0 @@
---
name: test release
# https://github.com/product-os/flowzone/tree/master/.github/actions
inputs:
json:
description: "JSON stringified object containing all the inputs from the calling workflow"
required: true
secrets:
description: "JSON stringified object containing all the secrets from the calling workflow"
required: true
variables:
description: "JSON stringified object containing all the variables from the calling workflow"
required: true
# --- custom environment
NODE_VERSION:
type: string
default: '20.x'
VERBOSE:
type: string
default: "true"
runs:
# https://docs.github.com/en/actions/creating-actions/creating-a-composite-action
using: "composite"
steps:
# https://github.com/actions/setup-node#caching-global-packages-data
- name: Setup Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4
with:
node-version: ${{ inputs.NODE_VERSION }}
cache: npm
- name: Set up Python 3.11
if: runner.os == 'macOS'
uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4
with:
python-version: "3.11"
- name: Test release
shell: bash
run: |
set -ea
[[ '${{ inputs.VERBOSE }}' =~ on|On|Yes|yes|true|True ]] && set -x
if [[ -e package-lock.json ]] || [[ -e npm-shrinkwrap.json ]]; then
npm ci
else
npm i
fi
npm run build
npm run test:core
- name: Compress custom source
shell: pwsh
run: tar --exclude-vcs -acf ${{ runner.temp }}/custom.tgz .
- name: Upload custom artifact
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
retention-days: 1

View File

@ -1,4 +0,0 @@
{
"extends": ["github>balena-io/renovate-config"],
"postUpdateOptions": ["npmDedupe"]
}

View File

@ -1,45 +0,0 @@
name: Flowzone
on:
pull_request:
types: [opened, synchronize, closed]
branches: [main, master]
pull_request_target:
types: [opened, synchronize, closed]
branches: [main, master]
jobs:
flowzone:
name: Flowzone
uses: product-os/flowzone/.github/workflows/flowzone.yml@master
# prevent duplicate workflow executions for pull_request and pull_request_target
if: |
(
github.event.pull_request.head.repo.full_name == github.repository &&
github.event_name == 'pull_request'
) || (
github.event.pull_request.head.repo.full_name != github.repository &&
github.event_name == 'pull_request_target'
)
secrets: inherit
with:
custom_test_matrix: >
{
"os": [
["self-hosted", "X64"],
["self-hosted", "ARM64"],
["macos-13"],
["windows-2019"],
["macos-latest-xlarge"]
]
}
custom_publish_matrix: >
{
"os": [
["self-hosted", "X64"],
["self-hosted", "ARM64"],
["macos-13"],
["windows-2019"],
["macos-latest-xlarge"]
]
}
github_prerelease: false
restrict_custom_actions: false

69
.gitignore vendored
View File

@ -1,36 +1,47 @@
# Reminders: # Logs
# * A pattern without '/' matches in subdirectories as well (files and directories) logs
# * A leading '/' anchors matching to the directory where `.gitignore` is defined
# * A trailing '/' makes the pattern match against directories only
# More details: https://git-scm.com/docs/gitignore
# development and testing tools or IDEs
*.log *.log
# Runtime data
pids
*.pid *.pid
*.seed *.seed
/.idea/
/.lock-wscript
/.nvmrc
/.nyc_output/
/.vscode/
/coverage/
/lib-cov/
/logs
/pids
# OS cache files # Directory for instrumented libs generated by jscoverage/JSCover
.DS_Store lib-cov
# balena CLI config and build files # Coverage directory used by tools like istanbul
.balenaconf coverage
.fast-boot.json .nyc_output
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directory
# Commenting this out is preferred by some people, see
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git-
node_modules
package-lock.json
.resinconf .resinconf
/balenarc.yml .balenaconf
/build/ resinrc.yml
/build-bin/ balenarc.yml
/dist/
/node_modules .DS_Store
.idea
.nvmrc
.vscode
/tmp
build/
build-bin/
build-zip/
dist/
# Ignore fast-boot cache file
**/.fast-boot.json
/oclif.manifest.json /oclif.manifest.json
/package-lock.json
/resinrc.yml
/tmp/

View File

@ -1 +0,0 @@
node automation/check-npm-version.js && ts-node automation/check-doc.ts

View File

@ -1,10 +1,8 @@
module.exports = { module.exports = {
spec: 'tests/commands/app/create.spec.ts',
reporter: 'spec', reporter: 'spec',
require: 'ts-node/register/transpile-only', require: 'ts-node/register/transpile-only',
file: './tests/config-tests', file: './tests/config-tests',
timeout: 12000, timeout: 12000,
// To test only, say, 'push.spec.ts', do it as follows so that
// requests are authenticated:
// spec: ['tests/auth/*.spec.ts', 'tests/**/deploy.spec.ts'],
spec: 'tests/**/*.spec.ts', spec: 'tests/**/*.spec.ts',
}; };

20
.resinci.yml Normal file
View File

@ -0,0 +1,20 @@
---
npm:
platforms:
- name: linux
os: ubuntu
architecture: x86_64
node_versions:
- "10"
- "12"
- "14"
- name: linux
os: alpine
architecture: x86_64
node_versions:
- "10"
- "12"
- "14"
docker:
publish: false

25
.travis.yml Normal file
View File

@ -0,0 +1,25 @@
language: node_js
os:
- linux
- osx
node_js:
- "10"
matrix:
exclude:
node_js: "10"
script:
- node --version
- npm --version
- npm run ci
# - npm run build:standalone
# - npm run build:installer
notifications:
email: false
deploy:
- provider: script
script: npm run release
skip_cleanup: true
on:
tags: true
condition: "$TRAVIS_TAG =~ ^v?[[:digit:]]+\\.[[:digit:]]+\\.[[:digit:]]+"
repo: balena-io/balena-cli

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -105,83 +105,33 @@ npm run update balena-sdk ^13.0.0 major
## Editing documentation files (README, INSTALL, Reference website...) ## Editing documentation files (README, INSTALL, Reference website...)
The `docs/balena-cli.md` file is automatically generated by running `npm run build:doc` (which also The `doc/cli.markdown` file is automatically generated by running `npm run build:doc` (which also
runs as part of `npm run build`). That file is then pulled by scripts in the runs as part of `npm run build`). That file is then pulled by scripts in the
[balena-io/docs](https://github.com/balena-io/docs/) GitHub repo for publishing at the [CLI [balena-io/docs](https://github.com/balena-io/docs/) GitHub repo for publishing at the [CLI
Documentation page](https://www.balena.io/docs/reference/cli/). Documentation page](https://www.balena.io/docs/reference/cli/).
The content sources for the auto generation of `docs/balena-cli.md` are: The content sources for the auto generation of `doc/cli.markdown` are:
* [Selected * [Selected
sections](https://github.com/balena-io/balena-cli/blob/v12.23.0/automation/capitanodoc/capitanodoc.ts#L199-L204) sections](https://github.com/balena-io/balena-cli/blob/v12.23.0/automation/capitanodoc/capitanodoc.ts#L199-L204)
of the README file. of the README file.
* The CLI's command documentation in source code (`src/commands/` folder), for example: * The CLI's command documentation in source code (`lib/commands/` folder), for example:
* `src/commands/push.ts` * `lib/commands/push.ts`
* `src/commands/env/add.ts` * `lib/commands/env/add.ts`
The README file is manually edited, but subsections are automatically extracted for inclusion in The README file is manually edited, but subsections are automatically extracted for inclusion in
`docs/balena-cli.md` by the `getCapitanoDoc()` function in `doc/cli.markdown` by the `getCapitanoDoc()` function in
[`automation/capitanodoc/capitanodoc.ts`](https://github.com/balena-io/balena-cli/blob/master/automation/capitanodoc/capitanodoc.ts). [`automation/capitanodoc/capitanodoc.ts`](https://github.com/balena-io/balena-cli/blob/master/automation/capitanodoc/capitanodoc.ts).
**IMPORTANT**
The file [`capitanodoc.ts`](https://github.com/balena-io/balena-cli/blob/master/automation/capitanodoc/capitanodoc.ts) lists
commands to generate documentation from. At the moment, it's manually updated and maintained alphabetically.
To add a new command to be documented,
1. Find the resource which it is part of or create a new one.
2. List the location of the build file
3. Make sure to add your files in alphabetical order
Once added, run the command `npm run build` to generate the documentation
The `INSTALL*.md` and `TROUBLESHOOTING.md` files are also manually edited. The `INSTALL*.md` and `TROUBLESHOOTING.md` files are also manually edited.
## Patches folder
The `patches` folder contains patch files created with the
[patch-package](https://www.npmjs.com/package/patch-package) tool. Small code changes to
third-party modules can be made by directly editing Javascript files under the `node_modules`
folder and then running `patch-package` to create the patch files. The patch files are then
applied immediately after `npm install`, through the `postinstall` script defined in
`package.json`.
The subfolders of the `patches` folder are documented in the
[apply-patches.js](https://github.com/balena-io/balena-cli/blob/master/patches/apply-patches.js)
script.
To make changes to the patch files under the `patches` folder, **do not edit them directly,**
not even for a "single character change" because the hash values in the patch files also need
to be recomputed by `patch-packages`. Instead, edit the relevant files under `node_modules`
directly, and then run `patch-packages` with the `--patch-dir` option to specify the subfolder
where the patch should be saved. For example, edit `node_modules/exit-hook/index.js` and then
run:
```sh
$ npx patch-package --patch-dir patches/all exit-hook
```
That said, these kinds of patches should be avoided in favour of creating pull requests
upstream. Patch files create additional maintenance work over time as the patches need to be
updated when the dependencies are updated, and they prevent the compounding community benefit
that sharing fixes upstream have on open source projects like the balena CLI. The typical
scenario where these patches are used is when the upstream maintainers are unresponsive or
unwilling to merge the required fixes, the fixes are very small and specific to the balena CLI,
and creating a fork of the upstream repo is likely to be more long-term effort than maintaining
the patches.
## Windows ## Windows
Besides the regular npm installation dependencies, the `npm run build:installer` script The `npm run build:installer` script (which generates the `.exe` executable installer on Windows)
that produces the `.exe` graphical installer on Windows also requires specifically requires [MSYS2](https://www.msys2.org/) to be installed. Other than that, the
[NSIS](https://sourceforge.net/projects/nsis/) and [MSYS2](https://www.msys2.org/) to be standard Command Prompt or PowerShell can be used (though MSYS2 is still handy, as it provides
installed. Be sure to add `C:\Program Files (x86)\NSIS` to the PATH, so that `makensis` 'git' and a number of common unix utilities). If changes are made to npm scripts in `package.json`,
is available. MSYS2 is recommended when developing the balena CLI on Windows. check that they also run on a standard Windows Command Prompt.
If changes are made to npm scripts in `package.json`, don't assume that a Unix shell like
bash is available. For example, some Windows shells don't have the `cp` and `rm` commands,
which is why you'll often find `ncp` and `rimraf` used in `package.json` scripts.
## Updating the 'npm-shrinkwrap.json' file ## Updating the 'npm-shrinkwrap.json' file
@ -214,24 +164,6 @@ Optionally, these steps may be automated by installing the
npx npm-merge-driver install -g npx npm-merge-driver install -g
``` ```
## `fast-boot` and `npm link` - modifying the `node_modules` folder
During development or debugging, it is sometimes useful to temporarily modify the `node_modules`
folder (with or without making the respective changes to the `npm-shrinkwrap.json` file),
replacing dependencies with different versions. This can be achieved with the `npm link`
command, or by manually editing or copying files to the `node_modules` folder.
Unexpected behavior may then be observed because of the CLI's use of the
[fast-boot2](https://www.npmjs.com/package/fast-boot2) package that caches module resolution.
`fast-boot2` is configured in `src/fast-boot.ts` to automatically invalidate the cache if
changes are made to the `package.json` or `npm-shrinkwrap.json` files, but the cache won't
be automatically invalidated if `npm link` is used or if manual modifications are made to the
`node_modules` folder. In this situation:
* Manually delete the module cache file (typically `~/.balena/cli-module-cache.json`), or
* Use the `bin/balena-dev` entry point (instead of `bin/balena`) as it does not activate
`fast-boot2`.
## TypeScript and oclif ## TypeScript and oclif
The CLI currently contains a mix of plain JavaScript and The CLI currently contains a mix of plain JavaScript and
@ -305,11 +237,3 @@ gotchas to bear in mind:
`node_modules/balena-sdk/node_modules/balena-errors` `node_modules/balena-sdk/node_modules/balena-errors`
In the case of subclasses of `TypedError`, a string comparison may be used instead: In the case of subclasses of `TypedError`, a string comparison may be used instead:
`error.name === 'BalenaApplicationNotFound'` `error.name === 'BalenaApplicationNotFound'`
## Further debugging notes
* If you need to selectively run specific tests, `it.only` will not work in cases when authorization is required as part of the test cycle. In order to target specific tests, control execution via `.mocharc.js` instead. Here is an example of targeting the `deploy` tests.
replace: `spec: 'tests/**/*.spec.ts',`
with: `spec: ['tests/auth/*.spec.ts', 'tests/**/deploy.spec.ts'],`

View File

@ -40,7 +40,7 @@ By default, the CLI is installed to the following folders:
OS | Folders OS | Folders
--- | --- --- | ---
Windows: | `C:\Program Files\balena-cli\` Windows: | `C:\Program Files\balena-cli\`
macOS: | `/usr/local/src/balena-cli/` <br> `/usr/local/bin/balena` macOS: | `/usr/local/lib/balena-cli/` <br> `/usr/local/bin/balena`
## Standalone Zip Package ## Standalone Zip Package
@ -60,7 +60,7 @@ macOS: | `/usr/local/src/balena-cli/` <br> `/usr/local/bin/balena`
[Windows](https://www.computerhope.com/issues/ch000549.htm) [Windows](https://www.computerhope.com/issues/ch000549.htm)
> * If you are using macOS 10.15 or later (Catalina, Big Sur), [check this known issue and > * 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). > workaround](https://github.com/balena-io/balena-cli/issues/1479).
> * **Linux Alpine** and **Busybox:** the standalone zip 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. > these "compact" Linux distributions, because of the alternative C libraries they ship with.
> For these, consider the [NPM Installation](#npm-installation) option. > For these, consider the [NPM Installation](#npm-installation) option.
@ -76,76 +76,58 @@ as described above.
If you are a Node.js developer, you may wish to install the balena CLI via [npm](https://www.npmjs.com). If you are a Node.js developer, you may wish to install the balena CLI via [npm](https://www.npmjs.com).
The npm installation involves building native (platform-specific) binary modules, which require The npm installation involves building native (platform-specific) binary modules, which require
some development tools to be installed first, as follows. some additional development tools to be installed first:
> **The balena CLI currently requires Node.js version ^20.6.0** * [Node.js](https://nodejs.org/) version 10 (min **10.20.0**) or 12 (version 14 is not yet fully supported)
> **Versions 21 and later are not yet fully supported.** * **Linux, macOS** and **Windows Subsystem for Linux (WSL):**
Installing Node via [nvm](https://github.com/nvm-sh/nvm/blob/master/README.md) is recommended.
When the "system" or "default" Node.js and npm packages are installed with "apt-get" in Linux
distributions like Ubuntu, users often report permission or compilation errors when running
"npm install". This [sample
Dockerfile](https://gist.github.com/pdcastro/5d4d96652181e7da685a32caf629dd44) shows the CLI
installation steps on an Ubuntu 18.04 base image.
* [Python 2.7](https://www.python.org/), [git](https://git-scm.com/), [make](https://www.gnu.org/software/make/), [g++](https://gcc.gnu.org/)
* **Linux** and **Windows Subsystem for Linux (WSL):**
`sudo apt-get install -y python git make g++`
* **macOS:** install Apple's Command Line Tools by running on a Terminal window:
`xcode-select --install`
### Install development tools On **Windows (not WSL),** the dependencies above and additional ones can be met by installing:
#### **Linux or WSL** (Windows Subsystem for Linux) * Node.js from the [Nodejs.org download page](https://nodejs.org/en/download/).
* The [MSYS2 shell](https://www.msys2.org/), which provides `git`, `make`, `g++`, `ssh`, `rsync`
```sh and more:
$ sudo apt-get update && sudo apt-get -y install curl python3 git make g++ * `pacman -S git openssh rsync gcc make`
$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash
$ . ~/.bashrc
$ nvm install 20
```
The `curl` command line above uses
[nvm](https://github.com/nvm-sh/nvm/blob/master/README.md#install--update-script) to install
Node.js, instead of using `apt-get`. Installing Node.js through `apt-get` is a common source of
problems from permission errors to conflict with other system packages, and therefore not
recommended.
#### **macOS**
* Download and install Apple's Command Line Tools from https://developer.apple.com/downloads/
* Install Node.js through [nvm](https://github.com/nvm-sh/nvm/blob/master/README.md#install--update-script):
```sh
$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash
$ . ~/.bashrc
$ nvm install 20
```
#### **Windows** (not WSL)
Install:
* If you'd like the ability to switch between Node.js versions, install
- 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:
* `pacman -S git gcc make openssh p7zip`
* [Set a Windows environment variable](https://www.onmsft.com/how-to/how-to-set-an-environment-variable-in-windows-10): `MSYS2_PATH_TYPE=inherit` * [Set a Windows environment variable](https://www.onmsft.com/how-to/how-to-set-an-environment-variable-in-windows-10): `MSYS2_PATH_TYPE=inherit`
* Note that a bug in the MSYS2 launch script (`msys2_shell.cmd`) makes text-based * Note that a bug in the MSYS2 launch script (`msys2_shell.cmd`) makes text-based
interactive CLI menus to misbehave. [Check this Github issue for a interactive CLI menus to misbehave. [Check this Github issue for a
workaround](https://github.com/msys2/MINGW-packages/issues/1633#issuecomment-240583890). workaround](https://github.com/msys2/MINGW-packages/issues/1633#issuecomment-240583890).
* The Windows Driver Kit (WDK), which is needed to compile some native Node modules. It is **not** * The Windows Driver Kit (WDK), which is needed to compile some native Node modules. It is **not**
necessary to install Visual Studio, only the WDK, which is "step 2" in the following guides: necessary to install Visual Studio, only the WDK, which is "step 2" in the following guides:
* [WDK for Windows 10](https://docs.microsoft.com/en-us/windows-hardware/drivers/download-the-wdk#download-icon-step-2-install-refreshed-wdk-for-windows-10-version-2004) * [WDK for Windows 10](https://docs.microsoft.com/en-us/windows-hardware/drivers/download-the-wdk#download-icon-step-2-install-wdk-for-windows-10-version-1903)
* [WDK for earlier versions of Windows](https://docs.microsoft.com/en-us/windows-hardware/drivers/other-wdk-downloads#step-2-install-the-wdk) * [WDK for earlier versions of Windows](https://docs.microsoft.com/en-us/windows-hardware/drivers/other-wdk-downloads#step-2-install-the-wdk)
* The [windows-build-tools](https://www.npmjs.com/package/windows-build-tools) npm package, * The [windows-build-tools](https://www.npmjs.com/package/windows-build-tools) npm package (which
by running the following command on an [administrator provides Python 2.7 and more), by running the following command on an [administrator
console](https://www.howtogeek.com/194041/how-to-open-the-command-prompt-as-administrator-in-windows-8.1/): console](https://www.howtogeek.com/194041/how-to-open-the-command-prompt-as-administrator-in-windows-8.1/):
`npm install --global --production windows-build-tools`
`npm install -g --production windows-build-tools`
### Install the balena CLI With these dependencies in place, the balena CLI installation command is:
After installing the development tools, install the balena CLI with:
```sh ```sh
$ npm install balena-cli --global --production --unsafe-perm $ npm install balena-cli -g --production --unsafe-perm
``` ```
`--unsafe-perm` is needed when `npm install` is executed as the `root` user (e.g. in a Docker `--unsafe-perm` is required when `npm install` is executed as the root user, or on systems where
container) in order to allow npm scripts like `postinstall` to be executed. the global install directory is not user-writable. It allows npm install steps to download and save
prebuilt native binaries, and also allows the execution of npm scripts like `postinstall` that are
used to patch dependencies. It is usually possible to omit `--unsafe-perm` if installing under a
regular (non-root) user account, especially if using a user-managed node installation such as
[nvm](https://github.com/creationix/nvm).
## Additional Dependencies ## Additional Dependencies
The `balena device ssh`, `device detect`, `build`, `deploy` and `preload` commands may require The `balena ssh`, `scan`, `build`, `deploy`, `preload` and `os configure` commands may require
additional software to be installed. Check the Additional Dependencies sections for each operating additional software to be installed. Check the Additional Dependencies sections for each operating
system: system:
@ -153,9 +135,9 @@ system:
* [macOS](./INSTALL-MAC.md#additional-dependencies) * [macOS](./INSTALL-MAC.md#additional-dependencies)
* [Linux](./INSTALL-LINUX.md#additional-dependencies) * [Linux](./INSTALL-LINUX.md#additional-dependencies)
Where Docker or balenaEngine are required, they may be installed on the local machine (where the The `build` and `deploy` commands are also capable of using Docker or balenaEngine on a remote
balena CLI is executed), on a remote server, or on a balenaOS device running a [balenaOS development server, or on a balenaOS device running a [balenaOS development
image](https://www.balena.io/docs/reference/OS/overview/2.x/#dev-vs-prod-images). Reasons why this image](https://www.balena.io/docs/reference/OS/overview/2.x/#dev-vs-prod-images)). Reasons why this
may be desirable include: may be desirable include:
* To avoid having to install Docker on the development machine / laptop. * To avoid having to install Docker on the development machine / laptop.
@ -163,7 +145,6 @@ may be desirable include:
* To build or run images "natively" on an ARM device, avoiding the need for QEMU emulation. * To build or run images "natively" on an ARM device, avoiding the need for QEMU emulation.
To use a remote Docker Engine (daemon) or balenaEngine, specify the remote machine's IP address and To use a remote Docker Engine (daemon) or balenaEngine, specify the remote machine's IP address and
port number with the `--dockerHost` and `--dockerPort` command-line options. The `preload` command port number with the `--dockerHost` and `--dockerPort` command-line options. For more details,
has additional requirements because the bind mount feature is used. For more details, see check `balena help build` or the [online
`balena help` for each command or the [online
reference](https://www.balena.io/docs/reference/cli/#cli-command-reference). reference](https://www.balena.io/docs/reference/cli/#cli-command-reference).

View File

@ -1,10 +1,8 @@
# balena CLI Installation Instructions for Linux # balena CLI Installation Instructions for Linux
These instructions are suitable for most Linux distributions on Intel x86, such as These instructions are for the recommended installation option. They are suitable for most Linux
Ubuntu, Debian, Fedora, Arch Linux and other glibc-based distributions. distributions, except notably for **Linux Alpine** or **Busybox**. For these distros, see [advanced
For the ARM architecture and for Linux distributions not based on glibc, such as installation options](./INSTALL-ADVANCED.md).
Alpine Linux, follow the [NPM Installation](./INSTALL-ADVANCED.md#npm-installation)
method.
Selected operating system: **Linux** Selected operating system: **Linux**
@ -13,67 +11,50 @@ Selected operating system: **Linux**
with "-standalone.zip", for example: with "-standalone.zip", for example:
`balena-cli-vX.Y.Z-linux-x64-standalone.zip` `balena-cli-vX.Y.Z-linux-x64-standalone.zip`
2. Extract the zip file contents to any folder you choose, for example `/home/james`. 2. Extract the zip file contents to any folder you choose. The extracted contents will include a
The extracted contents will include a `balena-cli` folder. `balena-cli` folder.
3. Add that folder (e.g. `/home/james/balena-cli`) to the `PATH` environment variable. 3. Add the `balena-cli` folder to the system's `PATH` environment variable. There are several
Check this [StackOverflow ways of achieving this on Linux: See this [StackOverflow post](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix). Close and reopen the terminal window
post](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix) so that the changes to PATH can take effect.
for instructions. Close and reopen the terminal window so that the changes to `PATH`
can take effect.
4. Check that the installation was successful by running the following commands on a 4. Check that the installation was successful by running the following commands on a
terminal window: command terminal:
* `balena version` - should print the CLI's version * `balena version` - should print the CLI's version
* `balena help` - should print a list of available commands * `balena help` - should print a list of available commands
No further steps are required to run most CLI commands. The `balena ssh`, `scan`, `build`,
`deploy` and `preload` commands may require additional software to be installed, as described
below.
To update the balena CLI to a new version, download a new release zip file and replace the previous To update the balena 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 installation folder. To uninstall, simply delete the folder and edit the PATH environment variable
as described above. as described above.
## sudo configuration
A few CLI commands require execution through sudo, e.g. `sudo balena device detect`.
If your Linux distribution has an `/etc/sudoers` file that defines a `secure_path`
setting, run `sudo visudo` to edit it and add the balena CLI's installation folder to
the ***pre-existing*** `secure_path` setting, for example:
```text
Defaults secure_path="/home/james/balena-cli:<pre-existing entries go here>"
```
If an `/etc/sudoers` file does not exist, or if it does not contain a pre-existing
`secure_path` setting, do not change it.
If you also have Docker installed, ensure that it can be executed ***without*** `sudo`, so that
CLI commands like `balena build` and `balena preload` can also be executed without `sudo`.
Check Docker's [post-installation
steps](https://docs.docker.com/engine/install/linux-postinstall/) on how to achieve this.
## Additional Dependencies ## Additional Dependencies
### build, deploy ### build, deploy
These commands require [Docker](https://docs.docker.com/install/overview/) or These commands require [Docker](https://docs.docker.com/install/overview/) or
[balenaEngine](https://www.balena.io/engine/) to be available on a local or remote [balenaEngine](https://www.balena.io/engine/) to be available (on a local or remote machine). Most
machine. Most users will follow [Docker's installation users will simply follow [Docker's installation
instructions](https://docs.docker.com/install/overview/) to install Docker on the same instructions](https://docs.docker.com/install/overview/) to install Docker on the same laptop (dev
workstation as the balena CLI. The [advanced installation machine) where the balena CLI is installed. The [advanced installation
options](./INSTALL-ADVANCED.md#additional-dependencies) document describes other possibilities. options](./INSTALL-ADVANCED.md) document describes other possibilities.
### balena device ssh ### balena ssh
The `balena device ssh` command requires the `ssh` command-line tool to be available. Most Linux The `balena ssh` command requires the `ssh` command-line tool to be available. Most Linux
distributions will already have it installed. Otherwise, `sudo apt-get install openssh-client` distributions will already have it installed. Otherwise, `sudo apt-get install openssh-client`
should do the trick on Debian or Ubuntu. should do the trick on Debian or Ubuntu.
The `balena device ssh` command also requires an SSH key to be added to your balena account: see [SSH The `balena ssh` command also requires an SSH key to be added to your balena account: see [SSH
Access documentation](https://www.balena.io/docs/learn/manage/ssh-access/). The `balena key*` Access documentation](https://www.balena.io/docs/learn/manage/ssh-access/). The `balena key*`
command set can also be used to list and manage SSH keys: see `balena help -v`. command set can also be used to list and manage SSH keys: see `balena help -v`.
### balena device detect ### balena scan
The `balena device detect` command requires a multicast DNS (mDNS) service like The `balena scan` command requires a multicast DNS (mDNS) service like
[Avahi](https://en.wikipedia.org/wiki/Avahi_(software)), which is installed by default on most [Avahi](https://en.wikipedia.org/wiki/Avahi_(software)), which is installed by default on most
desktop Linux distributions. Otherwise, on Debian or Ubuntu, the installation command would be desktop Linux distributions. Otherwise, on Debian or Ubuntu, the installation command would be
`sudo apt-get install avahi-daemon`. `sudo apt-get install avahi-daemon`.

View File

@ -10,40 +10,34 @@ Selected operating system: **macOS**
Look for a file name that ends with "-installer.pkg": Look for a file name that ends with "-installer.pkg":
`balena-cli-vX.Y.Z-macOS-x64-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 2. Double click the downloaded file to run the installer. After the installation completes,
instructions. close and re-open any open [command
terminal](https://www.balena.io/docs/reference/cli/#choosing-a-shell-command-promptterminal)
windows (so that the changes made by the installer to the PATH environment variable can take
effect).
3. Check that the installation was successful: 3. Check that the installation was successful by running the following commands on a
- [Open the Terminal command terminal:
app](https://support.apple.com/en-gb/guide/terminal/apd5265185d-f365-44cb-8b09-71a064a42125/mac). * `balena version` - should print the CLI's version
- On the terminal prompt, type `balena version` and hit Enter. It should display * `balena help` - should print a list of available commands
the version of the balena CLI that you have installed.
No further steps are required to run most CLI commands. The `balena device ssh`, `build`, `deploy` No further steps are required to run most CLI commands. The `balena ssh`, `build`, `deploy`
and `preload` commands may require additional software to be installed, as described and `preload` commands may require additional software to be installed, as described below.
in the next section.
To update the balena CLI, repeat the steps above for the new version.
To uninstall it, run the following command on a terminal prompt:
```text
sudo /usr/local/src/balena-cli/bin/uninstall
```
## Additional Dependencies ## Additional Dependencies
### build and deploy ### build and deploy
These commands require [Docker](https://docs.docker.com/install/overview/) or These commands require [Docker](https://docs.docker.com/install/overview/) or
[balenaEngine](https://www.balena.io/engine/) to be available on a local or remote [balenaEngine](https://www.balena.io/engine/) to be available (on a local or remote machine). Most
machine. Most users will follow [Docker's installation users will simply follow [Docker's installation
instructions](https://docs.docker.com/install/overview/) to install Docker on the same instructions](https://docs.docker.com/install/overview/) to install Docker on the same laptop (dev
workstation as the balena CLI. The [advanced installation machine) where the balena CLI is installed. The [advanced installation
options](./INSTALL-ADVANCED.md#additional-dependencies) document describes other possibilities. options](./INSTALL-ADVANCED.md) document describes other possibilities.
### balena device ssh ### balena ssh
The `balena device ssh` command requires the `ssh` command-line tool to be available. To check whether The `balena ssh` command requires the `ssh` command-line tool to be available. To check whether
it is already installed, run `ssh` on a Terminal window. If it is not yet installed, the options it is already installed, run `ssh` on a Terminal window. If it is not yet installed, the options
include: include:
@ -52,23 +46,23 @@ include:
Components → Command Line Tools → Install. Components → Command Line Tools → Install.
* Or, install [Homebrew](https://brew.sh/), then `brew install openssh` * Or, install [Homebrew](https://brew.sh/), then `brew install openssh`
The `balena device ssh` command also requires an SSH key to be added to your balena account: see [SSH The `balena ssh` command also requires an SSH key to be added to your balena account: see [SSH
Access documentation](https://www.balena.io/docs/learn/manage/ssh-access/). The `balena key*` Access documentation](https://www.balena.io/docs/learn/manage/ssh-access/). The `balena key*`
command set can also be used to list and manage SSH keys: see `balena help -v`. command set can also be used to list and manage SSH keys: see `balena help -v`.
### balena preload ### balena preload
Like the `build` and `deploy` commands, the `preload` command requires Docker. Like the `build` and `deploy` commands, the `preload` command requires Docker, with the additional
Preloading balenaOS images for some older device types (like the Raspberry restriction that Docker must be installed on the local machine (because Docker's bind mounting
Pi 3, but not the Raspberry 4) requires Docker to support the [AUFS storage feature is used). Also, for some device types (such as the Raspberry Pi), the `preload` command
requires Docker to support the [AUFS storage
driver](https://docs.docker.com/storage/storagedriver/aufs-driver/). Unfortunately, Docker Desktop driver](https://docs.docker.com/storage/storagedriver/aufs-driver/). Unfortunately, Docker Desktop
for Windows and macOS dropped support for the AUFS filesystem in Docker CE versions greater than for Windows dropped support for the AUFS filesystem in Docker CE versions greater than 18.06.1. The
18.06.1. The present workarounds are to either: present workaround is to either:
* Install the balena CLI on Linux (e.g. Ubuntu) with a virtual machine like VirtualBox.
This works because Docker for Linux still supports AUFS. Hint: if using a virtual machine,
copy the image file over, rather than accessing it through "file sharing", to avoid errors.
* Downgrade Docker Desktop to version 18.06.1. Link: [Docker CE for * Downgrade Docker Desktop to version 18.06.1. Link: [Docker CE for
Mac](https://docs.docker.com/docker-for-mac/release-notes/#docker-community-edition-18061-ce-mac73-2018-08-29) Mac](https://docs.docker.com/docker-for-mac/release-notes/#docker-community-edition-18061-ce-mac73-2018-08-29)
* Install the balena CLI on a Linux machine (as Docker for Linux still supports AUFS). A Linux
Virtual Machine also works, but a Docker container is _not_ recommended.
We are working on replacing AUFS with overlay2 in balenaOS images of the affected device types. Long term, we are working on replacing AUFS with overlay2 for the affected device types.

View File

@ -10,17 +10,19 @@ Selected operating system: **Windows**
Look for a file name that ends with "-installer.exe": 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 2. Double click the downloaded file to run the installer. After the installation completes,
instructions. close and re-open any open [command
terminal](https://www.balena.io/docs/reference/cli/#choosing-a-shell-command-promptterminal)
windows (so that the changes made by the installer to the PATH environment variable can take
effect).
3. Check that the installation was successful: 3. Check that the installation was successful by running the following commands on a
- Click on the Windows Start Menu, type PowerShell, and then click command terminal:
on Windows PowerShell. * `balena version` - should print the CLI's version
- On the command prompt, type `balena version` and hit Enter. It should display * `balena help` - should print a list of available commands
the version of the balena CLI that you have installed.
No further steps are required to run most CLI commands. The `balena device ssh`, `device detect`, `build`, No further steps are required to run most CLI commands. The `balena ssh`, `scan`, `build`,
`deploy` and `preload` commands may require additional software to be installed, as `deploy`, `preload` and `os configure` commands may require additional software to be installed, as
described below. described below.
## Additional Dependencies ## Additional Dependencies
@ -28,15 +30,15 @@ described below.
### build and deploy ### build and deploy
These commands require [Docker](https://docs.docker.com/install/overview/) or These commands require [Docker](https://docs.docker.com/install/overview/) or
[balenaEngine](https://www.balena.io/engine/) to be available on a local or remote [balenaEngine](https://www.balena.io/engine/) to be available (on a local or remote machine). Most
machine. Most users will follow [Docker's installation users will simply follow [Docker's installation
instructions](https://docs.docker.com/install/overview/) to install Docker on the same instructions](https://docs.docker.com/install/overview/) to install Docker on the same laptop (dev
workstation as the balena CLI. The [advanced installation machine) where the balena CLI is installed. The [advanced installation
options](./INSTALL-ADVANCED.md#additional-dependencies) document describes other possibilities. options](./INSTALL-ADVANCED.md) document describes other possibilities.
### balena device ssh ### balena ssh
The `balena device ssh` command requires the `ssh` command-line tool to be available. Microsoft started The `balena ssh` command requires the `ssh` command-line tool to be available. Microsoft started
distributing an SSH client with Windows 10, which is automatically installed through Windows distributing an SSH client with Windows 10, which is automatically installed through Windows
Update. To check whether it is installed, run `ssh` on a Windows Command Prompt or PowerShell. It Update. To check whether it is installed, run `ssh` on a Windows Command Prompt or PowerShell. It
can also be [manually can also be [manually
@ -44,30 +46,37 @@ installed](https://docs.microsoft.com/en-us/windows-server/administration/openss
if needed. For older versions of Windows, there are several ssh/OpenSSH clients provided by 3rd if needed. For older versions of Windows, there are several ssh/OpenSSH clients provided by 3rd
parties. parties.
The `balena device ssh` command also requires an SSH key to be added to your balena account: see [SSH The `balena ssh` command also requires an SSH key to be added to your balena account: see [SSH
Access documentation](https://www.balena.io/docs/learn/manage/ssh-access/). The `balena key*` Access documentation](https://www.balena.io/docs/learn/manage/ssh-access/). The `balena key*`
command set can also be used to list and manage SSH keys: see `balena help -v`. command set can also be used to list and manage SSH keys: see `balena help -v`.
### balena device detect ### balena scan
The `balena device detect` command requires a multicast DNS (mDNS) service like Apple's Bonjour. The `balena scan` command requires a multicast DNS (mDNS) service like Apple's Bonjour.
Many Windows machines will already have this service installed, as it is bundled in popular Many Windows machines will already have this service installed, as it is bundled in popular
applications such as Skype (Wikipedia lists [several others](https://en.wikipedia.org/wiki/Bonjour_(software))). applications such as Skype (Wikipedia lists [several others](https://en.wikipedia.org/wiki/Bonjour_(software))).
Otherwise, Bonjour for Windows can be downloaded and installed from: https://support.apple.com/kb/DL999 Otherwise, Bonjour for Windows can be downloaded and installed from: https://support.apple.com/kb/DL999
### balena preload ### balena preload
Like the `build` and `deploy` commands, the `preload` command requires Docker. Like the `build` and `deploy` commands, the `preload` command requires Docker, with the additional
Preloading balenaOS images for some older device types (like the Raspberry restriction that Docker must be installed on the local machine (because Docker's bind mounting
Pi 3, but not the Raspberry 4) requires Docker to support the [AUFS storage feature is used). Also, for some device types (such as the Raspberry Pi), the `preload` command
requires Docker to support the [AUFS storage
driver](https://docs.docker.com/storage/storagedriver/aufs-driver/). Unfortunately, Docker Desktop driver](https://docs.docker.com/storage/storagedriver/aufs-driver/). Unfortunately, Docker Desktop
for Windows and macOS dropped support for the AUFS filesystem in Docker CE versions greater than for Windows dropped support for the AUFS filesystem in Docker CE versions greater than 18.06.1. The
18.06.1. The present workarounds are to either: present workaround is to either:
* Install the balena CLI on Linux (e.g. Ubuntu) with a virtual machine like VirtualBox.
This works because Docker for Linux still supports AUFS. Hint: if using a virtual machine,
copy the image file over, rather than accessing it through "file sharing", to avoid errors.
* Downgrade Docker Desktop to version 18.06.1. Link: [Docker CE for * Downgrade Docker Desktop to version 18.06.1. Link: [Docker CE for
Windows](https://docs.docker.com/docker-for-windows/release-notes/#docker-community-edition-18061-ce-win73-2018-08-29) Windows](https://docs.docker.com/docker-for-windows/release-notes/#docker-community-edition-18061-ce-win73-2018-08-29)
* Install the balena CLI on a Linux machine (as Docker for Linux still supports AUFS). A Linux
Virtual Machine also works, but a Docker container is _not_ recommended.
We are working on replacing AUFS with overlay2 in balenaOS images of the affected device types. Long term, we are working on replacing AUFS with overlay2 for the affected device types.
### balena os configure
* The `balena os configure` command is currently not supported on Windows natively, but works with
the [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about) (WSL). When
using WSL, [install the balena CLI for
Linux](https://github.com/balena-io/balena-cli/blob/master/INSTALL-LINUX.md).

View File

@ -28,20 +28,18 @@ are supported. Alternative shells include:
* [MSYS2](https://www.msys2.org/): * [MSYS2](https://www.msys2.org/):
* Install additional packages with the command: * Install additional packages with the command:
`pacman -S git gcc make openssh p7zip` `pacman -S git openssh rsync`
* [Set a Windows environment variable](https://www.onmsft.com/how-to/how-to-set-an-environment-variable-in-windows-10): `MSYS2_PATH_TYPE=inherit` * [Set a Windows environment variable](https://www.onmsft.com/how-to/how-to-set-an-environment-variable-in-windows-10): `MSYS2_PATH_TYPE=inherit`
* Note that a bug in the MSYS2 launch script (`msys2_shell.cmd`) makes text-based interactive CLI * Note that a bug in the MSYS2 launch script (`msys2_shell.cmd`) makes text-based interactive CLI
menus to break. [Check this Github issue for a menus to break. [Check this Github issue for a
workaround](https://github.com/msys2/MINGW-packages/issues/1633#issuecomment-240583890). workaround](https://github.com/msys2/MINGW-packages/issues/1633#issuecomment-240583890).
* [MSYS](http://www.mingw.org/wiki/MSYS): select the `msys-rsync` and `msys-openssh` packages too
* [MSYS](http://www.mingw.org/wiki/MSYS)
* [Git for Windows](https://git-for-windows.github.io/) * [Git for Windows](https://git-for-windows.github.io/)
* During the installation, you will be prompted to choose between _"Use MinTTY"_ and _"Use * During the installation, you will be prompted to choose between _"Use MinTTY"_ and _"Use
Windows' default console window"._ Choose the latter, because of the same [MSYS2 Windows' default console window"._ Choose the latter, because of the same [MSYS2
bug](https://github.com/msys2/MINGW-packages/issues/1633) mentioned above (Git for Windows bug](https://github.com/msys2/MINGW-packages/issues/1633) mentioned above (Git for Windows
actually uses MSYS2). For a screenshot, check this actually uses MSYS2). For a screenshot, check this
[comment](https://github.com/balena-io/balena-cli/issues/598#issuecomment-556513098). [comment](https://github.com/balena-io/balena-cli/issues/598#issuecomment-556513098).
* Microsoft's [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about) * Microsoft's [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about)
(WSL). In this case, a Linux distribution like Ubuntu is installed via the Microsoft Store, and a (WSL). In this case, a Linux distribution like Ubuntu is installed via the Microsoft Store, and a
balena CLI release **for Linux** should be selected. See balena CLI release **for Linux** should be selected. See
@ -50,14 +48,14 @@ are supported. Alternative shells include:
On **macOS** and **Linux,** the standard terminal window is supported. Optionally, `bash` command On **macOS** and **Linux,** the standard terminal window is supported. Optionally, `bash` command
auto completion may be enabled by copying the auto completion may be enabled by copying the
[balena_comp](https://github.com/balena-io/balena-cli/blob/master/completion/balena-completion.bash) [balena-completion.bash](https://github.com/balena-io/balena-cli/blob/master/balena-completion.bash)
file to your system's `bash_completion` directory: check [Docker's command completion file to your system's `bash_completion` directory: check [Docker's command completion
guide](https://docs.docker.com/compose/completion/) for system setup instructions. guide](https://docs.docker.com/compose/completion/) for system setup instructions.
## Logging in ## Logging in
Several CLI commands require access to your balenaCloud account, for example in order to push a Several CLI commands require access to your balenaCloud account, for example in order to push a
new release to your fleet. Those commands require creating a CLI login session by running: new release to your application. Those commands require creating a CLI login session by running:
```sh ```sh
$ balena login $ balena login
@ -77,6 +75,7 @@ HTTP(S) proxies can be configured through any of the following methods, in prece
file](https://www.npmjs.com/package/balena-settings-client#documentation). It may be: file](https://www.npmjs.com/package/balena-settings-client#documentation). It may be:
* A string in URL format, e.g. `proxy: 'http://localhost:8000'` * A string in URL format, e.g. `proxy: 'http://localhost:8000'`
* An object in the format: * An object in the format:
```yaml ```yaml
proxy: proxy:
protocol: 'http' protocol: 'http'
@ -88,9 +87,9 @@ HTTP(S) proxies can be configured through any of the following methods, in prece
* The `HTTPS_PROXY` and/or `HTTP_PROXY` environment variables, in the same URL format as * The `HTTPS_PROXY` and/or `HTTP_PROXY` environment variables, in the same URL format as
`BALENARC_PROXY`. `BALENARC_PROXY`.
### Proxy setup for balena device ssh ### Proxy setup for balena ssh
In order to work behind a proxy server, the `balena device ssh` command requires the In order to work behind a proxy server, the `balena ssh` command requires the
[`proxytunnel`](http://proxytunnel.sourceforge.net/) package (command-line tool) to be installed. [`proxytunnel`](http://proxytunnel.sourceforge.net/) package (command-line tool) to be installed.
`proxytunnel` is available for Linux distributions like Ubuntu/Debian (`apt install proxytunnel`), `proxytunnel` is available for Linux distributions like Ubuntu/Debian (`apt install proxytunnel`),
and for macOS through [Homebrew](https://brew.sh/). Windows support is limited to the [Windows and for macOS through [Homebrew](https://brew.sh/). Windows support is limited to the [Windows
@ -110,7 +109,7 @@ The `BALENARC_NO_PROXY` variable may be used to exclude specified destinations f
> * This feature requires CLI version 11.30.8 or later. In the case of the npm [installation > * This feature requires CLI version 11.30.8 or later. In the case of the npm [installation
> option](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md), it also requires > option](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md), it also requires
> Node.js version 10.16.0 or later. > Node.js version 10.16.0 or later.
> * To exclude a `balena device ssh` target from proxying (IP address or `.local` hostname), the > * To exclude a `balena ssh` target from proxying (IP address or `.local` hostname), the
> `--noproxy` option should be specified in addition to the `BALENARC_NO_PROXY` variable. > `--noproxy` option should be specified in addition to the `BALENARC_NO_PROXY` variable.
By default (if `BALENARC_NO_PROXY` is not defined), all [private IPv4 By default (if `BALENARC_NO_PROXY` is not defined), all [private IPv4
@ -144,7 +143,7 @@ To learn more, troubleshoot issues, or to contact us for support:
* Check the [masterclass tutorials](https://www.balena.io/docs/learn/more/masterclasses/overview/) * Check the [masterclass tutorials](https://www.balena.io/docs/learn/more/masterclasses/overview/)
* Check our [FAQ / troubleshooting document](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md) * Check our [FAQ / troubleshooting document](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md)
* Ask us a question in the [balena forums](https://forums.balena.io/c/product-support) * Ask us a question through the [balenaCloud forum](https://forums.balena.io/c/balena-cloud)
For CLI bug reports or feature requests, check the For CLI bug reports or feature requests, check the
[CLI GitHub issues](https://github.com/balena-io/balena-cli/issues/). [CLI GitHub issues](https://github.com/balena-io/balena-cli/issues/).
@ -156,18 +155,14 @@ of major, minor and patch version releases.
The latest release of a major version of the balena CLI will remain compatible with The latest release of a major version of the balena CLI will remain compatible with
the balenaCloud backend services for at least one year from the date when the the balenaCloud backend services for at least one year from the date when the
following major version is released. For example, balena CLI v11.36.0, as the following major version is released. For example, balena CLI v10.17.5, as the
latest v11 release, would remain compatible with the balenaCloud backend for one latest v10 release, would remain compatible with the balenaCloud backend for one
year from the date when v12.0.0 was released. year from the date when v11.0.0 is released.
Half way through to that period (6 months after the release of the next major At the end of this period, the older major version is considered deprecated and
version), older major versions of the balena CLI will start printing a deprecation some of the functionality that depends on balenaCloud services may stop working
warning message when it is used interactively (when `stderr` is attached to a TTY at any time.
device file). At the end of that period, older major versions will exit with an Users are encouraged to regularly update the balena CLI to the latest version.
error message unless the `--unsupported` flag is used. This behavior was
introduced in CLI version 12.47.0 and is also documented by `balena help`.
To take advantage of the latest backend features and ensure compatibility, users
are encouraged to regularly update the balena CLI to the latest version.
## Contributing (including editing documentation files) ## Contributing (including editing documentation files)

View File

@ -31,11 +31,6 @@ command again.
Check whether the SD card is locked (a physical switch on the side of the card). Check whether the SD card is locked (a physical switch on the side of the card).
## I get `connect ETIMEDOUT` with `balena device tunnel`
Please update the CLI to the latest version. This issue was fixed in v12.38.5.
For more details, see: https://github.com/balena-io/balena-cli/issues/2172
## I get EINVAL errors on Cygwin ## I get EINVAL errors on Cygwin
The errors may look something like this: The errors may look something like this:
@ -79,10 +74,10 @@ Try resetting the ownership by running:
$ sudo chown -R <user> $HOME/.balena $ sudo chown -R <user> $HOME/.balena
``` ```
## Broken line wrapping / cursor behavior with `balena device ssh` ## Broken line wrapping / cursor behavior with `balena ssh`
Users sometimes come across broken line wrapping or cursor behavior in text terminals, for example Users sometimes come across broken line wrapping or cursor behavior in text terminals, for example
when long command lines are typed in a `balena device ssh` session, or when using text editors like `vim` when long command lines are typed in a `balena ssh` session, or when using text editors like `vim`
or `nano`. This is not something specific to the balena CLI, being also a commonly reported issue or `nano`. This is not something specific to the balena CLI, being also a commonly reported issue
with standard remote terminal tools like `ssh` or `telnet`. It is often a remote shell with standard remote terminal tools like `ssh` or `telnet`. It is often a remote shell
configuration issue (files like `/etc/profile`, `~/.bash_profile`, `~/.bash_login`, `~/.profile` configuration issue (files like `/etc/profile`, `~/.bash_profile`, `~/.bash_login`, `~/.profile`

43
appveyor.yml Normal file
View File

@ -0,0 +1,43 @@
# appveyor file
# http://www.appveyor.com/docs/appveyor-yml
image: Visual Studio 2017
init:
- git config --global core.autocrlf input
cache:
- C:\Users\appveyor\.node-gyp
- '%AppData%\npm-cache'
matrix:
fast_finish: true
# what combinations to test
environment:
matrix:
- nodejs_version: 10
install:
- ps: Install-Product node $env:nodejs_version x64
- set PATH=%APPDATA%\npm;%PATH%
- npm config set python 'C:\Python27\python.exe'
- npm --version
# - npm install
build: off
test: off
deploy: off
test_script:
- node --version
- npm --version
# - npm test
deploy_script:
- node --version
- npm --version
# - npm run build:standalone
# - npm run build:installer
# - IF "%APPVEYOR_REPO_TAG%" == "true" (npm run release)
# - IF NOT "%APPVEYOR_REPO_TAG%" == "true" (echo 'Not tagged, skipping deploy')

View File

@ -15,34 +15,31 @@
* limitations under the License. * limitations under the License.
*/ */
import type { JsonVersions } from '../src/commands/version/index'; import type { JsonVersions } from '../lib/commands/version';
import { run as oclifRun } from '@oclif/core'; import { run as oclifRun } from '@oclif/dev-cli';
import * as archiver from 'archiver'; import * as archiver from 'archiver';
import { exec, execFile } from 'child_process'; import * as Bluebird from 'bluebird';
import { execFile } from 'child_process';
import * as filehound from 'filehound'; import * as filehound from 'filehound';
import type { Stats } from 'fs';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import * as klaw from 'klaw'; import * as _ from 'lodash';
import * as path from 'path'; import * as path from 'path';
import * as rimraf from 'rimraf'; import * as rimraf from 'rimraf';
import * as semver from 'semver'; import * as semver from 'semver';
import { promisify } from 'util'; import * as util from 'util';
import { notarize } from '@electron/notarize';
import { stripIndent } from '../build/utils/lazy'; import { stripIndent } from '../lib/utils/lazy';
import { import {
diffLines, diffLines,
getSubprocessStdout,
loadPackageJson, loadPackageJson,
MSYS2_BASH,
ROOT, ROOT,
StdOutTap, StdOutTap,
whichSpawn, whichSpawn,
} from './utils'; } from './utils';
const execFileAsync = promisify(execFile);
const execAsync = promisify(exec);
const rimrafAsync = promisify(rimraf);
export const packageJSON = loadPackageJson(); export const packageJSON = loadPackageJson();
export const version = 'v' + packageJSON.version; export const version = 'v' + packageJSON.version;
const arch = process.arch; const arch = process.arch;
@ -61,13 +58,9 @@ const standaloneZips: PathByPlatform = {
win32: dPath(`balena-cli-${version}-windows-${arch}-standalone.zip`), win32: dPath(`balena-cli-${version}-windows-${arch}-standalone.zip`),
}; };
const getOclifInstallersOriginalNames = async (): Promise<PathByPlatform> => { const oclifInstallers: PathByPlatform = {
const { stdout } = await execAsync('git rev-parse --short HEAD'); darwin: dPath('macos', `balena-${version}.pkg`),
const sha = stdout.trim(); win32: dPath('win', `balena-${version}-${arch}.exe`),
return {
darwin: dPath('macos', `balena-${version}-${sha}-${arch}.pkg`),
win32: dPath('win32', `balena-${version}-${sha}-${arch}.exe`),
};
}; };
const renamedOclifInstallers: PathByPlatform = { const renamedOclifInstallers: PathByPlatform = {
@ -92,14 +85,13 @@ async function diffPkgOutput(pkgOut: string) {
'tests', 'tests',
'test-data', 'test-data',
'pkg', 'pkg',
`expected-warnings-${process.platform}-${arch}.txt`, `expected-warnings-${process.platform}.txt`,
); );
const absSavedPath = path.join(ROOT, relSavedPath); const absSavedPath = path.join(ROOT, relSavedPath);
const ignoreStartsWith = [ const ignoreStartsWith = [
'> pkg@', '> pkg@',
'> Fetching base Node.js binaries', '> Fetching base Node.js binaries',
' fetched-', ' fetched-',
'prebuild-install WARN install No prebuilt binaries found',
]; ];
const modulesRE = const modulesRE =
process.platform === 'win32' process.platform === 'win32'
@ -160,7 +152,7 @@ ${sep}
* messages (stdout and stderr) in order to call diffPkgOutput(). * messages (stdout and stderr) in order to call diffPkgOutput().
*/ */
async function execPkg(...args: any[]) { async function execPkg(...args: any[]) {
const { exec: pkgExec } = await import('@yao-pkg/pkg'); const { exec: pkgExec } = await import('pkg');
const outTap = new StdOutTap(true); const outTap = new StdOutTap(true);
try { try {
outTap.tap(); outTap.tap();
@ -185,18 +177,9 @@ async function execPkg(...args: any[]) {
* to be directly executed from inside another binary executable.) * to be directly executed from inside another binary executable.)
*/ */
async function buildPkg() { async function buildPkg() {
// https://github.com/vercel/pkg#targets
let targets = `linux-${arch}`;
if (process.platform === 'darwin') {
targets = `macos-${arch}`;
}
// TBC: not yet possible to build for Windows arm64 on x64 nodes
if (process.platform === 'win32') {
targets = `win-x64`;
}
const args = [ const args = [
'--targets', '--target',
targets, 'host',
'--output', '--output',
'build-bin/balena', 'build-bin/balena',
'package.json', 'package.json',
@ -211,6 +194,7 @@ async function buildPkg() {
const paths: Array<[string, string[], string[]]> = [ const paths: Array<[string, string[], string[]]> = [
// [platform, [source path], [destination path]] // [platform, [source path], [destination path]]
['*', ['open', 'xdg-open'], ['xdg-open']], ['*', ['open', 'xdg-open'], ['xdg-open']],
['*', ['opn', 'xdg-open'], ['xdg-open-402']],
['darwin', ['denymount', 'bin', 'denymount'], ['denymount']], ['darwin', ['denymount', 'bin', 'denymount'], ['denymount']],
]; ];
await Promise.all( await Promise.all(
@ -259,17 +243,7 @@ async function testPkg() {
console.log(`Testing standalone package "${pkgBalenaPath}"...`); console.log(`Testing standalone package "${pkgBalenaPath}"...`);
// Run `balena version -j`, parse its stdout as JSON, and check that the // Run `balena version -j`, parse its stdout as JSON, and check that the
// reported Node.js major version matches semver.major(process.version) // reported Node.js major version matches semver.major(process.version)
let { stdout, stderr } = await execFileAsync(pkgBalenaPath, [ const stdout = await getSubprocessStdout(pkgBalenaPath, ['version', '-j']);
'version',
'-j',
]);
const { filterCliOutputForTests } = await import('../tests/helpers');
const filtered = filterCliOutputForTests({
err: stderr.split(/\r?\n/),
out: stdout.split(/\r?\n/),
});
stdout = filtered.out.join('\n');
stderr = filtered.err.join('\n');
let pkgNodeVersion = ''; let pkgNodeVersion = '';
let pkgNodeMajorVersion = 0; let pkgNodeMajorVersion = 0;
try { try {
@ -286,10 +260,6 @@ async function testPkg() {
`Mismatched major version: built-in pkg Node version="${pkgNodeVersion}" vs process.version="${process.version}"`, `Mismatched major version: built-in pkg Node version="${pkgNodeVersion}" vs process.version="${process.version}"`,
); );
} }
if (filtered.err.length > 0) {
const err = filtered.err.join('\n');
throw new Error(`"${pkgBalenaPath}": non-empty stderr "${err}"`);
}
console.log('Success! (standalone package test successful)'); console.log('Success! (standalone package test successful)');
} }
@ -305,7 +275,7 @@ async function zipPkg() {
); );
} }
await fs.mkdirp(path.dirname(outputFile)); await fs.mkdirp(path.dirname(outputFile));
await new Promise<void>((resolve, reject) => { await new Promise((resolve, reject) => {
console.log(`Zipping standalone package to "${outputFile}"...`); console.log(`Zipping standalone package to "${outputFile}"...`);
const archive = archiver('zip', { const archive = archiver('zip', {
@ -322,100 +292,10 @@ async function zipPkg() {
archive.on('warning', console.warn); archive.on('warning', console.warn);
archive.pipe(outputStream); archive.pipe(outputStream);
archive.finalize().catch(reject); archive.finalize();
}); });
} }
export async function signFilesForNotarization() {
console.log('Signing files for notarization');
if (process.platform !== 'darwin') {
return;
}
console.log('Deleting unneeded zip files...');
await new Promise((resolve, reject) => {
klaw('node_modules/')
.on('data', (item: { path: string; stats: Stats }) => {
if (!item.stats.isFile()) {
return;
}
if (path.basename(item.path).endsWith('.node.bak')) {
console.log('Removing pkg .node.bak file', item.path);
fs.unlinkSync(item.path);
}
if (
path.basename(item.path).endsWith('.zip') &&
path.dirname(item.path).includes('test')
) {
console.log('Removing zip', item.path);
fs.unlinkSync(item.path);
}
})
.on('end', resolve)
.on('error', reject);
});
// Sign all .node files first
console.log('Signing .node files...');
await new Promise((resolve, reject) => {
klaw('node_modules/')
.on('data', async (item: { path: string; stats: Stats }) => {
if (!item.stats.isFile()) {
return;
}
if (path.basename(item.path).endsWith('.node')) {
console.log('running command:', 'codesign', [
'-d',
'-f',
'-s',
'Developer ID Application: Balena Ltd (66H43P8FRG)',
item.path,
]);
await whichSpawn('codesign', [
'-d',
'-f',
'-s',
'Developer ID Application: Balena Ltd (66H43P8FRG)',
item.path,
]);
}
})
.on('end', resolve)
.on('error', reject);
});
console.log('Signing other binaries...');
console.log('running command:', 'codesign', [
'-d',
'-f',
'--options=runtime',
'-s',
'Developer ID Application: Balena Ltd (66H43P8FRG)',
'node_modules/denymount/bin/denymount',
]);
await whichSpawn('codesign', [
'-d',
'-f',
'--options=runtime',
'-s',
'Developer ID Application: Balena Ltd (66H43P8FRG)',
'node_modules/denymount/bin/denymount',
]);
console.log('running command:', 'codesign', [
'-d',
'-f',
'--options=runtime',
'-s',
'Developer ID Application: Balena Ltd (66H43P8FRG)',
'node_modules/macmount/bin/macmount',
]);
await whichSpawn('codesign', [
'-d',
'-f',
'--options=runtime',
'-s',
'Developer ID Application: Balena Ltd (66H43P8FRG)',
'node_modules/macmount/bin/macmount',
]);
}
export async function buildStandaloneZip() { export async function buildStandaloneZip() {
console.log(`Building standalone zip package for CLI ${version}`); console.log(`Building standalone zip package for CLI ${version}`);
try { try {
@ -430,7 +310,6 @@ export async function buildStandaloneZip() {
} }
async function renameInstallerFiles() { async function renameInstallerFiles() {
const oclifInstallers = await getOclifInstallersOriginalNames();
if (await fs.pathExists(oclifInstallers[process.platform])) { if (await fs.pathExists(oclifInstallers[process.platform])) {
await fs.rename( await fs.rename(
oclifInstallers[process.platform], oclifInstallers[process.platform],
@ -441,30 +320,22 @@ async function renameInstallerFiles() {
/** /**
* If the CSC_LINK and CSC_KEY_PASSWORD env vars are set, digitally sign the * If the CSC_LINK and CSC_KEY_PASSWORD env vars are set, digitally sign the
* executable installer using Microsoft SignTool.exe (Sign Tool) * executable installer by running the balena-io/scripts/shared/sign-exe.sh
* https://learn.microsoft.com/en-us/dotnet/framework/tools/signtool-exe * script (which must be in the PATH) using a MSYS2 bash shell.
*/ */
async function signWindowsInstaller() { async function signWindowsInstaller() {
if (process.env.SM_CODE_SIGNING_CERT_SHA1_HASH) { if (process.env.CSC_LINK && process.env.CSC_KEY_PASSWORD) {
const exeName = renamedOclifInstallers[process.platform]; const exeName = renamedOclifInstallers[process.platform];
const execFileAsync = util.promisify<string, string[], void>(execFile);
console.log(`Signing installer "${exeName}"`); console.log(`Signing installer "${exeName}"`);
// trust ... await execFileAsync(MSYS2_BASH, [
await execFileAsync('signtool.exe', [ 'sign-exe.sh',
'sign', '-f',
'-sha1', exeName,
process.env.SM_CODE_SIGNING_CERT_SHA1_HASH,
'-tr',
process.env.TIMESTAMP_SERVER || 'http://timestamp.comodoca.com',
'-td',
'SHA256',
'-fd',
'SHA256',
'-d', '-d',
`balena-cli ${version}`, `balena-cli ${version}`,
exeName,
]); ]);
// ... but verify
await execFileAsync('signtool.exe', ['verify', '-pa', '-v', exeName]);
} else { } else {
console.log( console.log(
'Skipping installer signing step because CSC_* env vars are not set', 'Skipping installer signing step because CSC_* env vars are not set',
@ -473,27 +344,7 @@ async function signWindowsInstaller() {
} }
/** /**
* Wait for Apple Installer Notarization to continue * Run the `oclif-dev pack:win` or `pack:macos` command (depending on the value
*/
async function notarizeMacInstaller(): Promise<void> {
const teamId = process.env.XCODE_APP_LOADER_TEAM_ID || '66H43P8FRG';
const appleId =
process.env.XCODE_APP_LOADER_EMAIL || 'accounts+apple@balena.io';
const appleIdPassword = process.env.XCODE_APP_LOADER_PASSWORD;
if (appleIdPassword && teamId) {
await notarize({
tool: 'notarytool',
teamId,
appPath: renamedOclifInstallers.darwin,
appleId,
appleIdPassword,
});
}
}
/**
* Run the `oclif pack:win` or `pack:macos` command (depending on the value
* of process.platform) to generate the native installers (which end up under * of process.platform) to generate the native installers (which end up under
* the 'dist' folder). There are some harcoded options such as selecting only * the 'dist' folder). There are some harcoded options such as selecting only
* 64-bit binaries under Windows. * 64-bit binaries under Windows.
@ -503,10 +354,9 @@ export async function buildOclifInstaller() {
let packOpts = ['-r', ROOT]; let packOpts = ['-r', ROOT];
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
packOS = 'macos'; packOS = 'macos';
packOpts = packOpts.concat('--targets', `darwin-${arch}`);
} else if (process.platform === 'win32') { } else if (process.platform === 'win32') {
packOS = 'win'; packOS = 'win';
packOpts = packOpts.concat('--targets', 'win32-x64'); packOpts = packOpts.concat('-t', 'win32-x64');
} }
if (packOS) { if (packOS) {
console.log(`Building oclif installer for CLI ${version}`); console.log(`Building oclif installer for CLI ${version}`);
@ -517,14 +367,13 @@ export async function buildOclifInstaller() {
} }
for (const dir of dirs) { for (const dir of dirs) {
console.log(`rimraf(${dir})`); console.log(`rimraf(${dir})`);
await rimrafAsync(dir); await Bluebird.fromCallback((cb) => rimraf(dir, cb));
} }
console.log('======================================================='); console.log('=======================================================');
console.log(`oclif ${packCmd} ${packOpts.join(' ')}`); console.log(`oclif-dev "${packCmd}" "${packOpts.join('" "')}"`);
console.log(`cwd="${process.cwd()}" ROOT="${ROOT}"`); console.log(`cwd="${process.cwd()}" ROOT="${ROOT}"`);
console.log('======================================================='); console.log('=======================================================');
const oclifPath = path.join(ROOT, 'node_modules', 'oclif'); await oclifRun([packCmd].concat(...packOpts));
await oclifRun([packCmd].concat(...packOpts), oclifPath);
await renameInstallerFiles(); await renameInstallerFiles();
// The Windows installer is explicitly signed here (oclif doesn't do it). // The Windows installer is explicitly signed here (oclif doesn't do it).
// The macOS installer is automatically signed by oclif (which runs the // The macOS installer is automatically signed by oclif (which runs the
@ -532,10 +381,6 @@ export async function buildOclifInstaller() {
// (`oclif.macos.sign` section). // (`oclif.macos.sign` section).
if (process.platform === 'win32') { if (process.platform === 'win32') {
await signWindowsInstaller(); await signWindowsInstaller();
} else if (process.platform === 'darwin') {
console.log('Notarizing package...');
await notarizeMacInstaller(); // Notarize
console.log('Package notarized.');
} }
console.log(`oclif installer build completed`); console.log(`oclif installer build completed`);
} }

View File

@ -17,7 +17,6 @@
import * as path from 'path'; import * as path from 'path';
import { MarkdownFileParser } from './utils'; import { MarkdownFileParser } from './utils';
import { GlobSync } from 'glob';
/** /**
* This is the skeleton of CLI documentation/reference web page at: * This is the skeleton of CLI documentation/reference web page at:
@ -25,111 +24,163 @@ import { GlobSync } from 'glob';
* *
* The `getCapitanoDoc` function in this module parses README.md and adds * The `getCapitanoDoc` function in this module parses README.md and adds
* some content to this object. * some content to this object.
*
* IMPORTANT
*
* All commands need to be stored under a folder in src/commands to maintain uniformity
* Generating docs will error out if directive not followed
* To add a custom heading for command docs, add the heading next to the folder name
* in the `commandHeadings` dictionary.
*
* This dictionary is the source of truth that creates the docs config which is used
* to generate the CLI documentation. By default, the folder name will be used.
*
*/ */
const capitanoDoc = {
interface Category {
title: string;
files: string[];
}
interface Documentation {
title: string;
introduction: string;
categories: Category[];
}
// Mapping folders names to custom headings in the docs
const commandHeadings: { [key: string]: string } = {
'api-key': 'API Keys',
login: 'Authentication',
whoami: 'Authentication',
logout: 'Authentication',
env: 'Environment Variables',
help: 'Help and Version',
'ssh-key': 'SSH Keys',
organization: 'Organizations',
os: 'OS',
util: 'Utilities',
build: 'Deploy',
join: 'Platform',
leave: 'Platform',
app: 'Apps',
block: 'Blocks',
device: 'Devices',
fleet: 'Fleets',
release: 'Releases',
tag: 'Tags',
};
// Fetch all available commands
const allCommandsPaths = new GlobSync('build/commands/**/*.js', {
ignore: 'build/commands/internal/**',
}).found;
// Throw error if any commands found outside of command directories
const illegalCommandPaths = allCommandsPaths.filter((commandPath: string) =>
/^build\/commands\/[^/]+\.js$/.test(commandPath),
);
if (illegalCommandPaths.length !== 0) {
throw new Error(
`Found the following commands without a command directory: ${illegalCommandPaths}\n
To resolve this error, move the respective commands to their resource directories or create new ones.\n
Refer to the automation/capitanodoc/capitanodoc.ts file for more information.`,
);
}
// Docs config template
const capitanoDoc: Documentation = {
title: 'balena CLI Documentation', title: 'balena CLI Documentation',
introduction: '', introduction: '',
categories: [], categories: [
{
title: 'API keys',
files: ['build/commands/api-key/generate.js'],
},
{
title: 'Application',
files: [
'build/commands/apps.js',
'build/commands/app/index.js',
'build/commands/app/create.js',
'build/commands/app/purge.js',
'build/commands/app/rename.js',
'build/commands/app/restart.js',
'build/commands/app/rm.js',
],
},
{
title: 'Authentication',
files: [
'build/commands/login.js',
'build/commands/logout.js',
'build/commands/whoami.js',
],
},
{
title: 'Device',
files: [
'build/commands/devices/index.js',
'build/commands/devices/supported.js',
'build/commands/device/index.js',
'build/commands/device/identify.js',
'build/commands/device/init.js',
'build/commands/device/move.js',
'build/commands/device/os-update.js',
'build/commands/device/public-url.js',
'build/commands/device/purge.js',
'build/commands/device/reboot.js',
'build/commands/device/register.js',
'build/commands/device/rename.js',
'build/commands/device/restart.js',
'build/commands/device/rm.js',
'build/commands/device/shutdown.js',
],
},
{
title: 'Environment Variables',
files: [
'build/commands/envs.js',
'build/commands/env/add.js',
'build/commands/env/rename.js',
'build/commands/env/rm.js',
],
},
{
title: 'Tags',
files: [
'build/commands/tags.js',
'build/commands/tag/rm.js',
'build/commands/tag/set.js',
],
},
{
title: 'Help and Version',
files: ['help', 'build/commands/version.js'],
},
{
title: 'Keys',
files: [
'build/commands/keys.js',
'build/commands/key/index.js',
'build/commands/key/add.js',
'build/commands/key/rm.js',
],
},
{
title: 'Logs',
files: ['build/commands/logs.js'],
},
{
title: 'Network',
files: [
'build/commands/scan.js',
'build/commands/ssh.js',
'build/commands/tunnel.js',
],
},
{
title: 'Notes',
files: ['build/commands/note.js'],
},
{
title: 'OS',
files: [
'build/commands/os/build-config.js',
'build/commands/os/configure.js',
'build/commands/os/versions.js',
'build/commands/os/download.js',
'build/commands/os/initialize.js',
],
},
{
title: 'Config',
files: [
'build/commands/config/generate.js',
'build/commands/config/inject.js',
'build/commands/config/read.js',
'build/commands/config/reconfigure.js',
'build/commands/config/write.js',
],
},
{
title: 'Preload',
files: ['build/commands/preload.js'],
},
{
title: 'Push',
files: ['build/commands/push.js'],
},
{
title: 'Settings',
files: ['build/commands/settings.js'],
},
{
title: 'Local',
files: [
'build/commands/local/configure.js',
'build/commands/local/flash.js',
],
},
{
title: 'Deploy',
files: ['build/commands/build.js', 'build/commands/deploy.js'],
},
{
title: 'Platform',
files: ['build/commands/join.js', 'build/commands/leave.js'],
},
{
title: 'Utilities',
files: ['build/commands/util/available-drives.js'],
},
{
title: 'Support',
files: ['build/commands/support.js'],
},
],
}; };
// Helper function to capitalize each word of directory name
function formatTitle(dir: string): string {
return dir.replace(/(^\w|\s\w)/g, (word) => word.toUpperCase());
}
// Create a map to track the categories for faster lookup
const categoriesMap: { [key: string]: Category } = {};
for (const commandPath of allCommandsPaths) {
const commandDir = path.basename(path.dirname(commandPath));
const heading = commandHeadings[commandDir] || formatTitle(commandDir);
if (!categoriesMap[heading]) {
categoriesMap[heading] = { title: heading, files: [] };
capitanoDoc.categories.push(categoriesMap[heading]);
}
categoriesMap[heading].files.push(commandPath);
}
// Sort Category titles alphabetically
capitanoDoc.categories = capitanoDoc.categories.sort((a, b) =>
a.title.localeCompare(b.title),
);
// Sort Category file paths alphabetically
capitanoDoc.categories.forEach((category) => {
category.files.sort((a, b) => a.localeCompare(b));
});
/** /**
* Modify and return the `capitanoDoc` object above in order to generate the * Modify and return the `capitanoDoc` object above in order to render the
* CLI documentation at docs/balena-cli.md * CLI documentation/reference web page at:
* https://www.balena.io/docs/reference/cli/
* *
* This function parses the README.md file to extract relevant sections * This function parses the README.md file to extract relevant sections
* for the documentation web page. * for the documentation web page.
@ -145,7 +196,7 @@ export async function getCapitanoDoc(): Promise<typeof capitanoDoc> {
throw new Error(`Error parsing section title`); throw new Error(`Error parsing section title`);
} }
// match[1] has the title, match[2] has the rest // match[1] has the title, match[2] has the rest
return match?.[2]; return match && match[2];
}), }),
mdParser.getSectionOfTitle('Installation'), mdParser.getSectionOfTitle('Installation'),
mdParser.getSectionOfTitle('Choosing a shell (command prompt/terminal)'), mdParser.getSectionOfTitle('Choosing a shell (command prompt/terminal)'),

View File

@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import type { Command as OclifCommandClass } from '@oclif/core'; import { Command as OclifCommandClass } from '@oclif/command';
type OclifCommand = typeof OclifCommandClass; type OclifCommand = typeof OclifCommandClass;
@ -26,7 +26,7 @@ export interface Document {
export interface Category { export interface Category {
title: string; title: string;
commands: Array<OclifCommand & { name: string }>; commands: OclifCommand[];
} }
export { OclifCommand }; export { OclifCommand };

View File

@ -16,8 +16,9 @@
*/ */
import * as path from 'path'; import * as path from 'path';
import { getCapitanoDoc } from './capitanodoc'; import { getCapitanoDoc } from './capitanodoc';
import type { Category, Document, OclifCommand } from './doc-types'; import { Category, Document, OclifCommand } from './doc-types';
import * as markdown from './markdown'; import * as markdown from './markdown';
import { stripIndent } from '../../lib/utils/lazy';
/** /**
* Generates the markdown document (as a string) for the CLI documentation * Generates the markdown document (as a string) for the CLI documentation
@ -38,7 +39,7 @@ export async function renderMarkdown(): Promise<string> {
}; };
for (const jsFilename of commandCategory.files) { for (const jsFilename of commandCategory.files) {
category.commands.push(await importOclifCommands(jsFilename)); category.commands.push(...importOclifCommands(jsFilename));
} }
result.categories.push(category); result.categories.push(category);
} }
@ -46,23 +47,49 @@ export async function renderMarkdown(): Promise<string> {
return markdown.render(result); return markdown.render(result);
} }
async function importOclifCommands(jsFilename: string) { // Help is now managed via a plugin
const command = (await import(path.join(process.cwd(), jsFilename))) // This fake command allows capitanodoc to include help in docs
.default as OclifCommand; class FakeHelpCommand {
description = stripIndent`
List balena commands, or get detailed help for a specific command.
return { List balena commands, or get detailed help for a specific command.
...command, `;
// build/commands/device/index.js -> device
// build/commands/device/list.js -> device list examples = [
name: jsFilename '$ balena help',
.split('/') '$ balena help apps',
.slice(2) '$ balena help os download',
.join(' ') ];
.split('.')
.slice(0, 1) args = [
.join(' ') {
.split(' index')[0], name: 'command',
} as Category['commands'][0]; description: 'command to show help for',
},
];
usage = 'help [command]';
flags = {
verbose: {
description: 'show additional commands',
char: '-v',
},
};
}
function importOclifCommands(jsFilename: string): OclifCommand[] {
// TODO: Currently oclif commands with no `usage` overridden will cause
// an error when parsed. This should be improved so that `usage` does not have
// to be overridden if not necessary.
const command: OclifCommand =
jsFilename === 'help'
? ((new FakeHelpCommand() as unknown) as OclifCommand)
: (require(path.join(process.cwd(), jsFilename)).default as OclifCommand);
return [command];
} }
/** /**
@ -74,9 +101,8 @@ async function printMarkdown() {
console.log(await renderMarkdown()); console.log(await renderMarkdown());
} catch (error) { } catch (error) {
console.error(error); console.error(error);
process.exitCode = 1; process.exit(1);
} }
} }
// eslint-disable-next-line @typescript-eslint/no-floating-promises
printMarkdown(); printMarkdown();

View File

@ -14,31 +14,16 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import { Parser } from '@oclif/core'; import { flagUsages } from '@oclif/parser';
import * as ent from 'ent'; import * as ent from 'ent';
import * as _ from 'lodash'; import * as _ from 'lodash';
import { capitanoizeOclifUsage } from '../../src/utils/oclif-utils'; import { getManualSortCompareFunction } from '../../lib/utils/helpers';
import type { Category, Document } from './doc-types'; import { capitanoizeOclifUsage } from '../../lib/utils/oclif-utils';
import { Category, Document, OclifCommand } from './doc-types';
function renderOclifCommand(command: Category['commands'][0]): string[] { function renderOclifCommand(command: OclifCommand): string[] {
const result = [`## ${ent.encode(command.name || '')}`]; const result = [`## ${ent.encode(command.usage || '')}`];
if (command.aliases?.length) {
result.push('### Aliases');
result.push(
command.aliases
.map(
(alias) =>
`- \`${alias}\`${command.deprecateAliases ? ' *(deprecated)*' : ''}`,
)
.join('\n'),
);
result.push(
`\nTo use one of the aliases, replace \`${command.name}\` with the alias.`,
);
}
result.push('### Description');
const description = (command.description || '') const description = (command.description || '')
.split('\n') .split('\n')
.slice(1) // remove the first line, which oclif uses as help header .slice(1) // remove the first line, which oclif uses as help header
@ -52,8 +37,8 @@ function renderOclifCommand(command: Category['commands'][0]): string[] {
if (!_.isEmpty(command.args)) { if (!_.isEmpty(command.args)) {
result.push('### Arguments'); result.push('### Arguments');
for (const [name, arg] of Object.entries(command.args!)) { for (const arg of command.args!) {
result.push(`#### ${name.toUpperCase()}`, arg.description || ''); result.push(`#### ${arg.name.toUpperCase()}`, arg.description || '');
} }
} }
@ -64,7 +49,7 @@ function renderOclifCommand(command: Category['commands'][0]): string[] {
continue; continue;
} }
flag.name = name; flag.name = name;
const flagUsage = Parser.flagUsages([flag]) const flagUsage = flagUsages([flag])
.map(([usage, _description]) => usage) .map(([usage, _description]) => usage)
.join() .join()
.trim(); .trim();
@ -95,7 +80,7 @@ function renderToc(categories: Category[]): string[] {
result.push( result.push(
category.commands category.commands
.map((command) => { .map((command) => {
const signature = capitanoizeOclifUsage(command.name); const signature = capitanoizeOclifUsage(command.usage);
return `\t- [${ent.encode(signature)}](${getAnchor(signature)})`; return `\t- [${ent.encode(signature)}](${getAnchor(signature)})`;
}) })
.join('\n'), .join('\n'),
@ -104,7 +89,33 @@ function renderToc(categories: Category[]): string[] {
return result; return result;
} }
const manualCategorySorting: { [category: string]: string[] } = {
'Environment Variables': ['envs', 'env rm', 'env add', 'env rename'],
OS: [
'os versions',
'os download',
'os build config',
'os configure',
'os initialize',
],
};
function sortCommands(doc: Document): void {
for (const category of doc.categories) {
if (category.title in manualCategorySorting) {
category.commands = category.commands.sort(
getManualSortCompareFunction<OclifCommand, string>(
manualCategorySorting[category.title],
(cmd: OclifCommand, x: string) =>
(cmd.usage || '').toString().replace(/\W+/g, ' ').includes(x),
),
);
}
}
}
export function render(doc: Document) { export function render(doc: Document) {
sortCommands(doc);
const result = [ const result = [
`# ${doc.title}`, `# ${doc.title}`,
doc.introduction, doc.introduction,

View File

@ -15,9 +15,41 @@
* limitations under the License. * limitations under the License.
*/ */
import type { OptionDefinition } from 'capitano';
import * as ent from 'ent';
import * as fs from 'fs'; import * as fs from 'fs';
import * as readline from 'readline'; import * as readline from 'readline';
export function getOptionPrefix(signature: string) {
if (signature.length > 1) {
return '--';
} else {
return '-';
}
}
export function getOptionSignature(signature: string) {
return `${getOptionPrefix(signature)}${signature}`;
}
export function parseCapitanoOption(option: OptionDefinition): string {
let result = getOptionSignature(option.signature);
if (Array.isArray(option.alias)) {
for (const alias of option.alias) {
result += `, ${getOptionSignature(alias)}`;
}
} else if (typeof option.alias === 'string') {
result += `, ${getOptionSignature(option.alias)}`;
}
if (option.parameter) {
result += ` <${option.parameter}>`;
}
return ent.encode(result);
}
export class MarkdownFileParser { export class MarkdownFileParser {
constructor(public mdFilePath: string) {} constructor(public mdFilePath: string) {}

View File

@ -15,25 +15,24 @@
* limitations under the License. * limitations under the License.
*/ */
// eslint-disable-next-line no-restricted-imports const stripIndent = require('common-tags/lib/stripIndent');
import { stripIndent } from 'common-tags'; const _ = require('lodash');
import * as _ from 'lodash'; const { promises: fs } = require('fs');
import { promises as fs } from 'fs'; const path = require('path');
import * as path from 'path'; const simplegit = require('simple-git/promise');
import { simpleGit } from 'simple-git';
const ROOT = path.normalize(path.join(__dirname, '..')); const ROOT = path.normalize(path.join(__dirname, '..'));
/** /**
* Compare the timestamp of balena-cli.md with the timestamp of staged files, * Compare the timestamp of cli.markdown with the timestamp of staged files,
* issuing an error if balena-cli.md is older. * issuing an error if cli.markdown is older.
* If balena-cli.md does not require updating and the developer cannot run * If cli.markdown does not require updating and the developer cannot run
* `npm run build` on their laptop, the error message suggests a workaround * `npm run build` on their laptop, the error message suggests a workaround
* using `touch`. * using `touch`.
*/ */
async function checkBuildTimestamps() { async function checkBuildTimestamps() {
const git = simpleGit(ROOT); const git = simplegit(ROOT);
const docFile = path.join(ROOT, 'docs', 'balena-cli.md'); const docFile = path.join(ROOT, 'doc', 'cli.markdown');
const [docStat, gitStatus] = await Promise.all([ const [docStat, gitStatus] = await Promise.all([
fs.stat(docFile), fs.stat(docFile),
git.status(), git.status(),
@ -43,8 +42,8 @@ async function checkBuildTimestamps() {
...gitStatus.staged, ...gitStatus.staged,
...gitStatus.renamed.map((o) => o.to), ...gitStatus.renamed.map((o) => o.to),
]) ])
// select only staged files that start with src/ or typings/ // select only staged files that start with lib/ or typings/
.filter((f) => f.match(/^(src|typings)[/\\]/)) .filter((f) => f.match(/^(lib|typings)[/\\]/))
.map((f) => path.join(ROOT, f)); .map((f) => path.join(ROOT, f));
const fStats = await Promise.all(stagedFiles.map((f) => fs.stat(f))); const fStats = await Promise.all(stagedFiles.map((f) => fs.stat(f)));
@ -82,5 +81,4 @@ async function run() {
} }
} }
// eslint-disable-next-line @typescript-eslint/no-floating-promises
run(); run();

View File

@ -6,8 +6,6 @@
* *
* We don't `require('semver')` to allow this script to be run as a npm * We don't `require('semver')` to allow this script to be run as a npm
* 'preinstall' hook, at which point no dependencies have been installed. * 'preinstall' hook, at which point no dependencies have been installed.
*
* @param {string} version
*/ */
function parseSemver(version) { function parseSemver(version) {
const match = /v?(\d+)\.(\d+).(\d+)/.exec(version); const match = /v?(\d+)\.(\d+).(\d+)/.exec(version);
@ -18,13 +16,9 @@ function parseSemver(version) {
return [parseInt(major, 10), parseInt(minor, 10), parseInt(patch, 10)]; return [parseInt(major, 10), parseInt(minor, 10), parseInt(patch, 10)];
} }
/**
* @param {string} v1
* @param {string} v2
*/
function semverGte(v1, v2) { function semverGte(v1, v2) {
const v1Array = parseSemver(v1); let v1Array = parseSemver(v1);
const v2Array = parseSemver(v2); let v2Array = parseSemver(v2);
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
if (v1Array[i] < v2Array[i]) { if (v1Array[i] < v2Array[i]) {
return false; return false;
@ -47,25 +41,17 @@ function checkNpmVersion() {
// the reason is that it would unnecessarily prevent end users from // the reason is that it would unnecessarily prevent end users from
// using npm v6.4.1 that ships with Node 8. (It is OK for the // using npm v6.4.1 that ships with Node 8. (It is OK for the
// shrinkwrap file to get damaged if it is not going to be reused.) // shrinkwrap file to get damaged if it is not going to be reused.)
throw new Error(`\ console.error(`\
----------------------------------------------------------------------------- -------------------------------------------------------------------------------
Error: npm version '${npmVersion}' detected. Please upgrade to npm v${requiredVersion} or later Error: npm version '${npmVersion}' detected. Please upgrade to npm v${requiredVersion} or later
because of a bug that causes the 'npm-shrinkwrap.json' file to be damaged. because of a bug that causes the 'npm-shrinkwrap.json' file to be damaged.
At this point, however, your 'npm-shrinkwrap.json' file has already been At this point, however, your 'npm-shrinkwrap.json' file has already been
damaged. Please revert it to the master branch state with a command such as: damaged. Please revert it to the master branch state with a command such as:
"git checkout master -- npm-shrinkwrap.json" "git checkout master -- npm-shrinkwrap.json"
Then re-run "npm install" using npm version ${requiredVersion} or later. Then re-run "npm install" using npm version ${requiredVersion} or later.
-----------------------------------------------------------------------------`); -------------------------------------------------------------------------------`);
process.exit(1);
} }
} }
function main() { checkNpmVersion();
try {
checkNpmVersion();
} catch (e) {
console.error(e.message || e);
process.exitCode = 1;
}
}
main();

252
automation/deploy-bin.ts Normal file
View File

@ -0,0 +1,252 @@
/**
* @license
* Copyright 2019 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.
*/
import * as Bluebird from 'bluebird';
import * as _ from 'lodash';
import * as semver from 'semver';
import { finalReleaseAssets, version } from './build-bin';
const { GITHUB_TOKEN } = process.env;
/**
* Create or update a release in GitHub's releases page, uploading the
* installer files (standalone zip + native oclif installers).
*/
export async function createGitHubRelease() {
console.log(`Publishing release ${version} to GitHub`);
const publishRelease = await import('publish-release');
const ghRelease = await Bluebird.fromCallback(
publishRelease.bind(null, {
token: GITHUB_TOKEN || '',
owner: 'balena-io',
repo: 'balena-cli',
tag: version,
name: `balena-CLI ${version}`,
reuseRelease: true,
assets: finalReleaseAssets[process.platform],
}),
);
console.log(`Release ${version} successful: ${ghRelease.html_url}`);
}
/**
* Top-level function to create a CLI release in GitHub's releases page:
* call zipStandaloneInstaller(), rename the files as we'd like them to
* display on the releases page, and call createGitHubRelease() to upload
* the files.
*/
export async function release() {
try {
await createGitHubRelease();
} catch (err) {
console.error('Release failed');
console.error(err);
process.exit(1);
}
}
/** Return a cached Octokit instance, creating a new one as needed. */
const getOctokit = _.once(function () {
const Octokit = (require('@octokit/rest') as typeof import('@octokit/rest')).Octokit.plugin(
(require('@octokit/plugin-throttling') as typeof import('@octokit/plugin-throttling'))
.throttling,
);
return new Octokit({
auth: GITHUB_TOKEN,
throttle: {
onRateLimit: (retryAfter: number, options: any) => {
console.warn(
`Request quota exhausted for request ${options.method} ${options.url}`,
);
// retries 3 times
if (options.request.retryCount < 3) {
console.log(`Retrying after ${retryAfter} seconds!`);
return true;
}
},
onAbuseLimit: (_retryAfter: number, options: any) => {
// does not retry, only logs a warning
console.warn(
`Abuse detected for request ${options.method} ${options.url}`,
);
},
},
});
});
/**
* Extract pagination information (current page, total pages, ordinal number)
* from the 'link' response header (example below), using the parse-link-header
* npm package:
* "link": "<https://api.github.com/repositories/187370853/releases?per_page=2&page=2>; rel=\"next\",
* <https://api.github.com/repositories/187370853/releases?per_page=2&page=3>; rel=\"last\""
*
* @param response Octokit response object (including response.headers.link)
* @param perPageDefault Default per_page pagination value if missing in URL
* @return Object where 'page' is the current page number (1-based),
* 'pages' is the total number of pages, and 'ordinal' is the ordinal number
* (3rd, 4th, 5th...) of the first item in the current page.
*/
function getPageNumbers(
response: any,
perPageDefault: number,
): { page: number; pages: number; ordinal: number } {
const res = { page: 1, pages: 1, ordinal: 1 };
if (!response.headers.link) {
return res;
}
const parse = require('parse-link-header') as typeof import('parse-link-header');
const parsed = parse(response.headers.link);
if (parsed == null) {
throw new Error(`Failed to parse link header: '${response.headers.link}'`);
}
let perPage = perPageDefault;
if (parsed.next) {
if (parsed.next.per_page) {
perPage = parseInt(parsed.next.per_page, 10);
}
res.page = parseInt(parsed.next.page, 10) - 1;
res.pages = parseInt(parsed.last.page, 10);
} else {
if (parsed.prev.per_page) {
perPage = parseInt(parsed.prev.per_page, 10);
}
res.page = res.pages = parseInt(parsed.prev.page, 10) + 1;
}
res.ordinal = (res.page - 1) * perPage + 1;
return res;
}
/**
* Iterate over every GitHub release in the given owner/repo, check whether
* its tag_name matches against the affectedVersions semver spec, and if so
* replace its release description (body) with the given newDescription value.
* @param owner GitHub repo owner, e.g. 'balena-io' or 'pdcastro'
* @param repo GitHub repo, e.g. 'balena-cli'
* @param affectedVersions Semver spec, e.g. '2.6.1 - 7.10.9 || 8.0.0'
* @param newDescription New release description (body)
* @param editID Short string present in newDescription, e.g. '[AA101]', that
* can be searched to determine whether that release has already been updated.
*/
async function updateGitHubReleaseDescriptions(
owner: string,
repo: string,
affectedVersions: string,
newDescription: string,
editID: string,
) {
const perPage = 30;
const octokit = getOctokit();
const options = await octokit.repos.listReleases.endpoint.merge({
owner,
repo,
per_page: perPage,
});
let errCount = 0;
for await (const response of octokit.paginate.iterator(options)) {
const { page: thisPage, pages: totalPages, ordinal } = getPageNumbers(
response,
perPage,
);
let i = 0;
for (const cliRelease of response.data) {
const prefix = `[#${ordinal + i++} pg ${thisPage}/${totalPages}]`;
if (!cliRelease.id) {
console.error(
`${prefix} Error: missing release ID (errCount=${++errCount})`,
);
continue;
}
const skipMsg = `${prefix} skipping release "${cliRelease.tag_name}" (${cliRelease.id})`;
if (cliRelease.draft === true) {
console.info(`${skipMsg}: draft release`);
continue;
} else if (cliRelease.body && cliRelease.body.includes(editID)) {
console.info(`${skipMsg}: already updated`);
continue;
} else if (!semver.satisfies(cliRelease.tag_name, affectedVersions)) {
console.info(`${skipMsg}: outside version range`);
continue;
} else {
const updatedRelease = {
owner,
repo,
release_id: cliRelease.id,
body: newDescription,
};
let oldBodyPreview = cliRelease.body;
if (oldBodyPreview) {
oldBodyPreview = oldBodyPreview.replace(/\s+/g, ' ').trim();
if (oldBodyPreview.length > 12) {
oldBodyPreview = oldBodyPreview.substring(0, 9) + '...';
}
}
console.info(
`${prefix} updating release "${cliRelease.tag_name}" (${cliRelease.id}) old body="${oldBodyPreview}"`,
);
try {
await octokit.repos.updateRelease(updatedRelease);
} catch (err) {
console.error(
`${skipMsg}: Error: ${err.message} (count=${++errCount})`,
);
continue;
}
}
}
}
}
/**
* Add a warning description to CLI releases affected by a mixpanel tracking
* security issue (#1359). This function can be executed "manually" with the
* following command line:
*
* npx ts-node --type-check -P automation/tsconfig.json automation/run.ts fix1359
*/
export async function updateDescriptionOfReleasesAffectedByIssue1359() {
// Run only on Linux/Node10, instead of all platform/Node combinations.
// (It could have been any other platform, as long as it only runs once.)
if (process.platform !== 'linux' || semver.major(process.version) !== 10) {
return;
}
const owner = 'balena-io';
const repo = 'balena-cli';
const affectedVersions =
'2.6.1 - 7.10.9 || 8.0.0 - 8.1.0 || 9.0.0 - 9.15.6 || 10.0.0 - 10.17.5 || 11.0.0 - 11.7.2';
const editID = '[AA100]';
let newDescription = `
Please note: the "login" command in this release is affected by a
security issue fixed in versions
[7.10.10](https://github.com/balena-io/balena-cli/releases/tag/v7.10.10),
[8.1.1](https://github.com/balena-io/balena-cli/releases/tag/v8.1.1),
[9.15.7](https://github.com/balena-io/balena-cli/releases/tag/v9.15.7),
[10.17.6](https://github.com/balena-io/balena-cli/releases/tag/v10.17.6),
[11.7.3](https://github.com/balena-io/balena-cli/releases/tag/v11.7.3)
and later. If you need to use this version, avoid passing your password,
keys or tokens as command-line arguments. ${editID}`;
// remove line breaks and collapse white space
newDescription = newDescription.replace(/\s+/g, ' ').trim();
await updateGitHubReleaseDescriptions(
owner,
repo,
affectedVersions,
newDescription,
editID,
);
}

View File

@ -21,9 +21,13 @@ import {
buildOclifInstaller, buildOclifInstaller,
buildStandaloneZip, buildStandaloneZip,
catchUncommitted, catchUncommitted,
signFilesForNotarization,
testShrinkwrap, testShrinkwrap,
} from './build-bin'; } from './build-bin';
import {
release,
updateDescriptionOfReleasesAffectedByIssue1359,
} from './deploy-bin';
import { fixPathForMsys, ROOT, runUnderMsys } from './utils';
// DEBUG set to falsy for negative values else is truthy // DEBUG set to falsy for negative values else is truthy
process.env.DEBUG = ['0', 'no', 'false', '', undefined].includes( process.env.DEBUG = ['0', 'no', 'false', '', undefined].includes(
@ -32,56 +36,88 @@ process.env.DEBUG = ['0', 'no', 'false', '', undefined].includes(
? '' ? ''
: '1'; : '1';
function exitWithError(error: Error | string): never {
console.error(`Error: ${error}`);
process.exit(1);
}
/** /**
* Trivial command-line parser. Check whether the command-line argument is one * Trivial command-line parser. Check whether the command-line argument is one
* of the following strings, then call the appropriate functions: * of the following strings, then call the appropriate functions:
* 'build:installer' (to build a native oclif installer) * 'build:installer' (to build a native oclif installer)
* 'build:standalone' (to build a standalone pkg package) * 'build:standalone' (to build a standalone pkg package)
* 'release' (to create/update a GitHub release)
*
* In the case of 'build:installer', also call runUnderMsys() to switch the
* shell from cmd.exe to MSYS2 bash.exe.
* *
* @param args Arguments to parse (default is process.argv.slice(2)) * @param args Arguments to parse (default is process.argv.slice(2))
*/ */
async function parse(args?: string[]) { export async function run(args?: string[]) {
args = args || process.argv.slice(2); args = args || process.argv.slice(2);
console.error(`[debug] automation/run.ts process.argv=[${process.argv}]`); console.log(`automation/run.ts process.argv=[${process.argv}]\n`);
console.error(`[debug] automation/run.ts args=[${args}]`); console.log(`automation/run.ts args=[${args}]`);
if (_.isEmpty(args)) { if (_.isEmpty(args)) {
throw new Error('missing command-line arguments'); return exitWithError('missing command-line arguments');
} }
const commands: { [cmd: string]: () => void | Promise<void> } = { const commands: { [cmd: string]: () => void | Promise<void> } = {
'build:installer': buildOclifInstaller, 'build:installer': buildOclifInstaller,
'build:standalone': buildStandaloneZip, 'build:standalone': buildStandaloneZip,
'sign:binaries': signFilesForNotarization,
'catch-uncommitted': catchUncommitted, 'catch-uncommitted': catchUncommitted,
'test-shrinkwrap': testShrinkwrap, 'test-shrinkwrap': testShrinkwrap,
fix1359: updateDescriptionOfReleasesAffectedByIssue1359,
release,
}; };
for (const arg of args) { for (const arg of args) {
if (!Object.hasOwn(commands, arg)) { if (!commands.hasOwnProperty(arg)) {
throw new Error(`command unknown: ${arg}`); return exitWithError(`command unknown: ${arg}`);
} }
} }
// If runUnderMsys() is called to re-execute this script under MSYS2,
// the current working dir becomes the MSYS2 homedir, so we change back.
process.chdir(ROOT);
// The BUILD_TMP env var is used as an alternative location for oclif
// (patched) to copy/extract the CLI files, run npm install and then
// create the NSIS executable installer for Windows. This was necessary
// to avoid issues with a 260-char limit on Windows paths (possibly a
// limitation of some library used by NSIS), as the "current working dir"
// provided by balena CI is a rather long path to start with.
if (process.platform === 'win32' && !process.env.BUILD_TMP) {
const randID = (await import('crypto'))
.randomBytes(6)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_'); // base64url (RFC 4648)
process.env.BUILD_TMP = `C:\\tmp\\${randID}`;
}
for (const arg of args) { for (const arg of args) {
try { try {
if (arg === 'build:installer' && process.platform === 'win32') {
// ensure running under MSYS2
if (!process.env.MSYSTEM) {
process.env.MSYS2_PATH_TYPE = 'inherit';
await runUnderMsys([
fixPathForMsys(process.argv[0]),
fixPathForMsys(process.argv[1]),
arg,
]);
continue;
}
if (process.env.MSYS2_PATH_TYPE !== 'inherit') {
throw new Error(
'the MSYS2_PATH_TYPE env var must be set to "inherit"',
);
}
}
const cmdFunc = commands[arg]; const cmdFunc = commands[arg];
await cmdFunc(); await cmdFunc();
} catch (err) { } catch (err) {
if (typeof err === 'object') { return exitWithError(`"${arg}": ${err}`);
err.message = `"${arg}": ${err.message}`;
}
throw err;
} }
} }
} }
/** See jsdoc for parse() function above */
export async function run(args?: string[]) {
try {
await parse(args);
} catch (e) {
console.error(e.message ? `Error: ${e.message}` : e);
process.exitCode = 1;
}
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
run(); run();

View File

@ -10,10 +10,7 @@ npm i
if ! diff -q npm-shrinkwrap.json npm-shrinkwrap.json.old > /dev/null; then if ! diff -q npm-shrinkwrap.json npm-shrinkwrap.json.old > /dev/null; then
rm npm-shrinkwrap.json.old rm npm-shrinkwrap.json.old
echo "** npm-shrinkwrap.json was not deduplicated or not fully committed - FAIL **"; echo "** npm-shrinkwrap.json was not deduplicated or not fully committed - FAIL **";
echo "** This can usually be fixed with: **"; echo "** Please run 'npm ci', followed by 'npm dedupe' **";
echo "** git checkout master -- npm-shrinkwrap.json **";
echo "** rm -rf node_modules **";
echo "** npm install && npm dedupe && npm install **";
exit 1; exit 1;
fi fi

View File

@ -3,7 +3,7 @@ import * as semver from 'semver';
const changeTypes = ['major', 'minor', 'patch'] as const; const changeTypes = ['major', 'minor', 'patch'] as const;
const validateChangeType = (maybeChangeType = 'minor') => { const validateChangeType = (maybeChangeType: string = 'minor') => {
maybeChangeType = maybeChangeType.toLowerCase(); maybeChangeType = maybeChangeType.toLowerCase();
switch (maybeChangeType) { switch (maybeChangeType) {
case 'patch': case 'patch':
@ -11,7 +11,8 @@ const validateChangeType = (maybeChangeType = 'minor') => {
case 'major': case 'major':
return maybeChangeType; return maybeChangeType;
default: default:
throw new Error(`Invalid change type: '${maybeChangeType}'`); console.error(`Invalid change type: '${maybeChangeType}'`);
return process.exit(1);
} }
}; };
@ -36,8 +37,8 @@ const run = async (cmd: string) => {
} }
resolve({ stdout, stderr }); resolve({ stdout, stderr });
}); });
p.stdout?.pipe(process.stdout); p.stdout.pipe(process.stdout);
p.stderr?.pipe(process.stderr); p.stderr.pipe(process.stderr);
}); });
}; };
@ -57,24 +58,31 @@ const getUpstreams = async () => {
const repoYaml = fs.readFileSync(__dirname + '/../repo.yml', 'utf8'); const repoYaml = fs.readFileSync(__dirname + '/../repo.yml', 'utf8');
const yaml = await import('js-yaml'); const yaml = await import('js-yaml');
const { upstream } = yaml.load(repoYaml) as { const { upstream } = yaml.safeLoad(repoYaml) as {
upstream: Upstream[]; upstream: Upstream[];
}; };
return upstream; return upstream;
}; };
const getUsage = (upstreams: Upstream[], upstreamName: string) => ` const printUsage = (upstreams: Upstream[], upstreamName: string) => {
console.error(
`
Usage: npm run update ${upstreamName} $version [$changeType=minor] Usage: npm run update ${upstreamName} $version [$changeType=minor]
Upstream names: ${upstreams.map(({ repo }) => repo).join(', ')} Upstream names: ${upstreams.map(({ repo }) => repo).join(', ')}
`; `,
);
return process.exit(1);
};
async function $main() { // TODO: Drop the wrapper function once we move to TS 3.8,
// which will support top level await.
async function main() {
const upstreams = await getUpstreams(); const upstreams = await getUpstreams();
if (process.argv.length < 3) { if (process.argv.length < 3) {
throw new Error(getUsage(upstreams, '$upstreamName')); return printUsage(upstreams, '$upstreamName');
} }
const upstreamName = process.argv[2]; const upstreamName = process.argv[2];
@ -82,15 +90,16 @@ async function $main() {
const upstream = upstreams.find((v) => v.repo === upstreamName); const upstream = upstreams.find((v) => v.repo === upstreamName);
if (!upstream) { if (!upstream) {
throw new Error( console.error(
`Invalid upstream name '${upstreamName}', valid options: ${upstreams `Invalid upstream name '${upstreamName}', valid options: ${upstreams
.map(({ repo }) => repo) .map(({ repo }) => repo)
.join(', ')}`, .join(', ')}`,
); );
return process.exit(1);
} }
if (process.argv.length < 4) { if (process.argv.length < 4) {
throw new Error(getUsage(upstreams, upstreamName)); printUsage(upstreams, upstreamName);
} }
const packageName = upstream.module || upstream.repo; const packageName = upstream.module || upstream.repo;
@ -99,7 +108,8 @@ async function $main() {
await run(`npm install ${packageName}@${process.argv[3]}`); await run(`npm install ${packageName}@${process.argv[3]}`);
const newVersion = await getVersion(packageName); const newVersion = await getVersion(packageName);
if (newVersion === oldVersion) { if (newVersion === oldVersion) {
throw new Error(`Already on version '${newVersion}'`); console.error(`Already on version '${newVersion}'`);
return process.exit(1);
} }
console.log(`Updated ${upstreamName} from ${oldVersion} to ${newVersion}`); console.log(`Updated ${upstreamName} from ${oldVersion} to ${newVersion}`);
@ -107,11 +117,11 @@ async function $main() {
const changeType = process.argv[4] const changeType = process.argv[4]
? // if the caller specified a change type, use that one ? // if the caller specified a change type, use that one
validateChangeType(process.argv[4]) validateChangeType(process.argv[4])
: // use the same change type as in the dependency, but avoid major bumps : // use the same change type as in the dependency, but avoid major bumps
semverChangeType && semverChangeType !== 'major' semverChangeType && semverChangeType !== 'major'
? semverChangeType ? semverChangeType
: 'minor'; : 'minor';
console.log(`Using Change-type: ${changeType}`); console.log(`Using Change-type: ${changeType}`);
let { stdout: currentBranch } = await run('git rev-parse --abbrev-ref HEAD'); let { stdout: currentBranch } = await run('git rev-parse --abbrev-ref HEAD');
@ -127,13 +137,4 @@ async function $main() {
); );
} }
async function main() { main();
try {
await $main();
} catch (e) {
console.error(e);
process.exitCode = 1;
}
}
void main();

View File

@ -16,11 +16,11 @@
*/ */
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import * as _ from 'lodash';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs'; import * as shellEscape from 'shell-escape';
import { diffTrimmedLines } from 'diff';
import * as whichMod from 'which';
export const MSYS2_BASH = 'C:\\msys64\\usr\\bin\\bash.exe';
export const ROOT = path.join(__dirname, '..'); export const ROOT = path.join(__dirname, '..');
/** Tap and buffer this process' stdout and stderr */ /** Tap and buffer this process' stdout and stderr */
@ -67,6 +67,7 @@ export class StdOutTap {
* https://www.npmjs.com/package/diff * https://www.npmjs.com/package/diff
*/ */
export function diffLines(str1: string, str2: string): string { export function diffLines(str1: string, str2: string): string {
const { diffTrimmedLines } = require('diff');
const diffObjs = diffTrimmedLines(str1, str2); const diffObjs = diffTrimmedLines(str1, str2);
const prefix = (chunk: string, char: string) => const prefix = (chunk: string, char: string) =>
chunk chunk
@ -78,18 +79,102 @@ export function diffLines(str1: string, str2: string): string {
return part.added return part.added
? prefix(part.value, '+') ? prefix(part.value, '+')
: part.removed : part.removed
? prefix(part.value, '-') ? prefix(part.value, '-')
: prefix(part.value, ' '); : prefix(part.value, ' ');
}) })
.join('\n'); .join('\n');
return diffStr; return diffStr;
} }
export function loadPackageJson() { export function loadPackageJson() {
const packageJsonPath = path.join(ROOT, 'package.json'); return require(path.join(ROOT, 'package.json'));
}
const packageJson = fs.readFileSync(packageJsonPath, 'utf8'); /**
return JSON.parse(packageJson); * Convert e.g. 'C:\myfolder' -> '/C/myfolder' so that the path can be given
* as argument to "unix tools" like 'tar' under MSYS or MSYS2 on Windows.
*/
export function fixPathForMsys(p: string): string {
return p.replace(/\\/g, '/').replace(/^([a-zA-Z]):/, '/$1');
}
/**
* Run the MSYS2 bash.exe shell in a child process (child_process.spawn()).
* The given argv arguments are escaped using the 'shell-escape' package,
* so that backslashes in Windows paths, and other bash-special characters,
* are preserved. If argv is not provided, defaults to process.argv, to the
* effect that this current (parent) process is re-executed under MSYS2 bash.
* This is useful to change the default shell from cmd.exe to MSYS2 bash on
* Windows.
* @param argv Arguments to be shell-escaped and given to MSYS2 bash.exe.
*/
export async function runUnderMsys(argv?: string[]) {
const newArgv = argv || process.argv;
await new Promise((resolve, reject) => {
const args = ['-lc', shellEscape(newArgv)];
const child = spawn(MSYS2_BASH, args, { stdio: 'inherit' });
child.on('close', (code) => {
if (code) {
console.log(`runUnderMsys: child process exited with code ${code}`);
reject(code);
} else {
resolve();
}
});
});
}
/**
* Run the executable at execPath as a child process, and resolve a promise
* to the executable's stdout output as a string. Reject the promise if
* anything is printed to stderr, or if the child process exits with a
* non-zero exit code.
* @param execPath Executable path
* @param args Command-line argument for the executable
*/
export async function getSubprocessStdout(
execPath: string,
args: string[],
): Promise<string> {
const child = spawn(execPath, args);
return new Promise((resolve, reject) => {
let stdout = '';
child.stdout.on('error', reject);
child.stderr.on('error', reject);
child.stdout.on('data', (data: Buffer) => {
try {
stdout = data.toString();
} catch (err) {
reject(err);
}
});
child.stderr.on('data', (data: Buffer) => {
try {
const stderr = data.toString();
// ignore any debug lines, but ensure that we parse
// every line provided to the stderr stream
const lines = _.filter(
stderr.trim().split(/\r?\n/),
(line) => !line.startsWith('[debug]'),
);
if (lines.length > 0) {
reject(
new Error(`"${execPath}": non-empty stderr "${lines.join('\n')}"`),
);
}
} catch (err) {
reject(err);
}
});
child.on('exit', (code: number) => {
if (code) {
reject(new Error(`"${execPath}": non-zero exit code "${code}"`));
} else {
resolve(stdout);
}
});
});
} }
/** /**
@ -102,6 +187,7 @@ export function loadPackageJson() {
* @returns The program's full path, e.g. 'C:\WINDOWS\System32\OpenSSH\ssh.EXE' * @returns The program's full path, e.g. 'C:\WINDOWS\System32\OpenSSH\ssh.EXE'
*/ */
export async function which(program: string): Promise<string> { export async function which(program: string): Promise<string> {
const whichMod = await import('which');
let programPath: string; let programPath: string;
try { try {
programPath = await whichMod(program); programPath = await whichMod(program);
@ -120,7 +206,7 @@ export async function which(program: string): Promise<string> {
*/ */
export async function whichSpawn( export async function whichSpawn(
programName: string, programName: string,
args: string[] = [], args?: string[],
): Promise<void> { ): Promise<void> {
const program = await which(programName); const program = await which(programName);
let error: Error | undefined; let error: Error | undefined;
@ -132,7 +218,7 @@ export async function whichSpawn(
.on('error', reject) .on('error', reject)
.on('close', resolve); .on('close', resolve);
} catch (err) { } catch (err) {
reject(err as Error); reject(err);
} }
}); });
} catch (err) { } catch (err) {

73
balena-completion.bash Normal file
View File

@ -0,0 +1,73 @@
#!/bin/bash
_balena_complete()
{
local cur prev
# Valid top-level completions
commands="app apps build config deploy device devices env envs help key \
keys local login logout logs note os preload quickstart settings \
scan ssh util version whoami"
# Sub-completions
app_cmds="create restart rm"
config_cmds="generate inject read reconfigure write"
device_cmds="identify init move public-url reboot register rename rm \
shutdown"
device_public_url_cmds="disable enable status"
env_cmds="add rename rm"
key_cmds="add rm"
local_cmds="configure flash"
os_cmds="build-config configure download initialize versions"
util_cmds="available-drives"
COMPREPLY=()
cur=${COMP_WORDS[COMP_CWORD]}
prev=${COMP_WORDS[COMP_CWORD-1]}
if [ $COMP_CWORD -eq 1 ]
then
COMPREPLY=( $(compgen -W "${commands}" -- $cur) )
elif [ $COMP_CWORD -eq 2 ]
then
case "$prev" in
"app")
COMPREPLY=( $(compgen -W "$app_cmds" -- $cur) )
;;
"config")
COMPREPLY=( $(compgen -W "$config_cmds" -- $cur) )
;;
"device")
COMPREPLY=( $(compgen -W "$device_cmds" -- $cur) )
;;
"env")
COMPREPLY=( $(compgen -W "$env_cmds" -- $cur) )
;;
"key")
COMPREPLY=( $(compgen -W "$key_cmds" -- $cur) )
;;
"local")
COMPREPLY=( $(compgen -W "$local_cmds" -- $cur) )
;;
"os")
COMPREPLY=( $(compgen -W "$os_cmds" -- $cur) )
;;
"util")
COMPREPLY=( $(compgen -W "$util_cmds" -- $cur) )
;;
"*")
;;
esac
elif [ $COMP_CWORD -eq 3 ]
then
case "$prev" in
"public-url")
COMPREPLY=( $(compgen -W "$device_public_url_cmds" -- $cur) )
;;
"*")
;;
esac
fi
}
complete -F _balena_complete balena

View File

@ -1 +0,0 @@
run.js

22
bin/balena Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env node
// tslint:disable:no-var-requires
// We boost the threadpool size as ext2fs can deadlock with some
// operations otherwise, if the pool runs out.
process.env.UV_THREADPOOL_SIZE = '64';
// Disable oclif registering ts-node
process.env.OCLIF_TS_NODE = 0;
// Use fast-boot to cache require lookups, speeding up startup
require('fast-boot2').start({
cacheScope: __dirname + '/..',
cacheFile: __dirname + '/.fast-boot.json',
});
// Set the desired es version for downstream modules that support it
require('@balena/es-version').set('es2018');
// Run the CLI
require('../build/app').run();

View File

@ -1 +0,0 @@
dev.js

74
bin/balena-dev Executable file
View File

@ -0,0 +1,74 @@
#!/usr/bin/env node
// ****************************************************************************
// THIS IS FOR DEV PURPOSES ONLY AND WILL NOT BE PART OF THE PUBLISHED PACKAGE
// Before opening a PR you should build and test your changes using bin/balena
// ****************************************************************************
// tslint:disable:no-var-requires
// We boost the threadpool size as ext2fs can deadlock with some
// operations otherwise, if the pool runs out.
process.env.UV_THREADPOOL_SIZE = '64';
const path = require('path');
const rootDir = path.join(__dirname, '..');
// Allow balena-dev to work with oclif by temporarily
// pointing oclif config options to lib/ instead of build/
modifyOclifPaths();
// Undo changes on exit
process.on('exit', function () {
modifyOclifPaths(true);
});
// Undo changes in case of ctrl-v
process.on('SIGINT', function () {
modifyOclifPaths(true);
});
// Use fast-boot to cache require lookups, speeding up startup
require('fast-boot2').start({
cacheScope: __dirname + '/..',
cacheFile: '.fast-boot.json',
});
// Set the desired es version for downstream modules that support it
require('@balena/es-version').set('es2018');
// Note: before ts-node v6.0.0, 'transpile-only' (no type checking) was the
// default option. We upgraded ts-node and found that adding 'transpile-only'
// was necessary to avoid a mysterious 'null' error message. On the plus side,
// it is supposed to run faster. We still benefit from type checking when
// running 'npm run build'.
require('ts-node').register({
project: path.join(rootDir, 'tsconfig.json'),
transpileOnly: true,
});
require('../lib/app').run();
// Modify package.json oclif paths from build/ -> lib/, or vice versa
function modifyOclifPaths(revert) {
const fs = require('fs');
const packageJsonPath = path.join(rootDir, 'package.json');
const packageJson = fs.readFileSync(packageJsonPath, 'utf8');
const packageObj = JSON.parse(packageJson);
if (!packageObj.oclif) {
return;
}
let oclifSectionText = JSON.stringify(packageObj.oclif);
if (!revert) {
oclifSectionText = oclifSectionText.replace(/\/build\//g, '/lib/');
} else {
oclifSectionText = oclifSectionText.replace(/\/lib\//g, '/build/');
}
packageObj.oclif = JSON.parse(oclifSectionText);
fs.writeFileSync(
packageJsonPath,
`${JSON.stringify(packageObj, null, 2)}\n`,
'utf8',
);
}

View File

@ -1,3 +0,0 @@
@echo off
node "%~dp0\run" %*

View File

@ -1,90 +0,0 @@
#!/usr/bin/env node
// ****************************************************************************
// THIS IS FOR DEV PURPOSES ONLY AND WILL NOT BE PART OF THE PUBLISHED PACKAGE
// Before opening a PR you should build and test your changes using bin/balena
// ****************************************************************************
// We boost the threadpool size as ext2fs can deadlock with some
// operations otherwise, if the pool runs out.
process.env.UV_THREADPOOL_SIZE = '64';
// Note on `fast-boot2`: We do not use `fast-boot2` with `balena-dev` because:
// * fast-boot2's cacheKiller option is configured to include the timestamps of
// the package.json and npm-shrinkwrap.json files, to avoid unexpected CLI
// behavior when changes are made to dependencies during development. This is
// generally a good thing, however, `balena-dev` (a few lines below) edits
// `package.json` to modify oclif paths, and this results in cache
// invalidation and a performance hit rather than speedup.
// * Even if the timestamps are removed from cacheKiller, so that there is no
// cache invalidation, fast-boot's speedup is barely noticeable when ts-node
// is used, e.g. 1.43s vs 1.4s when running `balena version`.
// * `fast-boot` causes unexpected behavior when used with `npm link` or
// when the `node_modules` folder is manually modified (affecting transitive
// dependencies) during development (e.g. bug investigations). A workaround
// is to use `balena-dev` without `fast-boot`. See also notes in
// `CONTRIBUTING.md`.
const path = require('path');
const rootDir = path.join(__dirname, '..');
// Allow balena-dev to work with oclif by temporarily
// pointing oclif config options to src/ instead of build/
modifyOclifPaths();
// Undo changes on exit
process.on('exit', function () {
modifyOclifPaths(true);
});
// Undo changes in case of ctrl-c
process.on('SIGINT', function () {
modifyOclifPaths(true);
// Note process exit here will interfere with commands that do their own SIGINT handling,
// but without it commands can not be exited.
// So currently using balena-dev does not guarantee proper exit behaviour when using ctrl-c.
// Ideally a better solution is needed.
process.exit();
});
// Set the desired es version for downstream modules that support it
require('@balena/es-version').set('es2018');
// Note: before ts-node v6.0.0, 'transpile-only' (no type checking) was the
// default option. We upgraded ts-node and found that adding 'transpile-only'
// was necessary to avoid a mysterious 'null' error message. On the plus side,
// it is supposed to run faster. We still benefit from type checking when
// running 'npm run build'.
require('ts-node').register({
project: path.join(rootDir, 'tsconfig.json'),
transpileOnly: true,
});
void require('../src/app').run(undefined, {
dir: __dirname,
development: true,
});
// Modify package.json oclif paths from build/ -> src/, or vice versa
function modifyOclifPaths(revert) {
const fs = require('fs');
const packageJsonPath = path.join(rootDir, 'package.json');
const packageJson = fs.readFileSync(packageJsonPath, 'utf8');
const packageObj = JSON.parse(packageJson);
if (!packageObj.oclif) {
return;
}
let oclifSectionText = JSON.stringify(packageObj.oclif);
if (!revert) {
oclifSectionText = oclifSectionText.replace(/\/build\//g, '/src/');
} else {
oclifSectionText = oclifSectionText.replace(/\/src\//g, '/build/');
}
packageObj.oclif = JSON.parse(oclifSectionText);
fs.writeFileSync(
packageJsonPath,
`${JSON.stringify(packageObj, null, 2)}\n`,
'utf8',
);
}

View File

@ -1,3 +0,0 @@
@echo off
node "%~dp0\run" %*

View File

@ -1,21 +0,0 @@
#!/usr/bin/env node
// We boost the threadpool size as ext2fs can deadlock with some
// operations otherwise, if the pool runs out.
process.env.UV_THREADPOOL_SIZE = '64';
// Disable oclif registering ts-node
process.env.OCLIF_TS_NODE = '0';
async function run() {
// Use fast-boot to cache require lookups, speeding up startup
await require('../build/fast-boot').start();
// Set the desired es version for downstream modules that support it
require('@balena/es-version').set('es2018');
// Run the CLI
await require('../build/app').run(undefined, { dir: __dirname });
}
void run();

View File

@ -1,95 +0,0 @@
#compdef balena
#autoload
#GENERATED FILE DON'T MODIFY#
_balena() {
typeset -A opt_args
local context state line curcontext="$curcontext"
# Valid top-level completions
main_commands=( api-key app block build config deploy device device-type env fleet internal join leave local login logout organization os preload push release settings ssh-key support tag util version whoami )
# Sub-completions
api_key_cmds=( generate list revoke )
app_cmds=( create )
block_cmds=( create )
config_cmds=( generate inject read reconfigure write )
device_type_cmds=( list )
device_cmds=( deactivate detect identify init list local-mode logs move note os-update pin public-url purge reboot register rename restart rm shutdown ssh start-service stop-service track-fleet tunnel )
env_cmds=( list rename rm set )
fleet_cmds=( create list pin purge rename restart rm track-latest )
internal_cmds=( osinit )
local_cmds=( configure flash )
organization_cmds=( list )
os_cmds=( build-config configure download initialize versions )
release_cmds=( finalize invalidate list validate )
ssh_key_cmds=( add list rm )
tag_cmds=( list rm set )
_arguments -C \
'(- 1 *)--version[show version and exit]' \
'(- 1 *)--help[show help options and exit]' \
'1:first command:_balena_main_cmds' \
'2:second command:_balena_sec_cmds' \
&& ret=0
}
(( $+functions[_balena_main_cmds] )) ||
_balena_main_cmds() {
_describe -t main_commands 'command' main_commands "$@" && ret=0
}
(( $+functions[_balena_sec_cmds] )) ||
_balena_sec_cmds() {
case $line[1] in
"api-key")
_describe -t api_key_cmds 'api-key_cmd' api_key_cmds "$@" && ret=0
;;
"app")
_describe -t app_cmds 'app_cmd' app_cmds "$@" && ret=0
;;
"block")
_describe -t block_cmds 'block_cmd' block_cmds "$@" && ret=0
;;
"config")
_describe -t config_cmds 'config_cmd' config_cmds "$@" && ret=0
;;
"device-type")
_describe -t device_type_cmds 'device-type_cmd' device_type_cmds "$@" && ret=0
;;
"device")
_describe -t device_cmds 'device_cmd' device_cmds "$@" && ret=0
;;
"env")
_describe -t env_cmds 'env_cmd' env_cmds "$@" && ret=0
;;
"fleet")
_describe -t fleet_cmds 'fleet_cmd' fleet_cmds "$@" && ret=0
;;
"internal")
_describe -t internal_cmds 'internal_cmd' internal_cmds "$@" && ret=0
;;
"local")
_describe -t local_cmds 'local_cmd' local_cmds "$@" && ret=0
;;
"organization")
_describe -t organization_cmds 'organization_cmd' organization_cmds "$@" && ret=0
;;
"os")
_describe -t os_cmds 'os_cmd' os_cmds "$@" && ret=0
;;
"release")
_describe -t release_cmds 'release_cmd' release_cmds "$@" && ret=0
;;
"ssh-key")
_describe -t ssh_key_cmds 'ssh-key_cmd' ssh_key_cmds "$@" && ret=0
;;
"tag")
_describe -t tag_cmds 'tag_cmd' tag_cmds "$@" && ret=0
;;
esac
}
_balena "$@"

View File

@ -1,92 +0,0 @@
#!/bin/bash
#GENERATED FILE DON'T MODIFY#
_balena_complete()
{
local cur prev
# Valid top-level completions
main_commands="api-key app block build config deploy device device-type env fleet internal join leave local login logout organization os preload push release settings ssh-key support tag util version whoami"
# Sub-completions
api_key_cmds="generate list revoke"
app_cmds="create"
block_cmds="create"
config_cmds="generate inject read reconfigure write"
device_type_cmds="list"
device_cmds="deactivate detect identify init list local-mode logs move note os-update pin public-url purge reboot register rename restart rm shutdown ssh start-service stop-service track-fleet tunnel"
env_cmds="list rename rm set"
fleet_cmds="create list pin purge rename restart rm track-latest"
internal_cmds="osinit"
local_cmds="configure flash"
organization_cmds="list"
os_cmds="build-config configure download initialize versions"
release_cmds="finalize invalidate list validate"
ssh_key_cmds="add list rm"
tag_cmds="list rm set"
COMPREPLY=()
cur=${COMP_WORDS[COMP_CWORD]}
prev=${COMP_WORDS[COMP_CWORD-1]}
if [ $COMP_CWORD -eq 1 ]
then
COMPREPLY=( $(compgen -W "${main_commands}" -- $cur) )
elif [ $COMP_CWORD -eq 2 ]
then
case "$prev" in
api-key)
COMPREPLY=( $(compgen -W "$api_key_cmds" -- $cur) )
;;
app)
COMPREPLY=( $(compgen -W "$app_cmds" -- $cur) )
;;
block)
COMPREPLY=( $(compgen -W "$block_cmds" -- $cur) )
;;
config)
COMPREPLY=( $(compgen -W "$config_cmds" -- $cur) )
;;
device-type)
COMPREPLY=( $(compgen -W "$device_type_cmds" -- $cur) )
;;
device)
COMPREPLY=( $(compgen -W "$device_cmds" -- $cur) )
;;
env)
COMPREPLY=( $(compgen -W "$env_cmds" -- $cur) )
;;
fleet)
COMPREPLY=( $(compgen -W "$fleet_cmds" -- $cur) )
;;
internal)
COMPREPLY=( $(compgen -W "$internal_cmds" -- $cur) )
;;
local)
COMPREPLY=( $(compgen -W "$local_cmds" -- $cur) )
;;
organization)
COMPREPLY=( $(compgen -W "$organization_cmds" -- $cur) )
;;
os)
COMPREPLY=( $(compgen -W "$os_cmds" -- $cur) )
;;
release)
COMPREPLY=( $(compgen -W "$release_cmds" -- $cur) )
;;
ssh-key)
COMPREPLY=( $(compgen -W "$ssh_key_cmds" -- $cur) )
;;
tag)
COMPREPLY=( $(compgen -W "$tag_cmds" -- $cur) )
;;
"*")
;;
esac
fi
}
complete -F _balena_complete balena

View File

@ -1,175 +0,0 @@
/**
* @license
* Copyright 2021 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.
*/
const path = require('path');
const rootDir = path.join(__dirname, '..');
const fs = require('fs');
const manifestFile = 'oclif.manifest.json';
commandsFilePath = path.join(rootDir, manifestFile);
if (fs.existsSync(commandsFilePath)) {
console.log('Generating shell auto completion files...');
} else {
console.error(`generate-completion.js: Could not find "${manifestFile}"`);
process.exitCode = 1;
return;
}
const commandsJson = JSON.parse(fs.readFileSync(commandsFilePath, 'utf8'));
const mainCommands = [];
const additionalCommands = [];
for (const key of Object.keys(commandsJson.commands).sort()) {
const cmd = key.split(':');
if (cmd.length > 1) {
additionalCommands.push(cmd);
if (!mainCommands.includes(cmd[0])) {
mainCommands.push(cmd[0]);
}
} else {
mainCommands.push(cmd[0]);
}
}
const mainCommandsStr = mainCommands.join(' ');
// GENERATE BASH COMPLETION FILE
bashFilePathIn = path.join(__dirname, '/templates/bash.template');
bashFilePathOut = path.join(__dirname, 'balena-completion.bash');
try {
fs.unlinkSync(bashFilePathOut);
} catch (error) {
process.exitCode = 1;
return console.error(error);
}
fs.readFile(bashFilePathIn, 'utf8', function (err, data) {
if (err) {
process.exitCode = 1;
return console.error(err);
}
data = data.replace(
'#TEMPLATE FILE FOR BASH COMPLETION#',
"#GENERATED FILE DON'T MODIFY#",
);
data = data.replace(
/\$main_commands\$/g,
'main_commands="' + mainCommandsStr + '"',
);
let subCommands = [];
let prevElement = additionalCommands[0][0];
additionalCommands.forEach(function (element) {
if (element[0] === prevElement) {
subCommands.push(element[1]);
} else {
const prevElement2 = prevElement.replace(/-/g, '_') + '_cmds';
data = data.replace(
/\$sub_cmds\$/g,
' ' + prevElement2 + '="' + subCommands.join(' ') + '"\n$sub_cmds$',
);
data = data.replace(
/\$sub_cmds_prev\$/g,
' ' +
prevElement +
')\n COMPREPLY=( $(compgen -W "$' +
prevElement2 +
'" -- $cur) )\n ;;\n$sub_cmds_prev$',
);
prevElement = element[0];
subCommands = [];
subCommands.push(element[1]);
}
});
// cleanup placeholders
data = data.replace(/\$sub_cmds\$/g, '');
data = data.replace(/\$sub_cmds_prev\$/g, '');
fs.writeFile(bashFilePathOut, data, 'utf8', function (error) {
if (error) {
process.exitCode = 1;
return console.error(error);
}
});
});
// GENERATE ZSH COMPLETION FILE
zshFilePathIn = path.join(__dirname, '/templates/zsh.template');
zshFilePathOut = path.join(__dirname, '_balena');
try {
fs.unlinkSync(zshFilePathOut);
} catch (error) {
process.exitCode = 1;
return console.error(error);
}
fs.readFile(zshFilePathIn, 'utf8', function (err, data) {
if (err) {
process.exitCode = 1;
return console.error(err);
}
data = data.replace(
'#TEMPLATE FILE FOR ZSH COMPLETION#',
"#GENERATED FILE DON'T MODIFY#",
);
data = data.replace(
/\$main_commands\$/g,
'main_commands=( ' + mainCommandsStr + ' )',
);
let subCommands = [];
let prevElement = additionalCommands[0][0];
additionalCommands.forEach(function (element) {
if (element[0] === prevElement) {
subCommands.push(element[1]);
} else {
const prevElement2 = prevElement.replace(/-/g, '_') + '_cmds';
data = data.replace(
/\$sub_cmds\$/g,
' ' + prevElement2 + '=( ' + subCommands.join(' ') + ' )\n$sub_cmds$',
);
data = data.replace(
/\$sub_cmds_prev\$/g,
' "' +
prevElement +
'")\n _describe -t ' +
prevElement2 +
" '" +
prevElement +
"_cmd' " +
prevElement2 +
' "$@" && ret=0\n ;;\n$sub_cmds_prev$',
);
prevElement = element[0];
subCommands = [];
subCommands.push(element[1]);
}
});
// cleanup placeholders
data = data.replace(/\$sub_cmds\$/g, '');
data = data.replace(/\$sub_cmds_prev\$/g, '');
fs.writeFile(zshFilePathOut, data, 'utf8', function (error) {
if (error) {
process.exitCode = 1;
return console.error(error);
}
});
});

View File

@ -1,32 +0,0 @@
#!/bin/bash
#TEMPLATE FILE FOR BASH COMPLETION#
_balena_complete()
{
local cur prev
# Valid top-level completions
$main_commands$
# Sub-completions
$sub_cmds$
COMPREPLY=()
cur=${COMP_WORDS[COMP_CWORD]}
prev=${COMP_WORDS[COMP_CWORD-1]}
if [ $COMP_CWORD -eq 1 ]
then
COMPREPLY=( $(compgen -W "${main_commands}" -- $cur) )
elif [ $COMP_CWORD -eq 2 ]
then
case "$prev" in
$sub_cmds_prev$
"*")
;;
esac
fi
}
complete -F _balena_complete balena

View File

@ -1,35 +0,0 @@
#compdef balena
#autoload
#TEMPLATE FILE FOR ZSH COMPLETION#
_balena() {
typeset -A opt_args
local context state line curcontext="$curcontext"
# Valid top-level completions
$main_commands$
# Sub-completions
$sub_cmds$
_arguments -C \
'(- 1 *)--version[show version and exit]' \
'(- 1 *)--help[show help options and exit]' \
'1:first command:_balena_main_cmds' \
'2:second command:_balena_sec_cmds' \
&& ret=0
}
(( $+functions[_balena_main_cmds] )) ||
_balena_main_cmds() {
_describe -t main_commands 'command' main_commands "$@" && ret=0
}
(( $+functions[_balena_sec_cmds] )) ||
_balena_sec_cmds() {
case $line[1] in
$sub_cmds_prev$
esac
}
_balena "$@"

112
doc/automated-init.md Normal file
View File

@ -0,0 +1,112 @@
# Provisioning balena devices in automated (non-interactive) mode
This document describes how to run the `device init` command in non-interactive mode.
It requires collecting some preliminary information _once_.
The final command to provision the device looks like this:
```bash
balena device init --app APP_ID --os-version OS_VERSION --drive DRIVE --config CONFIG_FILE --yes
```
You can run this command as many times as you need, putting the new medium (SD card / USB stick) each time.
But before you can run it you need to collect the parameters and build the configuration file. Keep reading to figure out how to do it.
## Collect all the required parameters.
1. `DEVICE_TYPE`. Run
```bash
balena devices supported
```
and find the _slug_ for your target device type, like _raspberrypi3_.
1. `APP_ID`. Create an application (`balena app create APP_NAME --type DEVICE_TYPE`) or find an existing one (`balena apps`) and notice its ID.
1. `OS_VERSION`. Run
```bash
balena os versions DEVICE_TYPE
```
and pick the version that you need, like _v2.0.6+rev1.prod_.
_Note_ that even though we support _semver ranges_ it's recommended to use the exact version when doing the automated provisioning as it
guarantees full compatibility between the steps.
1. `DRIVE`. Plug in your target medium (SD card or the USB stick, depending on your device type) and run
```bash
balena util available-drives
```
and get the drive name, like _/dev/sdb_ or _/dev/mmcblk0_.
The balena CLI will not display the system drives to protect you,
but still please check very carefully that you've picked the correct drive as it will be erased during the provisioning process.
Now we have all the parameters -- time to build the config file.
## Build the config file
Interactive device provisioning process often includes collecting some extra device configuration, like the networking mode and wifi credentials.
To skip this interactive step we need to buid this configuration once and save it to the JSON file for later reuse.
Let's say we will place it into the `CONFIG_FILE` path, like _./balena-os/raspberrypi3-config.json_.
We also need to put the OS image somewhere, let's call this path `OS_IMAGE_PATH`, it can be something like _./balena-os/raspberrypi3-v2.0.6+rev1.prod.img_.
1. First we need to download the OS image once. That's needed for building the config, and will speedup the subsequent operations as the downloaded OS image is placed into the local cache.
Run:
```bash
balena os download DEVICE_TYPE --output OS_IMAGE_PATH --version OS_VERSION
```
1. Now we're ready to build the config:
```bash
balena os build-config OS_IMAGE_PATH DEVICE_TYPE --output CONFIG_FILE
```
This will run you through the interactive configuration wizard and in the end save the generated config as `CONFIG_FILE`. You can then verify it's not empty:
```bash
cat CONFIG_FILE
```
## Done
Now you're ready to run the command in the beginning of this guide.
Please note again that all of these steps only need to be done once (unless you need to change something), and once all the parameters are collected the main init command can be run unchanged.
But there are still some nuances to cover, please read below.
## Nuances
### `sudo` password on *nix systems
In order to write the image to the raw device we need the root permissions, this is unavoidable.
To improve the security we only run the minimal subcommand with `sudo`.
This means that with the default setup you're interrupted closer to the end of the device init process to enter your sudo password for this subcommand to work.
There are several ways to eliminate it and make the process fully non-interactive.
#### Option 1: make passwordless sudo.
Obviously you shouldn't do that if the machine you're working on has access to any sensitive resources or information.
But if you're using a machine dedicated to balena provisioning this can be fine, and also the simplest thing to do.
#### Option 2: `NOPASSWD` directive
You can configure the `balena` CLI command to be sudo-runnable without the password. Check [this post](https://askubuntu.com/questions/159007/how-do-i-run-specific-sudo-commands-without-a-password) for an example.
### Extra initialization config
As of June 2017 all the supported devices should not require any other interactive configuration.
But by the design of our system it is _possible_ (though it doesn't look very likely it's going to happen any time soon) that some extra initialization options may be requested for the specific device types.
If that is the case please raise the issue in the balena CLI repository and the maintainers will add the necessary options to build the similar JSON config for this step.

3027
doc/cli.markdown Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,32 +0,0 @@
const { FlatCompat } = require('@eslint/eslintrc');
const compat = new FlatCompat({
baseDirectory: __dirname,
});
module.exports = [
...require('@balena/lint/config/eslint.config'),
...compat.config({
parserOptions: {
project: 'tsconfig.dev.json',
},
ignorePatterns: ['**/generate-completion.js', '**/bin/**/*'],
rules: {
ignoreDefinitionFiles: 0,
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-shadow': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
'no-restricted-imports': ['error', {
paths: ['resin-cli-visuals', 'chalk', 'common-tags', 'resin-cli-form'],
}],
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
}],
},
}),
];

15
gulpfile.js Normal file
View File

@ -0,0 +1,15 @@
const gulp = require('gulp');
const inlinesource = require('gulp-inline-source');
const OPTIONS = {
files: {
pages: 'lib/auth/pages/*.ejs',
},
};
gulp.task('pages', () =>
gulp
.src(OPTIONS.files.pages)
.pipe(inlinesource())
.pipe(gulp.dest('build/auth/pages')),
);

View File

@ -16,15 +16,8 @@
*/ */
import * as packageJSON from '../package.json'; import * as packageJSON from '../package.json';
import type { AppOptions } from './preparser';
import {
checkDeletedCommand,
preparseArgs,
unsupportedFlag,
} from './preparser';
import { CliSettings } from './utils/bootstrap'; import { CliSettings } from './utils/bootstrap';
import { onceAsync } from './utils/lazy'; import { onceAsync, stripIndent } from './utils/lazy';
import { run as mainRun, settings } from '@oclif/core';
/** /**
* Sentry.io setup * Sentry.io setup
@ -34,7 +27,6 @@ export const setupSentry = onceAsync(async () => {
const config = await import('./config'); const config = await import('./config');
const Sentry = await import('@sentry/node'); const Sentry = await import('@sentry/node');
Sentry.init({ Sentry.init({
autoSessionTracking: false,
dsn: config.sentryDsn, dsn: config.sentryDsn,
release: packageJSON.version, release: packageJSON.version,
}); });
@ -51,8 +43,13 @@ export const setupSentry = onceAsync(async () => {
async function checkNodeVersion() { async function checkNodeVersion() {
const validNodeVersions = packageJSON.engines.node; const validNodeVersions = packageJSON.engines.node;
if (!(await import('semver')).satisfies(process.version, validNodeVersions)) { if (!(await import('semver')).satisfies(process.version, validNodeVersions)) {
const { getNodeEngineVersionWarn } = await import('./utils/messages'); console.warn(stripIndent`
console.warn(getNodeEngineVersionWarn(process.version, validNodeVersions)); ------------------------------------------------------------------------------
Warning: Node version "${process.version}" does not match required versions "${validNodeVersions}".
This may cause unexpected behavior. To upgrade Node, visit:
https://nodejs.org/en/download/
------------------------------------------------------------------------------
`);
} }
} }
@ -78,13 +75,11 @@ export function setMaxListeners(maxListeners: number) {
/** Selected CLI initialization steps */ /** Selected CLI initialization steps */
async function init() { async function init() {
if (process.env.BALENARC_NO_SENTRY) { if (process.env.BALENARC_NO_SENTRY) {
if (process.env.DEBUG) { console.error(`WARN: disabling Sentry.io error reporting`);
console.error(`WARN: disabling Sentry.io error reporting`);
}
} else { } else {
await setupSentry(); await setupSentry();
} }
await checkNodeVersion(); checkNodeVersion();
const settings = new CliSettings(); const settings = new CliSettings();
@ -94,75 +89,43 @@ async function init() {
setupBalenaSdkSharedOptions(settings); setupBalenaSdkSharedOptions(settings);
// check for CLI updates once a day // check for CLI updates once a day
if (!process.env.BALENARC_OFFLINE_MODE) { (await import('./utils/update')).notify();
(await import('./utils/update')).notify();
}
} }
/** Execute the oclif parser and the CLI command. */ /** Execute the oclif parser and the CLI command. */
async function oclifRun(command: string[], options: AppOptions) { async function oclifRun(
let deprecationPromise: Promise<void> | undefined; command: string[],
// check and enforce the CLI's deprecation policy options: import('./preparser').AppOptions,
if (!(unsupportedFlag || process.env.BALENARC_UNSUPPORTED)) { ) {
const { DeprecationChecker } = await import('./deprecation'); const { CustomMain } = await import('./utils/oclif-utils');
const deprecationChecker = new DeprecationChecker(packageJSON.version); const runPromise = CustomMain.run(command).then(
// warnAndAbortIfDeprecated uses previously cached data only () => {
await deprecationChecker.warnAndAbortIfDeprecated(); if (!options.noFlush) {
// checkForNewReleasesIfNeeded may query the npm registry return require('@oclif/command/flush');
deprecationPromise = deprecationChecker.checkForNewReleasesIfNeeded();
}
const runPromise = (async function (shouldFlush: boolean) {
let isEEXIT = false;
try {
if (options.development) {
// In dev mode -> use ts-node and dev plugins
process.env.NODE_ENV = 'development';
settings.debug = true;
} }
// For posteriority: We can't use default oclif 'execute' as },
// We customize error handling and flushing (error) => {
await mainRun(command, options.loadOptions ?? options.dir); // oclif sometimes exits with ExitError code 0 (not an error)
} catch (error) {
// oclif sometimes exits with ExitError code EEXIT 0 (not an error),
// for example the `balena help` command.
// (Avoid `error instanceof ExitError` here for the reasons explained // (Avoid `error instanceof ExitError` here for the reasons explained
// in the CONTRIBUTING.md file regarding the `instanceof` operator.) // in the CONTRIBUTING.md file regarding the `instanceof` operator.)
if (error.oclif?.exit === 0) { if (error.oclif?.exit === 0) {
isEEXIT = true; return;
} else { } else {
throw error; throw error;
} }
} },
if (shouldFlush) { );
const { flush } = await import('@oclif/core'); const { trackPromise } = await import('./hooks/prerun/track');
await flush(); await Promise.all([trackPromise, runPromise]);
}
// TODO: figure out why we need to call fast-boot stop() here, in
// addition to calling it in the main `run()` function in this file.
// If it is not called here as well, there is a process exit delay of
// 1 second when the fast-boot2 cache is modified (1 second is the
// default cache saving timeout). Try for example `balena help`.
// I have found that, when oclif's `Error: EEXIT: 0` is caught in
// the try/catch block above, execution does not get past the
// Promise.all() call below, but I don't understand why.
if (isEEXIT) {
(await import('./fast-boot')).stop();
}
})(!options.noFlush);
const { trackPromise } = await import('./hooks/prerun');
await Promise.all([trackPromise, deprecationPromise, runPromise]);
} }
/** CLI entrypoint. Called by the `bin/run.js` and `bin/dev.js` scripts. */ /** CLI entrypoint. Called by the `bin/balena` and `bin/balena-dev` scripts. */
export async function run(cliArgs = process.argv, options: AppOptions) { export async function run(
cliArgs = process.argv,
options: import('./preparser').AppOptions = {},
) {
try { try {
const { setOfflineModeEnvVars, normalizeEnvVars, pkgExec } = await import( const { normalizeEnvVars, pkgExec } = await import('./utils/bootstrap');
'./utils/bootstrap'
);
setOfflineModeEnvVars();
normalizeEnvVars(); normalizeEnvVars();
// The 'pkgExec' special/internal command provides a Node.js interpreter // The 'pkgExec' special/internal command provides a Node.js interpreter
@ -173,6 +136,8 @@ export async function run(cliArgs = process.argv, options: AppOptions) {
await init(); await init();
const { preparseArgs, checkDeletedCommand } = await import('./preparser');
// Look for commands that have been removed and if so, exit with a notice // Look for commands that have been removed and if so, exit with a notice
checkDeletedCommand(cliArgs.slice(2)); checkDeletedCommand(cliArgs.slice(2));
@ -181,13 +146,6 @@ export async function run(cliArgs = process.argv, options: AppOptions) {
} catch (err) { } catch (err) {
await (await import('./errors')).handleError(err); await (await import('./errors')).handleError(err);
} finally { } finally {
try {
(await import('./fast-boot')).stop();
} catch (e) {
if (process.env.DEBUG) {
console.error(`[debug] Stopping fast-boot: ${e}`);
}
}
// Windows fix: reading from stdin prevents the process from exiting // Windows fix: reading from stdin prevents the process from exiting
process.stdin.pause(); process.stdin.pause();
} }

View File

@ -56,7 +56,7 @@ export async function login({ host = '127.0.0.1', port = 0 }) {
console.info(`Opening web browser for URL:\n${loginUrl}`); console.info(`Opening web browser for URL:\n${loginUrl}`);
const open = await import('open'); const open = await import('open');
await open(loginUrl, { wait: false }); open(loginUrl, { wait: false });
const balena = getBalenaSdk(); const balena = getBalenaSdk();
const token = await loginServer.awaitForToken(); const token = await loginServer.awaitForToken();

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -59,15 +59,14 @@ export class LoginServer extends EventEmitter {
app.set('views', path.join(__dirname, 'pages')); app.set('views', path.join(__dirname, 'pages'));
this.server = await new Promise<import('net').Server>((resolve, reject) => { this.server = await new Promise<import('net').Server>((resolve, reject) => {
const callback = (err: Error) => { const server = app.listen(port, host, (err: Error) => {
if (err) { if (err) {
this.emit('error', err); this.emit('error', err);
reject(err); reject(err);
} else { } else {
resolve(server); resolve(server);
} }
}; });
const server = app.listen(port, host, callback as any);
server.on('connection', (socket) => this.serverSockets.push(socket)); server.on('connection', (socket) => this.serverSockets.push(socket));
}); });

View File

@ -14,44 +14,57 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import * as _ from 'lodash';
import * as url from 'url';
import { getBalenaSdk } from '../utils/lazy'; import { getBalenaSdk } from '../utils/lazy';
/** /**
* Get dashboard CLI login URL * @summary Get dashboard CLI login URL
* @function
* @protected
* *
* @param callbackUrl - Callback url, e.g. 'http://127.0.0.1:3000' * @param {String} callbackUrl - callback url
* @returns Dashboard login URL, e.g.: * @fulfil {String} - dashboard login url
* 'https://dashboard.balena-cloud.com/login/cli/http%253A%252F%252F127.0.0.1%253A59581%252Fauth' * @returns {Promise}
*
* @example
* utils.getDashboardLoginURL('http://127.0.0.1:3000').then (url) ->
* console.log(url)
*/ */
export async function getDashboardLoginURL( export const getDashboardLoginURL = (callbackUrl: string) => {
callbackUrl: string,
): Promise<string> {
// Encode percentages signs from the escaped url // Encode percentages signs from the escaped url
// characters to avoid angular getting confused. // characters to avoid angular getting confused.
callbackUrl = encodeURIComponent(callbackUrl).replace(/%/g, '%25'); callbackUrl = encodeURIComponent(callbackUrl).replace(/%/g, '%25');
const [{ URL }, dashboardUrl] = await Promise.all([ return getBalenaSdk()
import('url'), .settings.get('dashboardUrl')
getBalenaSdk().settings.get('dashboardUrl'), .then((dashboardUrl) =>
]); url.resolve(dashboardUrl, `/login/cli/${callbackUrl}`),
return new URL(`/login/cli/${callbackUrl}`, dashboardUrl).href; );
} };
/** /**
* Log in using a token, but only if the token is valid. * @summary Log in using a token, but only if the token is valid
* @function
* @protected
* *
* @description
* This function checks that the token is not only well-structured * This function checks that the token is not only well-structured
* but that it also authenticates with the server successfully. * but that it also authenticates with the server successfully.
* *
* If authenticated, the token is persisted, if not then the previous * If authenticated, the token is persisted, if not then the previous
* login state is restored. * login state is restored.
* *
* @param token - session token or api key * @param {String} token - session token or api key
* @returns whether the login was successful or not * @fulfil {Boolean} - whether the login was successful or not
* @returns {Promise}
*
* utils.loginIfTokenValid('...').then (loggedIn) ->
* if loggedIn
* console.log('Token is valid!')
*/ */
export async function loginIfTokenValid(token?: string): Promise<boolean> { export const loginIfTokenValid = async (token: string): Promise<boolean> => {
token = (token || '').trim(); if (_.isEmpty(token?.trim())) {
if (!token) {
return false; return false;
} }
const balena = getBalenaSdk(); const balena = getBalenaSdk();
@ -73,4 +86,4 @@ export async function loginIfTokenValid(token?: string): Promise<boolean> {
} }
} }
return isLoggedIn; return isLoggedIn;
} };

132
lib/command.ts Normal file
View File

@ -0,0 +1,132 @@
/**
* @license
* Copyright 2020 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.
*/
import Command from '@oclif/command';
import { InsufficientPrivilegesError } from './errors';
export default abstract class BalenaCommand extends Command {
/**
* When set to true, command will be listed in `help`,
* otherwise listed in `help --verbose` with secondary commands.
*/
public static primary = false;
/**
* Require elevated privileges to run.
* When set to true, command will exit with an error
* if executed without root on Mac/Linux
* or if executed by non-Administrator on Windows.
*/
public static root = false;
/**
* Require authentication to run.
* When set to true, command will exit with an error
* if user is not already logged in.
*/
public static authenticated = false;
/**
* Accept piped input.
* When set to true, command will read from stdin during init
* and make contents available on member `stdin`.
*/
public static readStdin = false;
public stdin: string;
/**
* Throw InsufficientPrivilegesError if not root on Mac/Linux
* or non-Administrator on Windows.
*
* Called automatically if `root=true`.
* Can be called explicitly by command implementation, if e.g.:
* - check should only be done conditionally
* - other code needs to execute before check
*/
protected static async checkElevatedPrivileges() {
const isElevated = await (await import('is-elevated'))();
if (!isElevated) {
throw new InsufficientPrivilegesError(
'You need root/admin privileges to run this command',
);
}
}
/**
* Throw NotLoggedInError if not logged in.
*
* Called automatically if `authenticated=true`.
* Can be called explicitly by command implementation, if e.g.:
* - check should only be done conditionally
* - other code needs to execute before check
*
* Note, currently public to allow use outside of derived commands
* (as some command implementations require this. Can be made protected
* if this changes).
*
* @throws {NotLoggedInError}
*/
public static async checkLoggedIn() {
await (await import('./utils/patterns')).checkLoggedIn();
}
/**
* Throw NotLoggedInError if not logged in when condition true.
*
* @param {boolean} doCheck - will check if true.
* @throws {NotLoggedInError}
*/
public static async checkLoggedInIf(doCheck: boolean) {
if (doCheck) {
await this.checkLoggedIn();
}
}
/**
* Read stdin contents and make available to command.
*
* This approach could be improved in the future to automatically set argument
* values from stdin based in configuration, minimising command implementation.
*/
protected async getStdin() {
this.stdin = await (await import('get-stdin'))();
}
/**
* Get a logger instance.
*/
protected static async getLogger() {
return (await import('./utils/logger')).getLogger();
}
protected async init() {
const ctr = this.constructor as typeof BalenaCommand;
if (ctr.root) {
await BalenaCommand.checkElevatedPrivileges();
}
if (ctr.authenticated) {
await BalenaCommand.checkLoggedIn();
}
if (ctr.readStdin) {
await this.getStdin();
}
}
}

View File

@ -15,20 +15,18 @@
* limitations under the License. * limitations under the License.
*/ */
import { Args, Command } from '@oclif/core'; import { flags } from '@oclif/command';
import Command from '../../command';
import { ExpectedError } from '../../errors'; import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy'; import { getBalenaSdk, stripIndent } from '../../utils/lazy';
async function isLoggedInWithJwt() { interface FlagsDef {
const balena = getBalenaSdk(); help: void;
try { }
const token = await balena.auth.getToken();
const { default: jwtDecode } = await import('jwt-decode'); interface ArgsDef {
jwtDecode(token); name: string;
return true;
} catch {
return false;
}
} }
export default class GenerateCmd extends Command { export default class GenerateCmd extends Command {
@ -43,30 +41,30 @@ export default class GenerateCmd extends Command {
`; `;
public static examples = ['$ balena api-key generate "Jenkins Key"']; public static examples = ['$ balena api-key generate "Jenkins Key"'];
public static args = { public static args = [
name: Args.string({ {
name: 'name',
description: 'the API key name', description: 'the API key name',
required: true, required: true,
}), },
];
public static usage = 'api-key generate <name>';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
}; };
public static authenticated = true; public static authenticated = true;
public async run() { public async run() {
const { args: params } = await this.parse(GenerateCmd); const { args: params } = this.parse<FlagsDef, ArgsDef>(GenerateCmd);
let key; let key;
try { try {
key = await getBalenaSdk().models.apiKey.create(params.name); key = await getBalenaSdk().models.apiKey.create(params.name);
} catch (e) { } catch (e) {
if (e.name === 'BalenaNotLoggedIn') { if (e.name === 'BalenaNotLoggedIn') {
if (await isLoggedInWithJwt()) {
throw new ExpectedError(stripIndent`
This command requires you to have been recently authenticated.
Please login again with 'balena login'.
In case you are using the Web authorization method, you need to logout and re-login to the dashboard first.
`);
}
throw new ExpectedError(stripIndent` throw new ExpectedError(stripIndent`
This command cannot be run when logged in with an API key. This command cannot be run when logged in with an API key.
Please login again with 'balena login' and select an alternative method. Please login again with 'balena login' and select an alternative method.

104
lib/commands/app/create.ts Normal file
View File

@ -0,0 +1,104 @@
/**
* @license
* Copyright 2016-2020 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.
*/
import { flags } from '@oclif/command';
import Command from '../../command';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import type * as BalenaSDK from 'balena-sdk';
interface FlagsDef {
type?: string; // application device type
help: void;
}
interface ArgsDef {
name: string;
}
export default class AppCreateCmd extends Command {
public static description = stripIndent`
Create an application.
Create a new balena application.
You can specify the application device type with the \`--type\` option.
Otherwise, an interactive dropdown will be shown for you to select from.
You can see a list of supported device types with:
$ balena devices supported
`;
public static examples = [
'$ balena app create MyApp',
'$ balena app create MyApp --type raspberry-pi',
];
public static args = [
{
name: 'name',
description: 'application name',
required: true,
},
];
public static usage = 'app create <name>';
public static flags: flags.Input<FlagsDef> = {
type: flags.string({
char: 't',
description:
'application device type (Check available types with `balena devices supported`)',
}),
help: cf.help,
};
public static authenticated = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
AppCreateCmd,
);
const balena = getBalenaSdk();
// Create application
const deviceType =
options.type ||
(await (await import('../../utils/patterns')).selectDeviceType());
let application: BalenaSDK.Application;
try {
application = await balena.models.application.create({
name: params.name,
deviceType,
organization: (await balena.auth.whoami())!,
});
} catch (err) {
// BalenaRequestError: Request error: Unique key constraint violated
if ((err.message || '').toLowerCase().includes('unique')) {
throw new ExpectedError(
`Error: application "${params.name}" already exists`,
);
}
throw err;
}
console.info(
`Application created: ${application.slug} (${deviceType}, id ${application.id})`,
);
}
}

85
lib/commands/app/index.ts Normal file
View File

@ -0,0 +1,85 @@
/**
* @license
* Copyright 2016-2020 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.
*/
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import type { Release } from 'balena-sdk';
interface FlagsDef {
help: void;
}
interface ArgsDef {
name: string;
}
export default class AppCmd extends Command {
public static description = stripIndent`
Display information about a single application.
Display detailed information about a single balena application.
`;
public static examples = ['$ balena app MyApp'];
public static args = [
{
name: 'name',
description: 'application name or numeric ID',
required: true,
},
];
public static usage = 'app <name>';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
};
public static authenticated = true;
public static primary = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppCmd);
const { getApplication } = await import('../../utils/sdk');
const application = (await getApplication(getBalenaSdk(), params.name, {
$expand: {
is_for__device_type: { $select: 'slug' },
should_be_running__release: { $select: 'commit' },
},
})) as ApplicationWithDeviceType & {
should_be_running__release: [Release?];
};
// @ts-expect-error
application.device_type = application.is_for__device_type[0].slug;
// @ts-expect-error
application.commit = application.should_be_running__release[0]?.commit;
console.log(
getVisuals().table.vertical(application, [
`$${application.app_name}$`,
'id',
'device_type',
'slug',
'commit',
]),
);
}
}

View File

@ -15,47 +15,61 @@
* limitations under the License. * limitations under the License.
*/ */
import { Command } from '@oclif/core'; import { flags } from '@oclif/command';
import * as ca from '../../utils/common-args'; import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy'; import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { applicationIdInfo } from '../../utils/messages'; import { tryAsInteger } from '../../utils/validation';
export default class FleetPurgeCmd extends Command { interface FlagsDef {
help: void;
}
interface ArgsDef {
name: string;
}
export default class AppRestartCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
Purge data from a fleet. Purge data from an application.
Purge data from all devices belonging to a fleet. Purge data from all devices belonging to an application.
This will clear the fleet's '/data' directory. This will clear the application's /data directory.
`;
public static examples = ['$ balena app purge MyApp'];
${applicationIdInfo.split('\n').join('\n\t\t')} public static args = [
`; {
name: 'name',
public static examples = [ description: 'application name or numeric ID',
'$ balena fleet purge MyFleet', required: true,
'$ balena fleet purge myorg/myfleet', },
]; ];
public static args = { public static usage = 'app purge <name>';
fleet: ca.fleetRequired,
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
}; };
public static authenticated = true; public static authenticated = true;
public async run() { public async run() {
const { args: params } = await this.parse(FleetPurgeCmd); const { args: params } = this.parse<FlagsDef, ArgsDef>(AppRestartCmd);
const { getApplication } = await import('../../utils/sdk');
const balena = getBalenaSdk(); const balena = getBalenaSdk();
// balena.models.application.purge only accepts a numeric id // balena.models.application.purge only accepts a numeric id
// so we must first fetch the app to get it's id, // so we must first fetch the app to get it's id, if we have been given a name
const application = await getApplication(balena, params.fleet, { let nameOrId = tryAsInteger(params.name);
$select: 'id',
}); if (typeof nameOrId === 'string') {
const app = await balena.models.application.get(nameOrId);
nameOrId = app.id;
}
try { try {
await balena.models.application.purge(application.id); await balena.models.application.purge(nameOrId);
} catch (e) { } catch (e) {
if (e.message.toLowerCase().includes('no online device(s) found')) { if (e.message.toLowerCase().includes('no online device(s) found')) {
// application.purge throws an error if no devices are online // application.purge throws an error if no devices are online

136
lib/commands/app/rename.ts Normal file
View File

@ -0,0 +1,136 @@
/**
* @license
* Copyright 2016-2020 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.
*/
import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent, getCliForm } from '../../utils/lazy';
import type { Application, ApplicationType, BalenaSDK } from 'balena-sdk';
interface FlagsDef {
help: void;
}
interface ArgsDef {
name: string;
newName?: string;
}
export default class AppRenameCmd extends Command {
public static description = stripIndent`
Rename an application.
Rename an application.
Note, if the \`newName\` parameter is omitted, it will be
prompted for interactively.
`;
public static examples = [
'$ balena app rename OldName',
'$ balena app rename OldName NewName',
];
public static args: Array<IArg<any>> = [
{
name: 'name',
description: 'application name or numeric ID',
required: true,
},
{
name: 'newName',
description: 'the new name for the application',
},
];
public static usage = 'app rename <name> [newName]';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
};
public static authenticated = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppRenameCmd);
const { ExpectedError, instanceOf } = await import('../../errors');
const { getApplication } = await import('../../utils/sdk');
const balena = getBalenaSdk();
// Get app
let app;
try {
app = await getApplication(balena, params.name, {
$expand: {
application_type: {
$select: ['is_legacy'],
},
},
});
} catch (e) {
const { BalenaApplicationNotFound } = await import('balena-errors');
if (instanceOf(e, BalenaApplicationNotFound)) {
throw new ExpectedError(`Application ${params.name} not found.`);
} else {
throw e;
}
}
// Check app supports renaming
const appType = (app.application_type as ApplicationType[])?.[0];
if (appType.is_legacy) {
throw new ExpectedError(
`Application ${params.name} is of 'legacy' type, and cannot be renamed.`,
);
}
const { validateApplicationName } = await import('../../utils/validation');
const newName =
params.newName ||
(await getCliForm().ask({
message: 'Please enter the new name for this application:',
type: 'input',
validate: validateApplicationName,
})) ||
'';
try {
await this.renameApplication(balena, app.id, newName);
} catch (e) {
// BalenaRequestError: Request error: Unique key constraint violated
if ((e.message || '').toLowerCase().includes('unique')) {
throw new ExpectedError(
`Error: application ${params.name} already exists.`,
);
}
throw e;
}
console.log(`Application ${params.name} renamed to ${newName}`);
}
async renameApplication(balena: BalenaSDK, id: number, newName: string) {
return balena.pine.patch<Application>({
resource: 'application',
id,
body: {
app_name: newName,
},
});
}
}

View File

@ -15,34 +15,47 @@
* limitations under the License. * limitations under the License.
*/ */
import { Args, Command } from '@oclif/core'; import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy'; import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
export default class FleetTrackLatestCmd extends Command { interface FlagsDef {
help: void;
}
interface ArgsDef {
name: string;
}
export default class AppRestartCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
Make this fleet track the latest release. Restart an application.
Make this fleet track the latest release. Restart all devices belonging to an application.
`; `;
public static examples = [ public static examples = ['$ balena app restart MyApp'];
'$ balena fleet track-latest myorg/myfleet',
'$ balena fleet track-latest myfleet', public static args = [
{
name: 'name',
description: 'application name or numeric ID',
required: true,
},
]; ];
public static args = { public static usage = 'app restart <name>';
slug: Args.string({
description: 'the slug of the fleet to make track the latest release', public static flags: flags.Input<FlagsDef> = {
required: true, help: cf.help,
}),
}; };
public static authenticated = true; public static authenticated = true;
public async run() { public async run() {
const { args: params } = await this.parse(FleetTrackLatestCmd); const { args: params } = this.parse<FlagsDef, ArgsDef>(AppRestartCmd);
const balena = getBalenaSdk(); await getBalenaSdk().models.application.restart(tryAsInteger(params.name));
await balena.models.application.trackLatestRelease(params.slug);
} }
} }

79
lib/commands/app/rm.ts Normal file
View File

@ -0,0 +1,79 @@
/**
* @license
* Copyright 2016-2020 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.
*/
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
interface FlagsDef {
yes: boolean;
help: void;
}
interface ArgsDef {
name: string;
}
export default class AppRmCmd extends Command {
public static description = stripIndent`
Remove an application.
Permanently remove a balena application.
The --yes option may be used to avoid interactive confirmation.
`;
public static examples = [
'$ balena app rm MyApp',
'$ balena app rm MyApp --yes',
];
public static args = [
{
name: 'name',
description: 'application name or numeric ID',
required: true,
},
];
public static usage = 'app rm <name>';
public static flags: flags.Input<FlagsDef> = {
yes: cf.yes,
help: cf.help,
};
public static authenticated = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
AppRmCmd,
);
const patterns = await import('../../utils/patterns');
// Confirm
await patterns.confirm(
options.yes ?? false,
`Are you sure you want to delete application ${params.name}?`,
);
// Remove
await getBalenaSdk().models.application.remove(tryAsInteger(params.name));
}
}

97
lib/commands/apps.ts Normal file
View File

@ -0,0 +1,97 @@
/**
* @license
* Copyright 2016-2020 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.
*/
import { flags } from '@oclif/command';
import Command from '../command';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
import { isV12 } from '../utils/version';
interface ExtendedApplication extends ApplicationWithDeviceType {
device_count?: number;
online_devices?: number;
}
interface FlagsDef {
help: void;
verbose?: boolean;
}
export default class AppsCmd extends Command {
public static description = stripIndent`
List all applications.
list all your balena applications.
For detailed information on a particular application,
use \`balena app <name> instead\`.
`;
public static examples = ['$ balena apps'];
public static usage = 'apps';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
verbose: flags.boolean({
char: 'v',
description: isV12()
? 'No-op since release v12.0.0'
: 'add extra columns in the tabular output (SLUG)',
}),
};
public static authenticated = true;
public static primary = true;
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(AppsCmd);
const balena = getBalenaSdk();
// Get applications
const applications = (await balena.models.application.getAll({
$select: ['id', 'app_name', 'slug'],
$expand: {
is_for__device_type: { $select: 'slug' },
owns__device: { $select: 'is_online' },
},
})) as ExtendedApplication[];
const _ = await import('lodash');
// Add extended properties
applications.forEach((application) => {
application.device_count = application.owns__device?.length ?? 0;
application.online_devices = _.sumBy(application.owns__device, (d) =>
d.is_online === true ? 1 : 0,
);
// @ts-expect-error
application.device_type = application.is_for__device_type[0].slug;
});
// Display
console.log(
getVisuals().table.horizontal(applications, [
'id',
'app_name',
options.verbose || isV12() ? 'slug' : '',
'device_type',
'online_devices',
'device_count',
]),
);
}
}

258
lib/commands/build.ts Normal file
View File

@ -0,0 +1,258 @@
/**
* @license
* Copyright 2016-2020 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.
*/
import { flags } from '@oclif/command';
import Command from '../command';
import { getBalenaSdk } from '../utils/lazy';
import * as compose from '../utils/compose';
import type { Application, ApplicationType, BalenaSDK } from 'balena-sdk';
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
import type { ComposeCliFlags, ComposeOpts } from '../utils/compose-types';
import { buildProject, composeCliFlags } from '../utils/compose_ts';
import type { BuildOpts, DockerCliFlags } from '../utils/docker';
import { dockerCliFlags } from '../utils/docker';
interface FlagsDef extends ComposeCliFlags, DockerCliFlags {
arch?: string;
deviceType?: string;
application?: string;
source?: string; // Not part of command profile - source param copied here.
help: void;
}
interface ArgsDef {
source?: string;
}
export default class BuildCmd extends Command {
public static description = `\
Build a project locally.
Use this command to build an image or a complete multicontainer project with
the provided docker daemon in your development machine or balena device.
(See also the \`balena push\` command for the option of building images in the
balenaCloud build servers.)
You must provide either an application or a device-type/architecture pair.
This command will look into the given source directory (or the current working
directory if one isn't specified) for a docker-compose.yml file, and if found,
each service defined in the compose file will be built. If a compose file isn't
found, it will look for a Dockerfile[.template] file (or alternative Dockerfile
specified with the \`--dockerfile\` option), and if no dockerfile is found, it
will try to generate one.
${registrySecretsHelp}
${dockerignoreHelp}
`;
public static examples = [
'$ balena build --application myApp',
'$ balena build ./source/ --application myApp',
'$ balena build --deviceType raspberrypi3 --arch armv7hf --emulated',
'$ balena build --docker /var/run/docker.sock --application myApp # Linux, Mac',
'$ balena build --docker //./pipe/docker_engine --application myApp # Windows',
'$ balena build --dockerHost my.docker.host --dockerPort 2376 --ca ca.pem --key key.pem --cert cert.pem -a myApp',
];
public static args = [
{
name: 'source',
description: 'path of project source directory',
},
];
public static usage = 'build [source]';
public static flags: flags.Input<FlagsDef> = {
arch: flags.string({
description: 'the architecture to build for',
char: 'A',
}),
deviceType: flags.string({
description: 'the type of device this build is for',
char: 'd',
}),
application: flags.string({
description: 'name of the target balena application this build is for',
char: 'a',
}),
...composeCliFlags,
...dockerCliFlags,
// NOTE: Not supporting -h for help, because of clash with -h in DockerCliFlags
// Revisit this in future release.
help: flags.help({}),
};
public static primary = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
BuildCmd,
);
await Command.checkLoggedInIf(!!options.application);
// compositions with many services trigger misleading warnings
// @ts-ignore editing property that isn't typed but does exist
(await import('events')).defaultMaxListeners = 1000;
const sdk = getBalenaSdk();
const logger = await Command.getLogger();
logger.logDebug('Parsing input...');
// `build` accepts `source` as a parameter, but compose expects it as an option
options.source = params.source;
delete params.source;
await this.validateOptions(options, sdk);
const app = await this.getAppAndResolveArch(options);
const { docker, buildOpts, composeOpts } = await this.prepareBuild(options);
try {
await this.buildProject(docker, logger, composeOpts, {
app,
arch: options.arch!,
deviceType: options.deviceType!,
buildEmulated: options.emulated,
buildOpts,
});
} catch (err) {
logger.logError('Build failed.');
throw err;
}
logger.outputDeferredMessages();
logger.logSuccess('Build succeeded!');
}
protected async validateOptions(opts: FlagsDef, sdk: BalenaSDK) {
// Validate option combinations
if (
(opts.application == null &&
(opts.arch == null || opts.deviceType == null)) ||
(opts.application != null &&
(opts.arch != null || opts.deviceType != null))
) {
const { ExpectedError } = await import('../errors');
throw new ExpectedError(
'You must specify either an application or an arch/deviceType pair to build for',
);
}
// Validate project directory
const { validateProjectDirectory } = await import('../utils/compose_ts');
const { dockerfilePath, registrySecrets } = await validateProjectDirectory(
sdk,
{
dockerfilePath: opts.dockerfile,
noParentCheck: opts['noparent-check'] || false,
projectPath: opts.source || '.',
registrySecretsPath: opts['registry-secrets'],
},
);
opts.dockerfile = dockerfilePath;
opts['registry-secrets'] = registrySecrets;
}
protected async getAppAndResolveArch(opts: FlagsDef) {
if (opts.application) {
const { getAppWithArch } = await import('../utils/helpers');
const app = await getAppWithArch(opts.application);
opts.arch = app.arch;
opts.deviceType = app.is_for__device_type[0].slug;
return app;
}
}
protected async prepareBuild(options: FlagsDef) {
const { getDocker, generateBuildOpts } = await import('../utils/docker');
const [docker, buildOpts, composeOpts] = await Promise.all([
getDocker(options),
generateBuildOpts(options),
compose.generateOpts(options),
]);
return {
docker,
buildOpts,
composeOpts,
};
}
/**
* Opts must be an object with the following keys:
* app: the app this build is for (optional)
* arch: the architecture to build for
* deviceType: the device type to build for
* buildEmulated
* buildOpts: arguments to forward to docker build command
*
* @param {DockerToolbelt} docker
* @param {Logger} logger
* @param {ComposeOpts} composeOpts
* @param opts
*/
protected async buildProject(
docker: import('docker-toolbelt'),
logger: import('../utils/logger'),
composeOpts: ComposeOpts,
opts: {
app?: Application;
arch: string;
deviceType: string;
buildEmulated: boolean;
buildOpts: BuildOpts;
},
) {
const { loadProject } = await import('../utils/compose_ts');
const project = await loadProject(logger, composeOpts);
const appType = (opts.app?.application_type as ApplicationType[])?.[0];
if (
appType != null &&
project.descriptors.length > 1 &&
!appType.supports_multicontainer
) {
logger.logWarn(
'Target application does not support multiple containers.\n' +
'Continuing with build, but you will not be able to deploy.',
);
}
await buildProject({
docker,
logger,
projectPath: project.path,
projectName: project.name,
composition: project.composition,
arch: opts.arch,
deviceType: opts.deviceType,
emulated: opts.buildEmulated,
buildOpts: opts.buildOpts,
inlineLogs: composeOpts.inlineLogs,
convertEol: composeOpts.convertEol,
dockerfilePath: composeOpts.dockerfilePath,
nogitignore: composeOpts.nogitignore,
multiDockerignore: composeOpts.multiDockerignore,
});
}
}

View File

@ -0,0 +1,257 @@
/**
* @license
* Copyright 2016-2020 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.
*/
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, getCliForm, stripIndent } from '../../utils/lazy';
import type { PineDeferred } from 'balena-sdk';
interface FlagsDef {
version: string; // OS version
application?: string;
app?: string; // application alias
device?: string;
deviceApiKey?: string;
deviceType?: string;
'generate-device-api-key': boolean;
output?: string;
// Options for non-interactive configuration
network?: string;
wifiSsid?: string;
wifiKey?: string;
appUpdatePollInterval?: string;
help: void;
}
export default class ConfigGenerateCmd extends Command {
public static description = stripIndent`
Generate a config.json file.
Generate a config.json file for a device or application.
Calling this command with the exact version number of the targeted image is required.
This command is interactive by default, but you can do this automatically without interactivity
by specifying an option for each question on the command line, if you know the questions
that will be asked for the relevant device type.
In case that you want to configure an image for an application with mixed device types,
you can pass the --device-type argument along with --app to specify the target device type.
`;
public static examples = [
'$ balena config generate --device 7cf02a6 --version 2.12.7',
'$ balena config generate --device 7cf02a6 --version 2.12.7 --generate-device-api-key',
'$ balena config generate --device 7cf02a6 --version 2.12.7 --device-api-key <existingDeviceKey>',
'$ balena config generate --device 7cf02a6 --version 2.12.7 --output config.json',
'$ balena config generate --app MyApp --version 2.12.7',
'$ balena config generate --app MyApp --version 2.12.7 --device-type fincm3',
'$ balena config generate --app MyApp --version 2.12.7 --output config.json',
'$ balena config generate --app MyApp --version 2.12.7 --network wifi --wifiSsid mySsid --wifiKey abcdefgh --appUpdatePollInterval 1',
];
public static usage = 'config generate';
public static flags: flags.Input<FlagsDef> = {
version: flags.string({
description: 'a balenaOS version',
required: true,
}),
application: flags.string({
description: 'application name',
char: 'a',
exclusive: ['app', 'device'],
}),
app: flags.string({
description: "same as '--application'",
exclusive: ['application', 'device'],
}),
device: flags.string({
description: 'device uuid',
char: 'd',
exclusive: ['application', 'app'],
}),
deviceApiKey: flags.string({
description:
'custom device key - note that this is only supported on balenaOS 2.0.3+',
char: 'k',
}),
deviceType: flags.string({
description: 'device type slug',
}),
'generate-device-api-key': flags.boolean({
description: 'generate a fresh device key for the device',
}),
output: flags.string({
description: 'path of output file',
char: 'o',
}),
// Options for non-interactive configuration
network: flags.string({
description: 'the network type to use: ethernet or wifi',
options: ['ethernet', 'wifi'],
}),
wifiSsid: flags.string({
description:
'the wifi ssid to use (used only if --network is set to wifi)',
}),
wifiKey: flags.string({
description:
'the wifi key to use (used only if --network is set to wifi)',
}),
appUpdatePollInterval: flags.string({
description:
'how frequently (in minutes) to poll for application updates',
}),
help: cf.help,
};
public static authenticated = true;
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(ConfigGenerateCmd);
const { getApplication } = await import('../../utils/sdk');
const balena = getBalenaSdk();
await this.validateOptions(options);
let resourceDeviceType: string;
let application: ApplicationWithDeviceType | null = null;
let device:
| (DeviceWithDeviceType & { belongs_to__application: PineDeferred })
| null = null;
if (options.device != null) {
const { tryAsInteger } = await import('../../utils/validation');
const rawDevice = await balena.models.device.get(
tryAsInteger(options.device),
{ $expand: { is_of__device_type: { $select: 'slug' } } },
);
if (!rawDevice.belongs_to__application) {
const { ExpectedError } = await import('../../errors');
throw new ExpectedError(stripIndent`
Device ${options.device} does not appear to belong to an accessible application.
Try with a different device, or use '--application' instead of '--device'.`);
}
device = rawDevice as DeviceWithDeviceType & {
belongs_to__application: PineDeferred;
};
resourceDeviceType = device.is_of__device_type[0].slug;
} else {
application = (await getApplication(balena, options.application!, {
$expand: {
is_for__device_type: { $select: 'slug' },
},
})) as ApplicationWithDeviceType;
resourceDeviceType = application.is_for__device_type[0].slug;
}
const deviceType = options.deviceType || resourceDeviceType;
const deviceManifest = await balena.models.device.getManifestBySlug(
deviceType,
);
// Check compatibility if application and deviceType provided
if (options.application && options.deviceType) {
const appDeviceManifest = await balena.models.device.getManifestBySlug(
resourceDeviceType,
);
const helpers = await import('../../utils/helpers');
if (
!helpers.areDeviceTypesCompatible(appDeviceManifest, deviceManifest)
) {
throw new balena.errors.BalenaInvalidDeviceType(
`Device type ${options.deviceType} is incompatible with application ${options.application}`,
);
}
}
// Prompt for values
// Pass params as an override: if there is any param with exactly the same name as a
// required option, that value is used (and the corresponding question is not asked)
const answers = await getCliForm().run(deviceManifest.options, {
override: options,
});
answers.version = options.version;
// Generate config
const { generateDeviceConfig, generateApplicationConfig } = await import(
'../../utils/config'
);
let config;
if (device) {
config = await generateDeviceConfig(
device,
options.deviceApiKey || options['generate-device-api-key'] || undefined,
answers,
);
} else if (application) {
answers.deviceType = deviceType;
config = await generateApplicationConfig(application, answers);
}
// Output
if (options.output != null) {
const fs = await import('fs');
await fs.promises.writeFile(options.output, JSON.stringify(config));
}
const prettyjson = await import('prettyjson');
console.log(prettyjson.render(config));
}
protected readonly missingDeviceOrAppMessage = stripIndent`
Either a device or an application must be specified.
See the help page for examples:
$ balena help config generate
`;
protected readonly deviceTypeNotAllowedMessage = stripIndent`
Specifying a different device type is only supported when
generating a config for an application:
* An application, with --app <appname>
* A specific device type, with --device-type <deviceTypeSlug>
See the help page for examples:
$ balena help config generate
`;
protected async validateOptions(options: FlagsDef) {
const { ExpectedError } = await import('../../errors');
// Prefer options.application over options.app
options.application = options.application || options.app;
delete options.app;
if (options.device == null && options.application == null) {
throw new ExpectedError(this.missingDeviceOrAppMessage);
}
if (!options.application && options.deviceType) {
throw new ExpectedError(this.deviceTypeNotAllowedMessage);
}
}
}

View File

@ -0,0 +1,96 @@
/**
* @license
* Copyright 2016-2020 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.
*/
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getVisuals, stripIndent } from '../../utils/lazy';
interface FlagsDef {
type: string;
drive?: string;
help: void;
}
interface ArgsDef {
file: string;
}
export default class ConfigInjectCmd extends Command {
public static description = stripIndent`
Inject a configuration file into a device or OS image.
Inject a config.json file to the mounted filesystem,
e.g. the SD card of a provisioned device or balenaOS image.
`;
public static examples = [
'$ balena config inject my/config.json --type raspberrypi3',
'$ balena config inject my/config.json --type raspberrypi3 --drive /dev/disk2',
];
public static args = [
{
name: 'file',
description: 'the path to the config.json file to inject',
required: true,
},
];
public static usage = 'config inject <file>';
public static flags: flags.Input<FlagsDef> = {
type: flags.string({
description:
'device type (Check available types with `balena devices supported`)',
char: 't',
required: true,
}),
drive: flags.string({
description: 'device filesystem or OS image location',
char: 'd',
}),
help: cf.help,
};
public static authenticated = true;
public static root = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
ConfigInjectCmd,
);
const { promisify } = await import('util');
const umountAsync = promisify((await import('umount')).umount);
const drive =
options.drive || (await getVisuals().drive('Select the device/OS drive'));
await umountAsync(drive);
const fs = await import('fs');
const configJSON = JSON.parse(
await fs.promises.readFile(params.file, 'utf8'),
);
const config = await import('balena-config-json');
await config.write(drive, options.type, configJSON);
console.info('Done');
}
}

View File

@ -0,0 +1,78 @@
/**
* @license
* Copyright 2016-2020 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.
*/
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getVisuals, stripIndent } from '../../utils/lazy';
interface FlagsDef {
type: string;
drive?: string;
help: void;
}
export default class ConfigReadCmd extends Command {
public static description = stripIndent`
Read the configuration of a device or OS image.
Read the config.json file from the mounted filesystem,
e.g. the SD card of a provisioned device or balenaOS image.
`;
public static examples = [
'$ balena config read --type raspberrypi3',
'$ balena config read --type raspberrypi3 --drive /dev/disk2',
];
public static usage = 'config read';
public static flags: flags.Input<FlagsDef> = {
type: flags.string({
description:
'device type (Check available types with `balena devices supported`)',
char: 't',
required: true,
}),
drive: flags.string({
description: 'device filesystem or OS image location',
char: 'd',
}),
help: cf.help,
};
public static authenticated = true;
public static root = true;
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(ConfigReadCmd);
const { promisify } = await import('util');
const umountAsync = promisify((await import('umount')).umount);
const drive =
options.drive || (await getVisuals().drive('Select the device drive'));
await umountAsync(drive);
const config = await import('balena-config-json');
const configJSON = await config.read(drive, options.type);
const prettyjson = await import('prettyjson');
console.info(prettyjson.render(configJSON));
}
}

View File

@ -1,6 +1,6 @@
/** /**
* @license * @license
* Copyright 2016-2021 Balena Ltd. * Copyright 2016-2020 Balena Ltd.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -15,67 +15,69 @@
* limitations under the License. * limitations under the License.
*/ */
import { Flags, Command } from '@oclif/core'; import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags'; import * as cf from '../../utils/common-flags';
import { getVisuals, stripIndent } from '../../utils/lazy'; import { getVisuals, stripIndent } from '../../utils/lazy';
interface FlagsDef {
type: string;
drive?: string;
advanced: boolean;
help: void;
}
export default class ConfigReconfigureCmd extends Command { export default class ConfigReconfigureCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
Interactively reconfigure a balenaOS image file or attached media. Interactively reconfigure a device or OS image.
Interactively reconfigure a balenaOS image file or attached media. Interactively reconfigure a provisioned device or OS image.
This command extracts the device UUID from the 'config.json' file of the
chosen balenaOS image file or attached media, and then passes the UUID as
the '--device' argument to the 'balena os configure' command.
For finer-grained or scripted control of the operation, use the
'balena config read' and 'balena os configure' commands separately.
`; `;
public static examples = [ public static examples = [
'$ balena config reconfigure', '$ balena config reconfigure --type raspberrypi3',
'$ balena config reconfigure --drive /dev/disk3', '$ balena config reconfigure --type raspberrypi3 --advanced',
'$ balena config reconfigure --drive balena.img --advanced', '$ balena config reconfigure --type raspberrypi3 --drive /dev/disk2',
]; ];
public static flags = { public static usage = 'config reconfigure';
drive: cf.driveOrImg,
advanced: Flags.boolean({ public static flags: flags.Input<FlagsDef> = {
type: flags.string({
description:
'device type (Check available types with `balena devices supported`)',
char: 't',
required: true,
}),
drive: flags.string({
description: 'device filesystem or OS image location',
char: 'd',
}),
advanced: flags.boolean({
description: 'show advanced commands', description: 'show advanced commands',
char: 'v', char: 'v',
}), }),
version: Flags.string({ help: cf.help,
description: 'balenaOS version, for example "2.32.0" or "2.44.0+rev1"',
}),
}; };
public static authenticated = true; public static authenticated = true;
public static root = true; public static root = true;
public async run() { public async run() {
const { flags: options } = await this.parse(ConfigReconfigureCmd); const { flags: options } = this.parse<FlagsDef, {}>(ConfigReconfigureCmd);
const { safeUmount } = await import('../../utils/umount'); const { promisify } = await import('util');
const umountAsync = promisify((await import('umount')).umount);
const drive = const drive =
options.drive || (await getVisuals().drive('Select the device drive')); options.drive || (await getVisuals().drive('Select the device drive'));
await safeUmount(drive); await umountAsync(drive);
const config = await import('balena-config-json'); const config = await import('balena-config-json');
const { uuid } = await config.read(drive, ''); const { uuid } = await config.read(drive, options.type);
await safeUmount(drive); await umountAsync(drive);
if (!uuid) {
const { ExpectedError } = await import('../../errors');
throw new ExpectedError(
`Error: UUID not found in 'config.json' file for '${drive}'`,
);
}
const configureCommand = ['os', 'configure', drive, '--device', uuid]; const configureCommand = ['os', 'configure', drive, '--device', uuid];
if (options.version) {
configureCommand.push('--version', options.version);
}
if (options.advanced) { if (options.advanced) {
configureCommand.push('--advanced'); configureCommand.push('--advanced');
} }

View File

@ -0,0 +1,106 @@
/**
* @license
* Copyright 2016-2020 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.
*/
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getVisuals, stripIndent } from '../../utils/lazy';
interface FlagsDef {
type: string;
drive?: string;
help: void;
}
interface ArgsDef {
key: string;
value: string;
}
export default class ConfigWriteCmd extends Command {
public static description = stripIndent`
Write a key-value pair to configuration of a device or OS image.
Write a key-value pair to the config.json file on the mounted filesystem,
e.g. the SD card of a provisioned device or balenaOS image.
`;
public static examples = [
'$ balena config write --type raspberrypi3 username johndoe',
'$ balena config write --type raspberrypi3 --drive /dev/disk2 username johndoe',
'$ balena config write --type raspberrypi3 files.network/settings "..."',
];
public static args = [
{
name: 'key',
description: 'the key of the config parameter to write',
required: true,
},
{
name: 'value',
description: 'the value of the config parameter to write',
required: true,
},
];
public static usage = 'config write <key> <value>';
public static flags: flags.Input<FlagsDef> = {
type: flags.string({
description:
'device type (Check available types with `balena devices supported`)',
char: 't',
required: true,
}),
drive: flags.string({
description: 'device filesystem or OS image location',
char: 'd',
}),
help: cf.help,
};
public static authenticated = true;
public static root = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
ConfigWriteCmd,
);
const { promisify } = await import('util');
const umountAsync = promisify((await import('umount')).umount);
const drive =
options.drive || (await getVisuals().drive('Select the device drive'));
await umountAsync(drive);
const config = await import('balena-config-json');
const configJSON = await config.read(drive, options.type);
console.info(`Setting ${params.key} to ${params.value}`);
const _ = await import('lodash');
_.set(configJSON, params.key, params.value);
await umountAsync(drive);
await config.write(drive, options.type, configJSON);
console.info('Done');
}
}

View File

@ -1,6 +1,6 @@
/** /**
* @license * @license
* Copyright 2016-2021 Balena Ltd. * Copyright 2016-2020 Balena Ltd.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -15,61 +15,52 @@
* limitations under the License. * limitations under the License.
*/ */
import { Args, Flags, Command } from '@oclif/core'; import { flags } from '@oclif/command';
import type { ImageDescriptor } from '@balena/compose/dist/parse'; import type { ImageDescriptor } from 'resin-compose-parse';
import { ExpectedError } from '../../errors';
import { getBalenaSdk, getChalk, stripIndent } from '../../utils/lazy'; import Command from '../command';
import { import { ExpectedError } from '../errors';
dockerignoreHelp, import { getBalenaSdk, getChalk } from '../utils/lazy';
registrySecretsHelp, import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
buildArgDeprecation, import * as compose from '../utils/compose';
} from '../../utils/messages';
import * as ca from '../../utils/common-args';
import * as compose from '../../utils/compose';
import type { import type {
BuiltImage, BuiltImage,
ComposeCliFlags, ComposeCliFlags,
ComposeOpts, ComposeOpts,
Release as ComposeReleaseInfo, } from '../utils/compose-types';
} from '../../utils/compose-types'; import type { DockerCliFlags } from '../utils/docker';
import type { BuildOpts, DockerCliFlags } from '../../utils/docker';
import { import {
applyReleaseTagKeysAndValues,
buildProject, buildProject,
composeCliFlags, composeCliFlags,
isBuildConfig, isBuildConfig,
parseReleaseTagKeysAndValues, } from '../utils/compose_ts';
} from '../../utils/compose_ts'; import { dockerCliFlags } from '../utils/docker';
import { dockerCliFlags } from '../../utils/docker'; import type { Application, ApplicationType, DeviceType } from 'balena-sdk';
import type { ApplicationType, DeviceType, Release } from 'balena-sdk';
interface ApplicationWithArch { interface ApplicationWithArch extends Application {
id: number;
arch: string; arch: string;
is_for__device_type: [Pick<DeviceType, 'slug'>];
application_type: [Pick<ApplicationType, 'slug' | 'supports_multicontainer'>];
} }
// TODO: For this special one we can't use Interfaces.InferredFlags/InferredArgs
// because of the 'registry-secrets' type which is defined in the actual code
// as a path (string | undefined) but then the cli turns it into an object
interface FlagsDef extends ComposeCliFlags, DockerCliFlags { interface FlagsDef extends ComposeCliFlags, DockerCliFlags {
source?: string; source?: string;
build: boolean; build: boolean;
nologupload: boolean; nologupload: boolean;
'release-tag'?: string[]; help: void;
draft: boolean; }
note?: string;
interface ArgsDef {
appName: string;
image?: string;
} }
export default class DeployCmd extends Command { export default class DeployCmd extends Command {
public static description = `\ public static description = `\
Deploy a single image or a multicontainer project to a balena fleet. Deploy a single image or a multicontainer project to a balena application.
Usage: \`deploy <fleet> ([image] | --build [--source build-dir])\` Usage: \`deploy <appName> ([image] | --build [--source build-dir])\`
Use this command to deploy an image or a complete multicontainer project to a Use this command to deploy an image or a complete multicontainer project to an
fleet, optionally building it first. The source images are searched for application, optionally building it first. The source images are searched for
(and optionally built) using the docker daemon in your development machine or (and optionally built) using the docker daemon in your development machine or
balena device. (See also the \`balena push\` command for the option of building balena device. (See also the \`balena push\` command for the option of building
the image in the balenaCloud build servers.) the image in the balenaCloud build servers.)
@ -77,15 +68,13 @@ the image in the balenaCloud build servers.)
Unless an image is specified, this command will look into the current directory Unless an image is specified, this command will look into the current directory
(or the one specified by --source) for a docker-compose.yml file. If one is (or the one specified by --source) for a docker-compose.yml file. If one is
found, this command will deploy each service defined in the compose file, found, this command will deploy each service defined in the compose file,
building it first if an image for it doesn't exist. Image names will be looked building it first if an image for it doesn't exist. If a compose file isn't
up according to the scheme: \`<projectName>_<serviceName>\`. found, the command will look for a Dockerfile[.template] file (or alternative
Dockerfile specified with the \`-f\` option), and if yet that isn't found, it
will try to generate one.
If a compose file isn't found, the command will look for a Dockerfile[.template] To deploy to an app on which you're a collaborator, use
file (or alternative Dockerfile specified with the \`-f\` option), and if yet \`balena deploy <appOwnerUsername>/<appName>\`.
that isn't found, it will try to generate one.
To deploy to a fleet where you are a collaborator, use fleet slug including the
organization: \`balena deploy <organization>/<fleet>\`.
${registrySecretsHelp} ${registrySecretsHelp}
@ -93,51 +82,44 @@ ${dockerignoreHelp}
`; `;
public static examples = [ public static examples = [
'$ balena deploy myFleet', '$ balena deploy myApp',
'$ balena deploy myorg/myfleet --build --source myBuildDir/', '$ balena deploy myApp --build --source myBuildDir/',
'$ balena deploy myorg/myfleet --build --source myBuildDir/ --note "this is the note for this release"', '$ balena deploy myApp myApp/myImage',
'$ balena deploy myorg/myfleet myRepo/myImage',
'$ balena deploy myFleet myRepo/myImage --release-tag key1 "" key2 "value2 with spaces"',
]; ];
public static args = { public static args = [
fleet: ca.fleetRequired, {
image: Args.string({ description: 'the image to deploy' }), name: 'appName',
}; description: 'the name of the application to deploy to',
required: true,
},
{
name: 'image',
description: 'the image to deploy',
},
];
public static flags = { public static usage = 'deploy <appName> [image]';
source: Flags.string({
public static flags: flags.Input<FlagsDef> = {
source: flags.string({
description: description:
'specify an alternate source directory; default is the working directory', 'specify an alternate source directory; default is the working directory',
char: 's', char: 's',
}), }),
build: Flags.boolean({ build: flags.boolean({
description: 'force a rebuild before deploy', description: 'force a rebuild before deploy',
char: 'b', char: 'b',
}), }),
nologupload: Flags.boolean({ nologupload: flags.boolean({
description: description:
"don't upload build logs to the dashboard with image (if building)", "don't upload build logs to the dashboard with image (if building)",
}), }),
'release-tag': Flags.string({
description: stripIndent`
Set release tags if the image deployment is successful. Multiple
arguments may be provided, alternating tag keys and values (see examples).
Hint: Empty values may be specified with "" (bash, cmd.exe) or '""' (PowerShell).
`,
multiple: true,
}),
draft: Flags.boolean({
description: stripIndent`
Deploy the release as a draft. Draft releases are ignored
by the 'track latest' release policy but can be used through release pinning.
Draft releases can be marked as final through the API. Releases are created
as final by default unless this option is given.`,
default: false,
}),
note: Flags.string({ description: 'The notes for this release' }),
...composeCliFlags, ...composeCliFlags,
...dockerCliFlags, ...dockerCliFlags,
// NOTE: Not supporting -h for help, because of clash with -h in DockerCliFlags
// Revisit this in future release.
help: flags.help({}),
}; };
public static authenticated = true; public static authenticated = true;
@ -145,21 +127,18 @@ ${dockerignoreHelp}
public static primary = true; public static primary = true;
public async run() { public async run() {
const { args: params, flags: options } = await this.parse(DeployCmd); const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
DeployCmd,
);
// compositions with many services trigger misleading warnings
// @ts-ignore editing property that isn't typed but does exist
(await import('events')).defaultMaxListeners = 1000; (await import('events')).defaultMaxListeners = 1000;
const Logger = await import('../../utils/logger'); const logger = await Command.getLogger();
const logger = Logger.getLogger();
logger.logDebug('Parsing input...'); logger.logDebug('Parsing input...');
const { fleet, image } = params; const { appName, image } = params;
// Build args are under consideration for removal - warn user
if (options.buildArg) {
console.log(buildArgDeprecation);
}
if (image != null && options.build) { if (image != null && options.build) {
throw new ExpectedError( throw new ExpectedError(
@ -169,64 +148,52 @@ ${dockerignoreHelp}
const sdk = getBalenaSdk(); const sdk = getBalenaSdk();
const { getRegistrySecrets, validateProjectDirectory } = await import( const { getRegistrySecrets, validateProjectDirectory } = await import(
'../../utils/compose_ts' '../utils/compose_ts'
);
const { releaseTagKeys, releaseTagValues } = parseReleaseTagKeysAndValues(
options['release-tag'] ?? [],
); );
if (image) { if (image) {
(options as FlagsDef)['registry-secrets'] = await getRegistrySecrets( options['registry-secrets'] = await getRegistrySecrets(
sdk, sdk,
options['registry-secrets'], options['registry-secrets'],
); );
} else { } else {
const { dockerfilePath, registrySecrets } = const {
await validateProjectDirectory(sdk, { dockerfilePath,
dockerfilePath: options.dockerfile, registrySecrets,
noParentCheck: options['noparent-check'] || false, } = await validateProjectDirectory(sdk, {
projectPath: options.source || '.', dockerfilePath: options.dockerfile,
registrySecretsPath: options['registry-secrets'], noParentCheck: options['noparent-check'] || false,
}); projectPath: options.source || '.',
registrySecretsPath: options['registry-secrets'],
});
options.dockerfile = dockerfilePath; options.dockerfile = dockerfilePath;
(options as FlagsDef)['registry-secrets'] = registrySecrets; options['registry-secrets'] = registrySecrets;
} }
const helpers = await import('../../utils/helpers'); const helpers = await import('../utils/helpers');
const app = await helpers.getAppWithArch(fleet); const app = await helpers.getAppWithArch(appName);
const dockerUtils = await import('../../utils/docker'); const dockerUtils = await import('../utils/docker');
const [docker, buildOpts, composeOpts] = await Promise.all([ const [docker, buildOpts, composeOpts] = await Promise.all([
dockerUtils.getDocker(options), dockerUtils.getDocker(options),
dockerUtils.generateBuildOpts(options as FlagsDef), dockerUtils.generateBuildOpts(options),
compose.generateOpts(options), compose.generateOpts(options),
]); ]);
const release = await this.deployProject(docker, logger, composeOpts, { await this.deployProject(docker, logger, composeOpts, {
app, app,
appName: fleet, // may be prefixed by 'owner/', unlike app.app_name appName, // may be prefixed by 'owner/', unlike app.app_name
image, image,
shouldPerformBuild: !!options.build, shouldPerformBuild: !!options.build,
shouldUploadLogs: !options.nologupload, shouldUploadLogs: !options.nologupload,
buildEmulated: !!options.emulated, buildEmulated: !!options.emulated,
createAsDraft: options.draft,
buildOpts, buildOpts,
}); });
await applyReleaseTagKeysAndValues(
sdk,
release.id,
releaseTagKeys,
releaseTagValues,
);
if (options.note) {
await sdk.models.release.setNote(release.id, options.note);
}
} }
async deployProject( async deployProject(
docker: import('dockerode'), docker: import('docker-toolbelt'),
logger: import('../../utils/logger'), logger: import('../utils/logger'),
composeOpts: ComposeOpts, composeOpts: ComposeOpts,
opts: { opts: {
app: ApplicationWithArch; // the application instance to deploy to app: ApplicationWithArch; // the application instance to deploy to
@ -236,29 +203,23 @@ ${dockerignoreHelp}
shouldPerformBuild: boolean; shouldPerformBuild: boolean;
shouldUploadLogs: boolean; shouldUploadLogs: boolean;
buildEmulated: boolean; buildEmulated: boolean;
buildOpts: BuildOpts; buildOpts: any; // arguments to forward to docker build command
createAsDraft: boolean;
}, },
) { ) {
const _ = await import('lodash'); const _ = await import('lodash');
const doodles = await import('resin-doodles'); const doodles = await import('resin-doodles');
const sdk = getBalenaSdk(); const sdk = getBalenaSdk();
const { deployProject: $deployProject, loadProject } = await import( const { deployProject: $deployProject, loadProject } = await import(
'../../utils/compose_ts' '../utils/compose_ts'
); );
const appType = opts.app.application_type[0]; const appType = (opts.app?.application_type as ApplicationType[])?.[0];
try { try {
const project = await loadProject( const project = await loadProject(logger, composeOpts, opts.image);
logger,
composeOpts,
opts.image,
opts.buildOpts.t,
);
if (project.descriptors.length > 1 && !appType?.supports_multicontainer) { if (project.descriptors.length > 1 && !appType?.supports_multicontainer) {
throw new ExpectedError( throw new ExpectedError(
'Target fleet does not support multiple containers. Aborting!', 'Target application does not support multiple containers. Aborting!',
); );
} }
@ -304,12 +265,13 @@ ${dockerignoreHelp}
projectName: project.name, projectName: project.name,
composition: compositionToBuild, composition: compositionToBuild,
arch: opts.app.arch, arch: opts.app.arch,
deviceType: opts.app.is_for__device_type[0].slug, deviceType: (opts.app?.is_for__device_type as DeviceType[])?.[0].slug,
emulated: opts.buildEmulated, emulated: opts.buildEmulated,
buildOpts: opts.buildOpts, buildOpts: opts.buildOpts,
inlineLogs: composeOpts.inlineLogs, inlineLogs: composeOpts.inlineLogs,
convertEol: composeOpts.convertEol, convertEol: composeOpts.convertEol,
dockerfilePath: composeOpts.dockerfilePath, dockerfilePath: composeOpts.dockerfilePath,
nogitignore: composeOpts.nogitignore,
multiDockerignore: composeOpts.multiDockerignore, multiDockerignore: composeOpts.multiDockerignore,
}); });
builtImagesByService = _.keyBy(builtImages, 'serviceName'); builtImagesByService = _.keyBy(builtImages, 'serviceName');
@ -324,18 +286,18 @@ ${dockerignoreHelp}
}, },
); );
let release: Release | ComposeReleaseInfo['release']; let release;
if (appType.slug === 'legacy-v1' || appType.slug === 'legacy-v2') { if (appType?.is_legacy) {
const { deployLegacy } = require('../../utils/deploy-legacy'); const { deployLegacy } = require('../utils/deploy-legacy');
const msg = getChalk().yellow( const msg = getChalk().yellow(
'Target fleet requires legacy deploy method.', 'Target application requires legacy deploy method.',
); );
logger.logWarn(msg); logger.logWarn(msg);
const [token, { username }, url, options] = await Promise.all([ const [token, username, url, options] = await Promise.all([
sdk.auth.getToken(), sdk.auth.getToken(),
sdk.auth.getUserInfo(), sdk.auth.whoami(),
sdk.settings.get('balenaUrl'), sdk.settings.get('balenaUrl'),
{ {
// opts.appName may be prefixed by 'owner/', unlike opts.app.app_name // opts.appName may be prefixed by 'owner/', unlike opts.app.app_name
@ -358,16 +320,21 @@ ${dockerignoreHelp}
$select: ['commit'], $select: ['commit'],
}); });
} else { } else {
const [userId, auth, apiEndpoint] = await Promise.all([
sdk.auth.getUserId(),
sdk.auth.getToken(),
sdk.settings.get('apiUrl'),
]);
release = await $deployProject( release = await $deployProject(
docker, docker,
sdk,
logger, logger,
project.composition, project.composition,
images, images,
opts.app.id, opts.app.id,
userId,
`Bearer ${auth}`,
apiEndpoint,
!opts.shouldUploadLogs, !opts.shouldUploadLogs,
composeOpts.projectPath,
opts.createAsDraft,
); );
} }
@ -377,7 +344,6 @@ ${dockerignoreHelp}
console.log(); console.log();
console.log(doodles.getDoodle()); // Show charlie console.log(doodles.getDoodle()); // Show charlie
console.log(); console.log();
return release;
} catch (err) { } catch (err) {
logger.logError('Deploy failed'); logger.logError('Deploy failed');
throw err; throw err;

View File

@ -15,10 +15,22 @@
* limitations under the License. * limitations under the License.
*/ */
import { Args, Command } from '@oclif/core'; import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy'; import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
import { ExpectedError } from '../../errors'; import { ExpectedError } from '../../errors';
interface FlagsDef {
help: void;
}
interface ArgsDef {
uuid: string;
}
export default class DeviceIdentifyCmd extends Command { export default class DeviceIdentifyCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
Identify a device. Identify a device.
@ -27,17 +39,25 @@ export default class DeviceIdentifyCmd extends Command {
`; `;
public static examples = ['$ balena device identify 23c73a1']; public static examples = ['$ balena device identify 23c73a1'];
public static args = { public static args: Array<IArg<any>> = [
uuid: Args.string({ {
name: 'uuid',
description: 'the uuid of the device to identify', description: 'the uuid of the device to identify',
parse: (dev) => tryAsInteger(dev),
required: true, required: true,
}), },
];
public static usage = 'device identify <uuid>';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
}; };
public static authenticated = true; public static authenticated = true;
public async run() { public async run() {
const { args: params } = await this.parse(DeviceIdentifyCmd); const { args: params } = this.parse<FlagsDef, ArgsDef>(DeviceIdentifyCmd);
const balena = getBalenaSdk(); const balena = getBalenaSdk();

View File

@ -15,17 +15,18 @@
* limitations under the License. * limitations under the License.
*/ */
import { Flags, Args, Command } from '@oclif/core'; import { flags } from '@oclif/command';
import { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import * as cf from '../../utils/common-flags'; import * as cf from '../../utils/common-flags';
import { expandForAppName } from '../../utils/helpers'; import { expandForAppName } from '../../utils/helpers';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy'; import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import { jsonInfo } from '../../utils/messages'; import { tryAsInteger } from '../../utils/validation';
import type { Application, Release } from 'balena-sdk'; import type { Application, Release } from 'balena-sdk';
interface ExtendedDevice extends DeviceWithDeviceType { interface ExtendedDevice extends DeviceWithDeviceType {
dashboard_url?: string; dashboard_url?: string;
fleet: string; // 'org/name' slug application_name?: string;
device_type?: string; device_type?: string;
commit?: string; commit?: string;
last_seen?: string; last_seen?: string;
@ -40,98 +41,78 @@ interface ExtendedDevice extends DeviceWithDeviceType {
undervoltage_detected?: boolean; undervoltage_detected?: boolean;
} }
interface FlagsDef {
help: void;
}
interface ArgsDef {
uuid: string;
}
export default class DeviceCmd extends Command { export default class DeviceCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
Show info about a single device. Show info about a single device.
Show information about a single device. Show information about a single device.
${jsonInfo.split('\n').join('\n\t\t')}
`; `;
public static examples = [ public static examples = ['$ balena device 7cf02a6'];
'$ balena device 7cf02a6',
'$ balena device 7cf02a6 --view', public static args: Array<IArg<any>> = [
'$ balena device 7cf02a6 --json', {
name: 'uuid',
description: 'the device uuid',
parse: (dev) => tryAsInteger(dev),
required: true,
},
]; ];
public static args = { public static usage = 'device <uuid>';
uuid: Args.string({
description: 'the device uuid',
required: true,
}),
};
public static flags = { public static flags: flags.Input<FlagsDef> = {
json: cf.json, help: cf.help,
view: Flags.boolean({
default: false,
description: 'open device dashboard page',
}),
}; };
public static authenticated = true; public static authenticated = true;
public static primary = true; public static primary = true;
public async run() { public async run() {
const { args: params, flags: options } = await this.parse(DeviceCmd); const { args: params } = this.parse<FlagsDef, ArgsDef>(DeviceCmd);
const balena = getBalenaSdk(); const balena = getBalenaSdk();
const device = (await balena.models.device.get( const device = (await balena.models.device.get(params.uuid, {
params.uuid, $select: [
options.json 'device_name',
? { 'id',
$expand: { 'overall_status',
device_tag: { 'is_online',
$select: ['tag_key', 'value'], 'ip_address',
}, 'mac_address',
...expandForAppName.$expand, 'last_connectivity_event',
}, 'uuid',
} 'supervisor_version',
: { 'is_web_accessible',
$select: [ 'note',
'device_name', 'os_version',
'id', 'memory_usage',
'overall_status', 'memory_total',
'is_online', 'storage_block_device',
'ip_address', 'storage_usage',
'mac_address', 'storage_total',
'last_connectivity_event', 'cpu_usage',
'uuid', 'cpu_temp',
'supervisor_version', 'cpu_id',
'is_web_accessible', 'is_undervolted',
'note', ],
'os_version', ...expandForAppName,
'memory_usage', })) as ExtendedDevice;
'memory_total',
'public_address',
'storage_block_device',
'storage_usage',
'storage_total',
'cpu_usage',
'cpu_temp',
'cpu_id',
'is_undervolted',
],
...expandForAppName,
},
)) as ExtendedDevice;
if (options.view) {
const open = await import('open');
const dashboardUrl = balena.models.device.getDashboardUrl(device.uuid);
await open(dashboardUrl, { wait: false });
return;
}
device.status = device.overall_status; device.status = device.overall_status;
device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid); device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid);
const belongsToApplication = const belongsToApplication = device.belongs_to__application as Application[];
device.belongs_to__application as Application[]; device.application_name = belongsToApplication?.[0]
device.fleet = belongsToApplication?.[0] ? belongsToApplication[0].app_name
? belongsToApplication[0].slug
: 'N/a'; : 'N/a';
device.device_type = device.is_of__device_type[0].slug; device.device_type = device.is_of__device_type[0].slug;
@ -179,11 +160,6 @@ export default class DeviceCmd extends Command {
); );
} }
if (options.json) {
console.log(JSON.stringify(device, null, 4));
return;
}
console.log( console.log(
getVisuals().table.vertical(device, [ getVisuals().table.vertical(device, [
`$${device.device_name}$`, `$${device.device_name}$`,
@ -192,9 +168,8 @@ export default class DeviceCmd extends Command {
'status', 'status',
'is_online', 'is_online',
'ip_address', 'ip_address',
'public_address',
'mac_address', 'mac_address',
'fleet', 'application_name',
'last_seen', 'last_seen',
'uuid', 'uuid',
'commit', 'commit',

View File

@ -15,71 +15,49 @@
* limitations under the License. * limitations under the License.
*/ */
import { Flags, Command } from '@oclif/core'; import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags'; import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy'; import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { applicationIdInfo } from '../../utils/messages';
import { runCommand } from '../../utils/helpers'; import { runCommand } from '../../utils/helpers';
interface FlagsDef { interface FlagsDef {
fleet?: string; application?: string;
app?: string;
yes: boolean; yes: boolean;
advanced: boolean; advanced: boolean;
'os-version'?: string; 'os-version'?: string;
drive?: string; drive?: string;
config?: string; config?: string;
'provisioning-key-name'?: string; help: void;
'provisioning-key-expiry-date'?: string;
} }
export default class DeviceInitCmd extends Command { export default class DeviceInitCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
Initialize a device with balenaOS. Initialise a device with balenaOS.
Register a new device in the selected fleet, download the OS image for the Initialise a device by downloading the OS image of a certain application
fleet's default device type, configure the image and write it to an SD card. and writing it to an SD Card.
This command effectively combines several other balena CLI commands in one,
namely:
'balena device register'
'balena os download'
'balena os build-config' or 'balena config generate'
'balena os configure'
'balena os local flash'
Possible arguments for the '--fleet', '--os-version' and '--drive' options can
be listed respectively with the commands:
'balena fleet list'
'balena os versions'
'balena util available-drives'
If the '--fleet' or '--drive' options are omitted, interactive menus will be
presented with values to choose from. If the '--os-version' option is omitted,
the latest released OS version for the fleet's default device type will be used.
${applicationIdInfo.split('\n').join('\n\t\t')}
Image configuration questions will be asked interactively unless a pre-configured
'config.json' file is provided with the '--config' option. The file can be
generated with the 'balena config generate' or 'balena os build-config' commands.
`;
Note, if the application option is omitted it will be prompted
for interactively.
`;
public static examples = [ public static examples = [
'$ balena device init', '$ balena device init',
'$ balena device init -f myorg/myfleet', '$ balena device init --application MyApp',
'$ balena device init --fleet myFleet --os-version 2.101.7 --drive /dev/disk5 --config config.json --yes',
'$ balena device init --fleet myFleet --os-version 2.83.21+rev1.prod --drive /dev/disk5 --config config.json --yes',
]; ];
public static flags = { public static usage = 'device init';
fleet: cf.fleet,
public static flags: flags.Input<FlagsDef> = {
application: cf.application,
app: cf.app,
yes: cf.yes, yes: cf.yes,
advanced: Flags.boolean({ advanced: flags.boolean({
char: 'v', char: 'v',
description: 'show advanced configuration options', description: 'show advanced configuration options',
}), }),
'os-version': Flags.string({ 'os-version': flags.string({
description: stripIndent` description: stripIndent`
exact version number, or a valid semver range, exact version number, or a valid semver range,
or 'latest' (includes pre-releases), or 'latest' (includes pre-releases),
@ -89,22 +67,16 @@ export default class DeviceInitCmd extends Command {
`, `,
}), }),
drive: cf.drive, drive: cf.drive,
config: Flags.string({ config: flags.string({
description: 'path to the config JSON file, see `balena os build-config`', description: 'path to the config JSON file, see `balena os build-config`',
}), }),
'provisioning-key-name': Flags.string({ help: cf.help,
description: 'custom key name assigned to generated provisioning api key',
}),
'provisioning-key-expiry-date': Flags.string({
description:
'expiry date assigned to generated provisioning api key (format: YYYY-MM-DD)',
}),
}; };
public static authenticated = true; public static authenticated = true;
public async run() { public async run() {
const { flags: options } = await this.parse(DeviceInitCmd); const { flags: options } = this.parse<FlagsDef, {}>(DeviceInitCmd);
// Imports // Imports
const { promisify } = await import('util'); const { promisify } = await import('util');
@ -114,26 +86,31 @@ export default class DeviceInitCmd extends Command {
tmp.setGracefulCleanup(); tmp.setGracefulCleanup();
const { downloadOSImage } = await import('../../utils/cloud'); const { downloadOSImage } = await import('../../utils/cloud');
const { getApplication } = await import('../../utils/sdk'); const { getApplication } = await import('../../utils/sdk');
const Logger = await import('../../utils/logger');
const logger = Logger.getLogger(); const logger = await Command.getLogger();
const balena = getBalenaSdk(); const balena = getBalenaSdk();
// Consolidate application options
options.application = options.application || options.app;
delete options.app;
// Get application and // Get application and
const application = options.fleet const application = (await getApplication(
? await getApplication(balena, options.fleet, { balena,
$select: ['id', 'slug'], options['application'] ||
$expand: { (await (await import('../../utils/patterns')).selectApplication()),
is_for__device_type: { {
$select: 'slug', $expand: {
}, is_for__device_type: {
$select: 'slug',
}, },
}) },
: await (await import('../../utils/patterns')).selectApplication(); },
)) as ApplicationWithDeviceType;
// Register new device // Register new device
const deviceUuid = balena.models.device.generateUniqueKey(); const deviceUuid = balena.models.device.generateUniqueKey();
console.info(`Registering to ${application.slug}: ${deviceUuid}`); console.info(`Registering to ${application.app_name}: ${deviceUuid}`);
await balena.models.device.register(application.id, deviceUuid); await balena.models.device.register(application.id, deviceUuid);
const device = await balena.models.device.get(deviceUuid); const device = await balena.models.device.get(deviceUuid);
@ -155,7 +132,7 @@ export default class DeviceInitCmd extends Command {
try { try {
logger.logDebug(`Process failed, removing device ${device.uuid}`); logger.logDebug(`Process failed, removing device ${device.uuid}`);
await balena.models.device.remove(device.uuid); await balena.models.device.remove(device.uuid);
} catch { } catch (e) {
// Ignore removal failures, and throw original error // Ignore removal failures, and throw original error
} }
throw e; throw e;
@ -176,21 +153,6 @@ export default class DeviceInitCmd extends Command {
} else if (options.advanced) { } else if (options.advanced) {
configureCommand.push('--advanced'); configureCommand.push('--advanced');
} }
if (options['provisioning-key-name']) {
configureCommand.push(
'--provisioning-key-name',
options['provisioning-key-name'],
);
}
if (options['provisioning-key-expiry-date']) {
configureCommand.push(
'--provisioning-key-expiry-date',
options['provisioning-key-expiry-date'],
);
}
await runCommand(configureCommand); await runCommand(configureCommand);
} }

174
lib/commands/device/move.ts Normal file
View File

@ -0,0 +1,174 @@
/**
* @license
* Copyright 2016-2020 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.
*/
import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import type { Application, BalenaSDK } from 'balena-sdk';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { expandForAppName } from '../../utils/helpers';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
import { ExpectedError } from '../../errors';
interface ExtendedDevice extends DeviceWithDeviceType {
application_name?: string;
}
interface FlagsDef {
application?: string;
app?: string;
help: void;
}
interface ArgsDef {
uuid: string;
}
export default class DeviceMoveCmd extends Command {
public static description = stripIndent`
Move one or more devices to another application.
Move one or more devices to another application.
Note, if the application option is omitted it will be prompted
for interactively.
`;
public static examples = [
'$ balena device move 7cf02a6',
'$ balena device move 7cf02a6,dc39e52',
'$ balena device move 7cf02a6 --application MyNewApp',
];
public static args: Array<IArg<any>> = [
{
name: 'uuid',
description:
'comma-separated list (no blank spaces) of device UUIDs to be moved',
required: true,
},
];
public static usage = 'device move <uuid(s)>';
public static flags: flags.Input<FlagsDef> = {
application: cf.application,
app: cf.app,
help: cf.help,
};
public static authenticated = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
DeviceMoveCmd,
);
const balena = getBalenaSdk();
options.application = options.application || options.app;
delete options.app;
// Parse ids string into array of correct types
const deviceIds: Array<string | number> = params.uuid
.split(',')
.map((id) => tryAsInteger(id));
// Get devices
const devices = await Promise.all(
deviceIds.map(
(uuid) =>
balena.models.device.get(uuid, expandForAppName) as Promise<
ExtendedDevice
>,
),
);
// Map application name for each device
for (const device of devices) {
const belongsToApplication = device.belongs_to__application as Application[];
device.application_name = belongsToApplication?.[0]
? belongsToApplication[0].app_name
: 'N/a';
}
// Get destination application
const application =
options.application ||
(await this.interactivelySelectApplication(balena, devices));
// Move each device
for (const uuid of deviceIds) {
try {
await balena.models.device.move(uuid, tryAsInteger(application));
console.info(`${uuid} was moved to ${application}`);
} catch (err) {
console.info(`${err.message}, uuid: ${uuid}`);
process.exitCode = 1;
}
}
}
async interactivelySelectApplication(
balena: BalenaSDK,
devices: ExtendedDevice[],
) {
const [deviceDeviceTypes, deviceTypes] = await Promise.all([
Promise.all(
devices.map((device) =>
balena.models.device.getManifestBySlug(
device.is_of__device_type[0].slug,
),
),
),
balena.models.config.getDeviceTypes(),
]);
const compatibleDeviceTypes = deviceTypes.filter((dt) =>
deviceDeviceTypes.every(
(deviceDeviceType) =>
balena.models.os.isArchitectureCompatibleWith(
deviceDeviceType.arch,
dt.arch,
) &&
!!dt.isDependent === !!deviceDeviceType.isDependent &&
dt.state !== 'DISCONTINUED',
),
);
const patterns = await import('../../utils/patterns');
try {
const application = await patterns.selectApplication(
(app) =>
compatibleDeviceTypes.some(
(dt) => dt.slug === app.is_for__device_type[0].slug,
) &&
// @ts-ignore using the extended device object prop
devices.some((device) => device.application_name !== app.app_name),
true,
);
return application;
} catch (err) {
if (deviceDeviceTypes.length) {
throw new ExpectedError(
`${err.message}\nDo all devices have a compatible architecture?`,
);
}
throw err;
}
}
}

View File

@ -0,0 +1,150 @@
/**
* @license
* Copyright 2016-2020 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.
*/
import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent, getCliForm } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
import type { Device } from 'balena-sdk';
import { ExpectedError } from '../../errors';
interface FlagsDef {
version?: string;
yes: boolean;
help: void;
}
interface ArgsDef {
uuid: string;
}
export default class DeviceOsUpdateCmd extends Command {
public static description = stripIndent`
Start a Host OS update for a device.
Start a Host OS update for a device.
Note this command will ask for confirmation interactively.
This can be avoided by passing the \`--yes\` option.
Requires balenaCloud; will not work with openBalena or standalone balenaOS.
`;
public static examples = [
'$ balena device os-update 23c73a1',
'$ balena device os-update 23c73a1 --version 2.31.0+rev1.prod',
];
public static args: Array<IArg<any>> = [
{
name: 'uuid',
description: 'the uuid of the device to update',
parse: (dev) => tryAsInteger(dev),
required: true,
},
];
public static usage = 'device os-update <uuid>';
public static flags: flags.Input<FlagsDef> = {
version: flags.string({
description: 'a balenaOS version',
}),
yes: cf.yes,
help: cf.help,
};
public static authenticated = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
DeviceOsUpdateCmd,
);
const sdk = getBalenaSdk();
// Get device info
const {
uuid,
is_of__device_type,
os_version,
os_variant,
} = (await sdk.models.device.get(params.uuid, {
$select: ['uuid', 'os_version', 'os_variant'],
$expand: {
is_of__device_type: {
$select: 'slug',
},
},
})) as DeviceWithDeviceType;
// Get current device OS version
const currentOsVersion = sdk.models.device.getOsVersion({
os_version,
os_variant,
} as Device);
if (!currentOsVersion) {
throw new ExpectedError(
'The current os version of the device is not available',
);
}
// Get supported OS update versions
const hupVersionInfo = await sdk.models.os.getSupportedOsUpdateVersions(
is_of__device_type[0].slug,
currentOsVersion,
);
if (hupVersionInfo.versions.length === 0) {
throw new ExpectedError(
'There are no available Host OS update targets for this device',
);
}
// Get target OS version
let targetOsVersion = options.version;
if (targetOsVersion != null) {
if (!hupVersionInfo.versions.includes(targetOsVersion)) {
throw new ExpectedError(
`The provided version ${targetOsVersion} is not in the Host OS update targets for this device`,
);
}
} else {
targetOsVersion = await getCliForm().ask({
message: 'Target OS version',
type: 'list',
choices: hupVersionInfo.versions.map((version) => ({
name:
hupVersionInfo.recommended === version
? `${version} (recommended)`
: version,
value: version,
})),
});
}
const patterns = await import('../../utils/patterns');
// Confirm and start update
await patterns.confirm(
options.yes || false,
'Host OS updates require a device restart when they complete. Are you sure you want to proceed?',
);
await sdk.models.device.startOsUpdate(uuid, targetOsVersion);
await patterns.awaitDeviceOsUpdate(uuid, targetOsVersion);
}
}

View File

@ -15,9 +15,26 @@
* limitations under the License. * limitations under the License.
*/ */
import { Flags, Args, Command } from '@oclif/core'; import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import { ExpectedError } from '../../errors'; import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy'; import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
interface FlagsDef {
enable: boolean;
disable: boolean;
status: boolean;
help?: void;
}
interface ArgsDef {
uuid: string;
// Optional hidden arg to support old command format
legacyUuid?: string;
}
export default class DevicePublicUrlCmd extends Command { export default class DevicePublicUrlCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
@ -26,6 +43,9 @@ export default class DevicePublicUrlCmd extends Command {
This command will output the current public URL for the This command will output the current public URL for the
specified device. It can also enable or disable the URL, specified device. It can also enable or disable the URL,
or output the enabled status, using the respective options. or output the enabled status, using the respective options.
The old command style 'balena device public-url enable <uuid>'
is deprecated, but still supported.
`; `;
public static examples = [ public static examples = [
@ -35,33 +55,64 @@ export default class DevicePublicUrlCmd extends Command {
'$ balena device public-url 23c73a1 --status', '$ balena device public-url 23c73a1 --status',
]; ];
public static args = { public static args: Array<IArg<any>> = [
uuid: Args.string({ {
name: 'uuid',
description: 'the uuid of the device to manage', description: 'the uuid of the device to manage',
parse: (dev) => tryAsInteger(dev),
required: true, required: true,
}), },
}; {
// Optional hidden arg to support old command format
name: 'legacyUuid',
parse: (dev) => tryAsInteger(dev),
hidden: true,
},
];
public static flags = { public static usage = 'device public-url <uuid>';
enable: Flags.boolean({
public static flags: flags.Input<FlagsDef> = {
enable: flags.boolean({
description: 'enable the public URL', description: 'enable the public URL',
exclusive: ['disable', 'status'], exclusive: ['disable', 'status'],
}), }),
disable: Flags.boolean({ disable: flags.boolean({
description: 'disable the public URL', description: 'disable the public URL',
exclusive: ['enable', 'status'], exclusive: ['enable', 'status'],
}), }),
status: Flags.boolean({ status: flags.boolean({
description: 'determine if public URL is enabled', description: 'determine if public URL is enabled',
exclusive: ['enable', 'disable'], exclusive: ['enable', 'disable'],
}), }),
help: cf.help,
}; };
public static authenticated = true; public static authenticated = true;
public async run() { public async run() {
const { args: params, flags: options } = const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
await this.parse(DevicePublicUrlCmd); DevicePublicUrlCmd,
);
// Legacy command format support.
// Previously this command used the following format
// (changed due to oclif technicalities):
// `balena device public-url enable|disable|status <uuid>`
if (params.legacyUuid) {
const action = params.uuid;
if (!['enable', 'disable', 'status'].includes(action)) {
throw new ExpectedError(
`Unexpected arguments: ${params.uuid} ${params.legacyUuid}`,
);
}
options.enable = action === 'enable';
options.disable = action === 'disable';
options.status = action === 'status';
params.uuid = params.legacyUuid;
delete params.legacyUuid;
}
const balena = getBalenaSdk(); const balena = getBalenaSdk();

View File

@ -15,15 +15,26 @@
* limitations under the License. * limitations under the License.
*/ */
import { Args, Command } from '@oclif/core'; import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, getCliUx, stripIndent } from '../../utils/lazy'; import { getBalenaSdk, getCliUx, stripIndent } from '../../utils/lazy';
interface FlagsDef {
help: void;
}
interface ArgsDef {
uuid: string;
}
export default class DevicePurgeCmd extends Command { export default class DevicePurgeCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
Purge data from a device. Purge application data from a device.
Purge data from a device. Purge application data from a device.
This will clear the device's "/data" directory. This will clear the application's /data directory.
Multiple devices may be specified with a comma-separated list Multiple devices may be specified with a comma-separated list
of values (no spaces). of values (no spaces).
@ -33,26 +44,36 @@ export default class DevicePurgeCmd extends Command {
'$ balena device purge 55d43b3,23c73a1', '$ balena device purge 55d43b3,23c73a1',
]; ];
public static args = { public static usage = 'device purge <uuid>';
uuid: Args.string({
public static args: Array<IArg<any>> = [
{
name: 'uuid',
description: 'comma-separated list (no blank spaces) of device UUIDs', description: 'comma-separated list (no blank spaces) of device UUIDs',
required: true, required: true,
}), },
];
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
}; };
public static authenticated = true; public static authenticated = true;
public async run() { public async run() {
const { args: params } = await this.parse(DevicePurgeCmd); const { args: params } = this.parse<FlagsDef, ArgsDef>(DevicePurgeCmd);
const { tryAsInteger } = await import('../../utils/validation');
const balena = getBalenaSdk(); const balena = getBalenaSdk();
const ux = getCliUx(); const ux = getCliUx();
const deviceUuids = params.uuid.split(','); const deviceIds = params.uuid.split(',').map((id) => {
return tryAsInteger(id);
});
for (const uuid of deviceUuids) { for (const deviceId of deviceIds) {
ux.action.start(`Purging data from device ${uuid}`); ux.action.start(`Purging data from device ${deviceId}`);
await balena.models.device.purge(uuid); await balena.models.device.purge(deviceId);
ux.action.stop(); ux.action.stop();
} }
} }

View File

@ -15,9 +15,21 @@
* limitations under the License. * limitations under the License.
*/ */
import { Args, Command } from '@oclif/core'; import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import * as cf from '../../utils/common-flags'; import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy'; import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
interface FlagsDef {
force: boolean;
help: void;
}
interface ArgsDef {
uuid: string;
}
export default class DeviceRebootCmd extends Command { export default class DeviceRebootCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
@ -27,21 +39,28 @@ export default class DeviceRebootCmd extends Command {
`; `;
public static examples = ['$ balena device reboot 23c73a1']; public static examples = ['$ balena device reboot 23c73a1'];
public static args = { public static args: Array<IArg<any>> = [
uuid: Args.string({ {
name: 'uuid',
description: 'the uuid of the device to reboot', description: 'the uuid of the device to reboot',
parse: (dev) => tryAsInteger(dev),
required: true, required: true,
}), },
}; ];
public static flags = { public static usage = 'device reboot <uuid>';
public static flags: flags.Input<FlagsDef> = {
force: cf.force, force: cf.force,
help: cf.help,
}; };
public static authenticated = true; public static authenticated = true;
public async run() { public async run() {
const { args: params, flags: options } = await this.parse(DeviceRebootCmd); const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
DeviceRebootCmd,
);
const balena = getBalenaSdk(); const balena = getBalenaSdk();

View File

@ -0,0 +1,82 @@
/**
* @license
* Copyright 2016-2020 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.
*/
import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
interface FlagsDef {
uuid?: string;
help: void;
}
interface ArgsDef {
application: string;
}
export default class DeviceRegisterCmd extends Command {
public static description = stripIndent`
Register a device.
Register a device to an application.
`;
public static examples = [
'$ balena device register MyApp',
'$ balena device register MyApp --uuid <uuid>',
];
public static args: Array<IArg<any>> = [
{
name: 'application',
description: 'the name or id of application to register device with',
required: true,
},
];
public static usage = 'device register <application>';
public static flags: flags.Input<FlagsDef> = {
uuid: flags.string({
description: 'custom uuid',
char: 'u',
}),
help: cf.help,
};
public static authenticated = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
DeviceRegisterCmd,
);
const { getApplication } = await import('../../utils/sdk');
const balena = getBalenaSdk();
const application = await getApplication(balena, params.application);
const uuid = options.uuid ?? balena.models.device.generateUniqueKey();
console.info(`Registering to ${application.app_name}: ${uuid}`);
const result = await balena.models.device.register(application.id, uuid);
return result && result.uuid;
}
}

View File

@ -15,8 +15,21 @@
* limitations under the License. * limitations under the License.
*/ */
import { Args, Command } from '@oclif/core'; import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent, getCliForm } from '../../utils/lazy'; import { getBalenaSdk, stripIndent, getCliForm } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
interface FlagsDef {
help: void;
}
interface ArgsDef {
uuid: string;
newName?: string;
}
export default class DeviceRenameCmd extends Command { export default class DeviceRenameCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
@ -31,20 +44,29 @@ export default class DeviceRenameCmd extends Command {
'$ balena device rename 7cf02a6 MyPi', '$ balena device rename 7cf02a6 MyPi',
]; ];
public static args = { public static args: Array<IArg<any>> = [
uuid: Args.string({ {
name: 'uuid',
description: 'the uuid of the device to rename', description: 'the uuid of the device to rename',
parse: (dev) => tryAsInteger(dev),
required: true, required: true,
}), },
newName: Args.string({ {
name: 'newName',
description: 'the new name for the device', description: 'the new name for the device',
}), },
];
public static usage = 'device rename <uuid> [newName]';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
}; };
public static authenticated = true; public static authenticated = true;
public async run() { public async run() {
const { args: params } = await this.parse(DeviceRenameCmd); const { args: params } = this.parse<FlagsDef, ArgsDef>(DeviceRenameCmd);
const balena = getBalenaSdk(); const balena = getBalenaSdk();

View File

@ -15,7 +15,10 @@
* limitations under the License. * limitations under the License.
*/ */
import { Flags, Args, Command } from '@oclif/core'; import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, getCliUx, stripIndent } from '../../utils/lazy'; import { getBalenaSdk, getCliUx, stripIndent } from '../../utils/lazy';
import type { import type {
BalenaSDK, BalenaSDK,
@ -23,6 +26,15 @@ import type {
CurrentServiceWithCommit, CurrentServiceWithCommit,
} from 'balena-sdk'; } from 'balena-sdk';
interface FlagsDef {
help: void;
service?: string;
}
interface ArgsDef {
uuid: string;
}
export default class DeviceRestartCmd extends Command { export default class DeviceRestartCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
Restart containers on a device. Restart containers on a device.
@ -43,42 +55,51 @@ export default class DeviceRestartCmd extends Command {
'$ balena device restart 23c73a1 -s myService1,myService2', '$ balena device restart 23c73a1 -s myService1,myService2',
]; ];
public static args = { public static args: Array<IArg<any>> = [
uuid: Args.string({ {
name: 'uuid',
description: description:
'comma-separated list (no blank spaces) of device UUIDs to restart', 'comma-separated list (no blank spaces) of device UUIDs to restart',
required: true, required: true,
}), },
}; ];
public static flags = { public static usage = 'device restart <uuid>';
service: Flags.string({
public static flags: flags.Input<FlagsDef> = {
service: flags.string({
description: description:
'comma-separated list (no blank spaces) of service names to restart', 'comma-separated list (no blank spaces) of service names to restart',
char: 's', char: 's',
}), }),
help: cf.help,
}; };
public static authenticated = true; public static authenticated = true;
public async run() { public async run() {
const { args: params, flags: options } = await this.parse(DeviceRestartCmd); const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
DeviceRestartCmd,
);
const { tryAsInteger } = await import('../../utils/validation');
const balena = getBalenaSdk(); const balena = getBalenaSdk();
const ux = getCliUx(); const ux = getCliUx();
const deviceUuids = params.uuid.split(','); const deviceIds = params.uuid.split(',').map((id) => {
return tryAsInteger(id);
});
const serviceNames = options.service?.split(','); const serviceNames = options.service?.split(',');
// Iterate sequentially through deviceUuids. // Iterate sequentially through deviceIds.
// We may later want to add a batching feature, // We may later want to add a batching feature,
// so that n devices are processed in parallel // so that n devices are processed in parallel
for (const uuid of deviceUuids) { for (const deviceId of deviceIds) {
ux.action.start(`Restarting services on device ${uuid}`); ux.action.start(`Restarting services on device ${deviceId}`);
if (serviceNames) { if (serviceNames) {
await this.restartServices(balena, uuid, serviceNames); await this.restartServices(balena, deviceId, serviceNames);
} else { } else {
await this.restartAllServices(balena, uuid); await this.restartAllServices(balena, deviceId);
} }
ux.action.stop(); ux.action.stop();
} }
@ -86,7 +107,7 @@ export default class DeviceRestartCmd extends Command {
async restartServices( async restartServices(
balena: BalenaSDK, balena: BalenaSDK,
deviceUuid: string, deviceId: number | string,
serviceNames: string[], serviceNames: string[],
) { ) {
const { ExpectedError, instanceOf } = await import('../../errors'); const { ExpectedError, instanceOf } = await import('../../errors');
@ -95,7 +116,7 @@ export default class DeviceRestartCmd extends Command {
// Get device // Get device
let device: DeviceWithServiceDetails<CurrentServiceWithCommit>; let device: DeviceWithServiceDetails<CurrentServiceWithCommit>;
try { try {
device = await balena.models.device.getWithServiceDetails(deviceUuid, { device = await balena.models.device.getWithServiceDetails(deviceId, {
$expand: { $expand: {
is_running__release: { $select: 'commit' }, is_running__release: { $select: 'commit' },
}, },
@ -103,7 +124,7 @@ export default class DeviceRestartCmd extends Command {
} catch (e) { } catch (e) {
const { BalenaDeviceNotFound } = await import('balena-errors'); const { BalenaDeviceNotFound } = await import('balena-errors');
if (instanceOf(e, BalenaDeviceNotFound)) { if (instanceOf(e, BalenaDeviceNotFound)) {
throw new ExpectedError(`Device ${deviceUuid} not found.`); throw new ExpectedError(`Device ${deviceId} not found.`);
} else { } else {
throw e; throw e;
} }
@ -115,7 +136,7 @@ export default class DeviceRestartCmd extends Command {
serviceNames.forEach((service) => { serviceNames.forEach((service) => {
if (!device.current_services[service]) { if (!device.current_services[service]) {
throw new ExpectedError( throw new ExpectedError(
`Service ${service} not found on device ${deviceUuid}.`, `Service ${service} not found on device ${deviceId}.`,
); );
} }
}); });
@ -134,7 +155,7 @@ export default class DeviceRestartCmd extends Command {
if (serviceContainer) { if (serviceContainer) {
restartPromises.push( restartPromises.push(
balena.models.device.restartService( balena.models.device.restartService(
deviceUuid, deviceId,
serviceContainer.image_id, serviceContainer.image_id,
), ),
); );
@ -145,32 +166,32 @@ export default class DeviceRestartCmd extends Command {
await Promise.all(restartPromises); await Promise.all(restartPromises);
} catch (e) { } catch (e) {
if (e.message.toLowerCase().includes('no online device')) { if (e.message.toLowerCase().includes('no online device')) {
throw new ExpectedError(`Device ${deviceUuid} is not online.`); throw new ExpectedError(`Device ${deviceId} is not online.`);
} else { } else {
throw e; throw e;
} }
} }
} }
async restartAllServices(balena: BalenaSDK, deviceUuid: string) { async restartAllServices(balena: BalenaSDK, deviceId: number | string) {
// Note: device.restartApplication throws `BalenaDeviceNotFound: Device not found` if device not online. // Note: device.restartApplication throws `BalenaDeviceNotFound: Device not found` if device not online.
// Need to use device.get first to distinguish between non-existant and disconnected devices. // Need to use device.get first to distinguish between non-existant and offline devices.
// Remove this workaround when SDK issue resolved: https://github.com/balena-io/balena-sdk/issues/649 // Remove this workaround when SDK issue resolved: https://github.com/balena-io/balena-sdk/issues/649
const { instanceOf, ExpectedError } = await import('../../errors'); const { instanceOf, ExpectedError } = await import('../../errors');
try { try {
const device = await balena.models.device.get(deviceUuid); const device = await balena.models.device.get(deviceId);
if (!device.is_online) { if (!device.is_online) {
throw new ExpectedError(`Device ${deviceUuid} is not online.`); throw new ExpectedError(`Device ${deviceId} is not online.`);
} }
} catch (e) { } catch (e) {
const { BalenaDeviceNotFound } = await import('balena-errors'); const { BalenaDeviceNotFound } = await import('balena-errors');
if (instanceOf(e, BalenaDeviceNotFound)) { if (instanceOf(e, BalenaDeviceNotFound)) {
throw new ExpectedError(`Device ${deviceUuid} not found.`); throw new ExpectedError(`Device ${deviceId} not found.`);
} else { } else {
throw e; throw e;
} }
} }
await balena.models.device.restartApplication(deviceUuid); await balena.models.device.restartApplication(deviceId);
} }
} }

View File

@ -15,9 +15,21 @@
* limitations under the License. * limitations under the License.
*/ */
import { Args, Command } from '@oclif/core'; import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import * as cf from '../../utils/common-flags'; import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy'; import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
interface FlagsDef {
yes: boolean;
help: void;
}
interface ArgsDef {
uuid: string;
}
export default class DeviceRmCmd extends Command { export default class DeviceRmCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
@ -34,22 +46,28 @@ export default class DeviceRmCmd extends Command {
'$ balena device rm 7cf02a6 --yes', '$ balena device rm 7cf02a6 --yes',
]; ];
public static args = { public static args: Array<IArg<any>> = [
uuid: Args.string({ {
name: 'uuid',
description: description:
'comma-separated list (no blank spaces) of device UUIDs to be removed', 'comma-separated list (no blank spaces) of device UUIDs to be removed',
required: true, required: true,
}), },
}; ];
public static flags = { public static usage = 'device rm <uuid(s)>';
public static flags: flags.Input<FlagsDef> = {
yes: cf.yes, yes: cf.yes,
help: cf.help,
}; };
public static authenticated = true; public static authenticated = true;
public async run() { public async run() {
const { args: params, flags: options } = await this.parse(DeviceRmCmd); const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
DeviceRmCmd,
);
const balena = getBalenaSdk(); const balena = getBalenaSdk();
const patterns = await import('../../utils/patterns'); const patterns = await import('../../utils/patterns');
@ -66,7 +84,7 @@ export default class DeviceRmCmd extends Command {
// Remove // Remove
for (const uuid of uuids) { for (const uuid of uuids) {
try { try {
await balena.models.device.remove(uuid); await balena.models.device.remove(tryAsInteger(uuid));
} catch (err) { } catch (err) {
console.info(`${err.message}, uuid: ${uuid}`); console.info(`${err.message}, uuid: ${uuid}`);
process.exitCode = 1; process.exitCode = 1;

View File

@ -15,11 +15,23 @@
* limitations under the License. * limitations under the License.
*/ */
import { Args, Command } from '@oclif/core'; import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import * as cf from '../../utils/common-flags'; import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy'; import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
import { ExpectedError } from '../../errors'; import { ExpectedError } from '../../errors';
interface FlagsDef {
force: boolean;
help: void;
}
interface ArgsDef {
uuid: string;
}
export default class DeviceShutdownCmd extends Command { export default class DeviceShutdownCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
Shutdown a device. Shutdown a device.
@ -28,22 +40,28 @@ export default class DeviceShutdownCmd extends Command {
`; `;
public static examples = ['$ balena device shutdown 23c73a1']; public static examples = ['$ balena device shutdown 23c73a1'];
public static args = { public static args: Array<IArg<any>> = [
uuid: Args.string({ {
name: 'uuid',
description: 'the uuid of the device to shutdown', description: 'the uuid of the device to shutdown',
parse: (dev) => tryAsInteger(dev),
required: true, required: true,
}), },
}; ];
public static flags = { public static usage = 'device shutdown <uuid>';
public static flags: flags.Input<FlagsDef> = {
force: cf.force, force: cf.force,
help: cf.help,
}; };
public static authenticated = true; public static authenticated = true;
public async run() { public async run() {
const { args: params, flags: options } = const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
await this.parse(DeviceShutdownCmd); DeviceShutdownCmd,
);
const balena = getBalenaSdk(); const balena = getBalenaSdk();

View File

@ -0,0 +1,140 @@
/**
* @license
* Copyright 2016-2020 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.
*/
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { expandForAppName } from '../../utils/helpers';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
import type { Application } from 'balena-sdk';
interface ExtendedDevice extends DeviceWithDeviceType {
dashboard_url?: string;
application_name?: string | null;
device_type?: string | null;
}
interface FlagsDef {
application?: string;
app?: string;
help: void;
json: boolean;
}
export default class DevicesCmd extends Command {
public static description = stripIndent`
List all devices.
list all devices that belong to you.
You can filter the devices by application by using the \`--application\` option.
The --json option is recommended when scripting the output of this command,
because field names are less likely to change in JSON format and because it
better represents data types like arrays, empty strings and null values.
The 'jq' utility may be helpful for querying JSON fields in shell scripts
(https://stedolan.github.io/jq/manual/).
`;
public static examples = [
'$ balena devices',
'$ balena devices --application MyApp',
'$ balena devices --app MyApp',
'$ balena devices -a MyApp',
];
public static usage = 'devices';
public static flags: flags.Input<FlagsDef> = {
application: cf.application,
app: cf.app,
help: cf.help,
json: flags.boolean({
char: 'j',
description: 'produce JSON output instead of tabular output',
}),
};
public static primary = true;
public static authenticated = true;
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(DevicesCmd);
const balena = getBalenaSdk();
// Consolidate application options
options.application = options.application || options.app;
delete options.app;
let devices;
if (options.application != null) {
devices = (await balena.models.device.getAllByApplication(
tryAsInteger(options.application),
expandForAppName,
)) as ExtendedDevice[];
} else {
devices = (await balena.models.device.getAll(
expandForAppName,
)) as ExtendedDevice[];
}
devices = devices.map(function (device) {
device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid);
const belongsToApplication = device.belongs_to__application as Application[];
device.application_name = belongsToApplication?.[0]?.app_name || null;
device.uuid = device.uuid.slice(0, 7);
device.device_type = device.is_of__device_type?.[0]?.slug || null;
return device;
});
const fields = [
'id',
'uuid',
'device_name',
'device_type',
'application_name',
'status',
'is_online',
'supervisor_version',
'os_version',
'dashboard_url',
];
const _ = await import('lodash');
if (options.json) {
console.log(
JSON.stringify(
devices.map((device) => _.pick(device, fields)),
null,
4,
),
);
} else {
console.log(
getVisuals().table.horizontal(
devices.map((dev) => _.mapValues(dev, (val) => val ?? 'N/a')),
fields,
),
);
}
}
}

View File

@ -0,0 +1,119 @@
/**
* @license
* Copyright 2016-2019 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.
*/
import { flags } from '@oclif/command';
import type * as SDK from 'balena-sdk';
import * as _ from 'lodash';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import { CommandHelp } from '../../utils/oclif-utils';
interface FlagsDef {
discontinued: boolean;
help: void;
json?: boolean;
verbose?: boolean;
}
export default class DevicesSupportedCmd extends Command {
public static description = stripIndent`
List the supported device types (like 'raspberrypi3' or 'intel-nuc').
List the supported device types (like 'raspberrypi3' or 'intel-nuc').
The --verbose option adds extra columns/fields to the output, including the
"STATE" column whose values are one of 'new', 'released' or 'discontinued'.
However, 'discontinued' device types are only listed if the '--discontinued'
option is used.
The --json option is recommended when scripting the output of this command,
because the JSON format is less likely to change and it better represents data
types like lists and empty strings (for example, the ALIASES column contains a
list of zero or more values). The 'jq' utility may be helpful in shell scripts
(https://stedolan.github.io/jq/manual/).
`;
public static examples = [
'$ balena devices supported',
'$ balena devices supported --verbose',
'$ balena devices supported -vj',
];
public static usage = (
'devices supported ' +
new CommandHelp({ args: DevicesSupportedCmd.args }).defaultUsage()
).trim();
public static flags: flags.Input<FlagsDef> = {
discontinued: flags.boolean({
description: 'include "discontinued" device types',
}),
help: cf.help,
json: flags.boolean({
char: 'j',
description: 'produce JSON output instead of tabular output',
}),
verbose: flags.boolean({
char: 'v',
description:
'add extra columns in the tabular output (ALIASES, ARCH, STATE)',
}),
};
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(DevicesSupportedCmd);
const dts = await getBalenaSdk().models.config.getDeviceTypes();
let deviceTypes: Array<Partial<SDK.DeviceTypeJson.DeviceType>> = dts.map(
(d) => {
if (d.aliases && d.aliases.length) {
// remove aliases that are equal to the slug
d.aliases = d.aliases.filter((alias: string) => alias !== d.slug);
if (!options.json) {
// stringify the aliases array with commas and spaces
d.aliases = [d.aliases.join(', ')];
}
} else {
// ensure it is always an array (for the benefit of JSON output)
d.aliases = [];
}
return d;
},
);
if (!options.discontinued) {
deviceTypes = deviceTypes.filter((dt) => dt.state !== 'DISCONTINUED');
}
const fields = options.verbose
? ['slug', 'aliases', 'arch', 'state', 'name']
: ['slug', 'aliases', 'arch', 'name'];
deviceTypes = _.sortBy(
deviceTypes.map((d) => {
const picked = _.pick(d, fields);
// 'BETA' renamed to 'NEW'
picked.state = picked.state === 'BETA' ? 'NEW' : picked.state;
return picked;
}),
fields,
);
if (options.json) {
console.log(JSON.stringify(deviceTypes, null, 4));
} else {
const visuals = getVisuals();
const output = await visuals.table.horizontal(deviceTypes, fields);
console.log(output);
}
}
}

View File

@ -15,16 +15,18 @@
* limitations under the License. * limitations under the License.
*/ */
import { Args, Command } from '@oclif/core'; import { flags } from '@oclif/command';
import type * as BalenaSdk from 'balena-sdk'; import type * as BalenaSdk from 'balena-sdk';
import Command from '../../command';
import { ExpectedError } from '../../errors'; import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags'; import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy'; import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { applicationIdInfo } from '../../utils/messages';
interface FlagsDef { interface FlagsDef {
fleet?: string; application?: string; // application name
device?: string; // device UUID device?: string; // device UUID
help: void;
quiet: boolean; quiet: boolean;
service?: string; // service name service?: string; // service name
} }
@ -34,22 +36,20 @@ interface ArgsDef {
value?: string; value?: string;
} }
export default class EnvSetCmd extends Command { export default class EnvAddCmd extends Command {
public static aliases = ['env add'];
public static deprecateAliases = true;
public static description = stripIndent` public static description = stripIndent`
Add or update env or config variable to fleets, devices or services. Add env or config variable to application(s), device(s) or service(s).
Add or update an environment or config variable to one or more fleets, devices or Add an environment or config variable to one or more applications, devices
services, as selected by the respective command-line options. Either the or services, as selected by the respective command-line options. Either the
--fleet or the --device option must be provided, and either may be be --application or the --device option must be provided, and either may be be
used alongside the --service option to define a service-specific variable. used alongside the --service option to define a service-specific variable.
(A service corresponds to a Docker image/container in a microservices fleet.) (A service is an application container in a "microservices" application.)
When the --service option is used in conjunction with the --device option, When the --service option is used in conjunction with the --device option,
the service variable applies to the selected device only. Otherwise, it the service variable applies to the selected device only. Otherwise, it
applies to all devices of the selected fleet. If the --service option is applies to all devices of the selected application (i.e., the application's
omitted, the variable applies to all services. fleet). If the --service option is omitted, the variable applies to all
services.
If VALUE is omitted, the CLI will attempt to use the value of the environment If VALUE is omitted, the CLI will attempt to use the value of the environment
variable of same name in the CLI process' environment. In this case, a warning variable of same name in the CLI process' environment. In this case, a warning
@ -61,59 +61,58 @@ export default class EnvSetCmd extends Command {
running on devices. They are also stored differently in the balenaCloud API running on devices. They are also stored differently in the balenaCloud API
database. Configuration variables cannot be set for specific services, database. Configuration variables cannot be set for specific services,
therefore the --service option cannot be used when the variable name starts therefore the --service option cannot be used when the variable name starts
with a reserved prefix. When defining custom fleet variables, please avoid with a reserved prefix. When defining custom application variables, please
these reserved prefixes. avoid the reserved prefixes.
`;
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [ public static examples = [
'$ balena env set TERM --fleet MyFleet', '$ balena env add TERM --application MyApp',
'$ balena env set EDITOR vim -f myorg/myfleet', '$ balena env add EDITOR vim --application MyApp',
'$ balena env set EDITOR vim --fleet MyFleet,MyFleet2', '$ balena env add EDITOR vim --application MyApp,MyApp2',
'$ balena env set EDITOR vim --fleet MyFleet --service MyService', '$ balena env add EDITOR vim --application MyApp --service MyService',
'$ balena env set EDITOR vim --fleet MyFleet,MyFleet2 --service MyService,MyService2', '$ balena env add EDITOR vim --application MyApp,MyApp2 --service MyService,MyService2',
'$ balena env set EDITOR vim --device 7cf02a6', '$ balena env add EDITOR vim --device 7cf02a6',
'$ balena env set EDITOR vim --device 7cf02a6,d6f1433', '$ balena env add EDITOR vim --device 7cf02a6,d6f1433',
'$ balena env set EDITOR vim --device 7cf02a6 --service MyService', '$ balena env add EDITOR vim --device 7cf02a6 --service MyService',
'$ balena env set EDITOR vim --device 7cf02a6,d6f1433 --service MyService,MyService2', '$ balena env add EDITOR vim --device 7cf02a6,d6f1433 --service MyService,MyService2',
]; ];
public static args = { public static args = [
name: Args.string({ {
name: 'name',
required: true, required: true,
description: 'environment or config variable name', description: 'environment or config variable name',
}), },
value: Args.string({ {
name: 'value',
required: false, required: false,
description: description:
"variable value; if omitted, use value from this process' environment", "variable value; if omitted, use value from this process' environment",
}), },
}; ];
// Required for supporting empty string ('') `value` args. public static usage = 'env add <name> [value]';
public static strict = false;
public static flags = { public static flags: flags.Input<FlagsDef> = {
fleet: { ...cf.fleet, exclusive: ['device'] }, application: { exclusive: ['device'], ...cf.application },
device: { ...cf.device, exclusive: ['fleet'] }, device: { exclusive: ['application'], ...cf.device },
help: cf.help,
quiet: cf.quiet, quiet: cf.quiet,
service: cf.service, service: cf.service,
}; };
public async run() { public async run() {
const { args: params, flags: options } = await this.parse(EnvSetCmd); const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
EnvAddCmd,
);
const cmd = this; const cmd = this;
if (!options.fleet && !options.device) { if (!options.application && !options.device) {
throw new ExpectedError( throw new ExpectedError(
'Either the --fleet or the --device option must be specified', 'Either the --application or the --device option must always be used',
); );
} }
const { checkLoggedIn } = await import('../../utils/patterns'); await Command.checkLoggedIn();
await checkLoggedIn();
if (params.value == null) { if (params.value == null) {
params.value = process.env[params.name]; params.value = process.env[params.name];
@ -149,16 +148,16 @@ export default class EnvSetCmd extends Command {
} }
const varType = isConfigVar ? 'configVar' : 'envVar'; const varType = isConfigVar ? 'configVar' : 'envVar';
if (options.fleet) { if (options.application) {
for (const appSlug of await resolveFleetSlugs(balena, options.fleet)) { for (const app of options.application.split(',')) {
try { try {
await balena.models.application[varType].set( await balena.models.application[varType].set(
appSlug, app,
params.name, params.name,
params.value, params.value,
); );
} catch (err) { } catch (err) {
console.error(`${err.message}, fleet: ${appSlug}`); console.error(`${err.message}, app: ${app}`);
process.exitCode = 1; process.exitCode = 1;
} }
} }
@ -179,45 +178,26 @@ export default class EnvSetCmd extends Command {
} }
} }
// TODO: Stop accepting application names in the next major
// and just drop this in favor of doing the .split(',') directly.
async function resolveFleetSlugs(
balena: BalenaSdk.BalenaSDK,
fleetOption: string,
) {
const fleetSlugs: string[] = [];
const { getFleetSlug } = await import('../../utils/sdk');
for (const appNameOrSlug of fleetOption.split(',')) {
try {
fleetSlugs.push(await getFleetSlug(balena, appNameOrSlug));
} catch (err) {
console.error(`${err.message}, fleet: ${appNameOrSlug}`);
process.exitCode = 1;
}
}
return fleetSlugs;
}
/** /**
* Add service variables for a device or fleet. * Add service variables for a device or application.
*/ */
async function setServiceVars( async function setServiceVars(
sdk: BalenaSdk.BalenaSDK, sdk: BalenaSdk.BalenaSDK,
params: ArgsDef, params: ArgsDef,
options: FlagsDef, options: FlagsDef,
) { ) {
if (options.fleet) { if (options.application) {
for (const appSlug of await resolveFleetSlugs(sdk, options.fleet)) { for (const app of options.application.split(',')) {
for (const service of options.service!.split(',')) { for (const service of options.service!.split(',')) {
try { try {
const serviceId = await getServiceIdForApp(sdk, appSlug, service); const serviceId = await getServiceIdForApp(sdk, app, service);
await sdk.models.service.var.set( await sdk.models.service.var.set(
serviceId, serviceId,
params.name, params.name,
params.value!, params.value!,
); );
} catch (err) { } catch (err) {
console.error(`${err.message}, fleet: ${appSlug}`); console.error(`${err.message}, application: ${app}`);
process.exitCode = 1; process.exitCode = 1;
} }
} }
@ -232,7 +212,7 @@ async function setServiceVars(
sdk, sdk,
uuid, uuid,
['id'], ['id'],
['slug'], ['app_name'],
); );
} catch (err) { } catch (err) {
console.error(`${err.message}, device: ${uuid}`); console.error(`${err.message}, device: ${uuid}`);
@ -241,7 +221,11 @@ async function setServiceVars(
} }
for (const service of options.service!.split(',')) { for (const service of options.service!.split(',')) {
try { try {
const serviceId = await getServiceIdForApp(sdk, app.slug, service); const serviceId = await getServiceIdForApp(
sdk,
app.app_name,
service,
);
await sdk.models.device.serviceVar.set( await sdk.models.device.serviceVar.set(
device.id, device.id,
serviceId, serviceId,
@ -262,12 +246,11 @@ async function setServiceVars(
*/ */
async function getServiceIdForApp( async function getServiceIdForApp(
sdk: BalenaSdk.BalenaSDK, sdk: BalenaSdk.BalenaSDK,
appSlug: string, appName: string,
serviceName: string, serviceName: string,
): Promise<number> { ): Promise<number> {
let serviceId: number | undefined; let serviceId: number | undefined;
const services = await sdk.models.service.getAllByApplication(appSlug, { const services = await sdk.models.service.getAllByApplication(appName, {
$select: 'id',
$filter: { service_name: serviceName }, $filter: { service_name: serviceName },
}); });
if (services.length > 0) { if (services.length > 0) {
@ -275,7 +258,7 @@ async function getServiceIdForApp(
} }
if (serviceId === undefined) { if (serviceId === undefined) {
throw new ExpectedError( throw new ExpectedError(
`Cannot find service ${serviceName} for fleet ${appSlug}`, `Cannot find service ${serviceName} for application ${appName}`,
); );
} }
return serviceId; return serviceId;

View File

@ -14,16 +14,33 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import { Args, Command } from '@oclif/core'; import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import * as ec from '../../utils/env-common'; import * as ec from '../../utils/env-common';
import { getBalenaSdk, stripIndent } from '../../utils/lazy'; import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { parseAsInteger } from '../../utils/validation'; import { parseAsInteger } from '../../utils/validation';
type IArg<T> = import('@oclif/parser').args.IArg<T>;
interface FlagsDef {
config: boolean;
device: boolean;
service: boolean;
help: void;
}
interface ArgsDef {
id: number;
value: string;
}
export default class EnvRenameCmd extends Command { export default class EnvRenameCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
Change the value of a config or env var for a fleet, device or service. Change the value of a config or env var for an app, device or service.
Change the value of a configuration or environment variable for a fleet, Change the value of a configuration or environment variable for an application,
device or service, as selected by command-line options. device or service, as selected by command-line options.
${ec.rmRenameHelp.split('\n').join('\n\t\t')} ${ec.rmRenameHelp.split('\n').join('\n\t\t')}
@ -37,31 +54,36 @@ export default class EnvRenameCmd extends Command {
'$ balena env rename 678678 1 --device --config', '$ balena env rename 678678 1 --device --config',
]; ];
public static args = { public static args: Array<IArg<any>> = [
id: Args.integer({ {
name: 'id',
required: true, required: true,
description: "variable's numeric database ID", description: "variable's numeric database ID",
parse: (input) => parseAsInteger(input, 'id'), parse: (input) => parseAsInteger(input, 'id'),
}), },
value: Args.string({ {
name: 'value',
required: true, required: true,
description: description:
"variable value; if omitted, use value from this process' environment", "variable value; if omitted, use value from this process' environment",
}), },
}; ];
public static flags = { public static usage = 'env rename <id> <value>';
public static flags: flags.Input<FlagsDef> = {
config: ec.booleanConfig, config: ec.booleanConfig,
device: ec.booleanDevice, device: ec.booleanDevice,
service: ec.booleanService, service: ec.booleanService,
help: cf.help,
}; };
public async run() { public async run() {
const { args: params, flags: opt } = await this.parse(EnvRenameCmd); const { args: params, flags: opt } = this.parse<FlagsDef, ArgsDef>(
EnvRenameCmd,
);
const { checkLoggedIn } = await import('../../utils/patterns'); await Command.checkLoggedIn();
await checkLoggedIn();
await getBalenaSdk().pine.patch({ await getBalenaSdk().pine.patch({
resource: ec.getVarResourceName(opt.config, opt.device, opt.service), resource: ec.getVarResourceName(opt.config, opt.device, opt.service),

View File

@ -15,16 +15,31 @@
* limitations under the License. * limitations under the License.
*/ */
import { Flags, Args, Command } from '@oclif/core'; import { flags } from '@oclif/command';
import Command from '../../command';
import * as ec from '../../utils/env-common'; import * as ec from '../../utils/env-common';
import { getBalenaSdk, stripIndent } from '../../utils/lazy'; import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { parseAsInteger } from '../../utils/validation'; import { parseAsInteger } from '../../utils/validation';
type IArg<T> = import('@oclif/parser').args.IArg<T>;
interface FlagsDef {
config: boolean;
device: boolean;
service: boolean;
yes: boolean;
}
interface ArgsDef {
id: number;
}
export default class EnvRmCmd extends Command { export default class EnvRmCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
Remove a config or env var from a fleet, device or service. Remove a config or env var from an application, device or service.
Remove a configuration or environment variable from a fleet, device Remove a configuration or environment variable from an application, device
or service, as selected by command-line options. or service, as selected by command-line options.
${ec.rmRenameHelp.split('\n').join('\n\t\t')} ${ec.rmRenameHelp.split('\n').join('\n\t\t')}
@ -42,19 +57,22 @@ export default class EnvRmCmd extends Command {
'$ balena env rm 789789 --device --service --yes', '$ balena env rm 789789 --device --service --yes',
]; ];
public static args = { public static args: Array<IArg<any>> = [
id: Args.integer({ {
name: 'id',
required: true, required: true,
description: "variable's numeric database ID", description: "variable's numeric database ID",
parse: (input) => parseAsInteger(input, 'id'), parse: (input) => parseAsInteger(input, 'id'),
}), },
}; ];
public static flags = { public static usage = 'env rm <id>';
public static flags: flags.Input<FlagsDef> = {
config: ec.booleanConfig, config: ec.booleanConfig,
device: ec.booleanDevice, device: ec.booleanDevice,
service: ec.booleanService, service: ec.booleanService,
yes: Flags.boolean({ yes: flags.boolean({
char: 'y', char: 'y',
description: description:
'do not prompt for confirmation before deleting the variable', 'do not prompt for confirmation before deleting the variable',
@ -63,16 +81,18 @@ export default class EnvRmCmd extends Command {
}; };
public async run() { public async run() {
const { args: params, flags: opt } = await this.parse(EnvRmCmd); const { args: params, flags: opt } = this.parse<FlagsDef, ArgsDef>(
EnvRmCmd,
);
const { checkLoggedIn } = await import('../../utils/patterns'); await Command.checkLoggedIn();
await checkLoggedIn();
const { confirm } = await import('../../utils/patterns'); const { confirm } = await import('../../utils/patterns');
await confirm( await confirm(
opt.yes || false, opt.yes || false,
'Are you sure you want to delete the environment variable?', 'Are you sure you want to delete the environment variable?',
undefined,
true,
); );
const balena = getBalenaSdk(); const balena = getBalenaSdk();

442
lib/commands/envs.ts Normal file
View File

@ -0,0 +1,442 @@
/**
* @license
* Copyright 2016-2019 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.
*/
import { flags } from '@oclif/command';
import type * as SDK from 'balena-sdk';
import * as _ from 'lodash';
import Command from '../command';
import { ExpectedError } from '../errors';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
import { CommandHelp } from '../utils/oclif-utils';
import { isV12 } from '../utils/version';
interface FlagsDef {
all?: boolean; // whether to include application-wide, device-wide variables //TODO: REMOVE
application?: string; // application name
config: boolean;
device?: string; // device UUID
json: boolean;
help: void;
service?: string; // service name
verbose: boolean;
}
interface EnvironmentVariableInfo extends SDK.EnvironmentVariableBase {
appName?: string | null; // application name
deviceUUID?: string; // device UUID
serviceName?: string; // service name
}
interface DeviceServiceEnvironmentVariableInfo
extends SDK.DeviceServiceEnvironmentVariable {
appName?: string; // application name
deviceUUID?: string; // device UUID
serviceName?: string; // service name
}
interface ServiceEnvironmentVariableInfo
extends SDK.ServiceEnvironmentVariable {
appName?: string; // application name
deviceUUID?: string; // device UUID
serviceName?: string; // service name
}
export default class EnvsCmd extends Command {
public static description = isV12()
? stripIndent`
List the environment or config variables of an application, device or service.
List the environment or configuration variables of an application, device or
service, as selected by the respective command-line options. (A service is
an application container in a "microservices" application.)
The results include application-wide (fleet), device-wide (multiple services on
a device) and service-specific variables that apply to the selected application,
device or service. It can be thought of as including "inherited" variables;
for example, a service inherits device-wide variables, and a device inherits
application-wide variables.
The printed output may include DEVICE and/or SERVICE columns to distinguish
between application-wide, device-specific and service-specific variables.
An asterisk in these columns indicates that the variable applies to
"all devices" or "all services".
The --config option is used to list "configuration variables" that control
balena platform features, as opposed to custom environment variables defined
by the user. The --config and the --service options are mutually exclusive
because configuration variables cannot be set for specific services.
The --json option is recommended when scripting the output of this command,
because the JSON format is less likely to change and it better represents data
types like lists and empty strings. The 'jq' utility may be helpful in shell
scripts (https://stedolan.github.io/jq/manual/). When --json is used, an empty
JSON array ([]) is printed instead of an error message when no variables exist
for the given query. When querying variables for a device, note that the
application name may be null in JSON output (or 'N/A' in tabular output) if the
application linked to the device is no longer accessible by the current user
(for example, in case the current user has been removed from the application
by its owner).
`
: stripIndent`
List the environment or config variables of an application, device or service.
List the environment or configuration variables of an application, device or
service, as selected by the respective command-line options. (A service is
an application container in a "microservices" application.)
The --config option is used to list "configuration variables" that control
balena platform features, as opposed to custom environment variables defined
by the user. The --config and the --service options are mutually exclusive
because configuration variables cannot be set for specific services.
The --all option is used to include application-wide (fleet), device-wide
(multiple services on a device) and service-specific variables that apply to
the selected application, device or service. It can be thought of as including
"inherited" variables: for example, a service inherits device-wide variables,
and a device inherits application-wide variables. Variables are still filtered
out by type with the --config option, such that configuration and non-
configuration variables are never listed together.
When the --all option is used, the printed output may include DEVICE and/or
SERVICE columns to distinguish between application-wide, device-specific and
service-specific variables. An asterisk in these columns indicates that the
variable applies to "all devices" or "all services".
The --json option is recommended when scripting the output of this command,
because the JSON format is less likely to change and it better represents data
types like lists and empty strings. The 'jq' utility may be helpful in shell
scripts (https://stedolan.github.io/jq/manual/). When --json is used, an empty
JSON array ([]) is printed instead of an error message when no variables exist
for the given query. When querying variables for a device, note that the
application name may be null in JSON output (or 'N/A' in tabular output) if the
application linked to the device is no longer accessible by the current user
(for example, in case the current user has been removed from the application
by its owner).
`;
public static examples = isV12()
? [
'$ balena envs --application MyApp',
'$ balena envs --application MyApp --json',
'$ balena envs --application MyApp --service MyService',
'$ balena envs --application MyApp --service MyService',
'$ balena envs --application MyApp --config',
'$ balena envs --device 7cf02a6',
'$ balena envs --device 7cf02a6 --json',
'$ balena envs --device 7cf02a6 --config --json',
'$ balena envs --device 7cf02a6 --service MyService',
]
: [
'$ balena envs --application MyApp',
'$ balena envs --application MyApp --all --json',
'$ balena envs --application MyApp --service MyService',
'$ balena envs --application MyApp --all --service MyService',
'$ balena envs --application MyApp --config',
'$ balena envs --device 7cf02a6',
'$ balena envs --device 7cf02a6 --all --json',
'$ balena envs --device 7cf02a6 --config --all --json',
'$ balena envs --device 7cf02a6 --all --service MyService',
];
public static usage = (
'envs ' + new CommandHelp({ args: EnvsCmd.args }).defaultUsage()
).trim();
public static flags: flags.Input<FlagsDef> = {
...(isV12()
? {
all: flags.boolean({
description: stripIndent`
No-op since balena CLI v12.0.0.`,
hidden: true,
}),
}
: {
all: flags.boolean({
description: stripIndent`
include app-wide, device-wide variables that apply to the selected device or service.
Variables are still filtered out by type with the --config option.`,
}),
}),
application: { exclusive: ['device'], ...cf.application },
config: flags.boolean({
char: 'c',
description: 'show configuration variables only',
exclusive: ['service'],
}),
device: { exclusive: ['application'], ...cf.device },
help: cf.help,
json: flags.boolean({
char: 'j',
description: 'produce JSON output instead of tabular output',
}),
verbose: cf.verbose,
service: { exclusive: ['config'], ...cf.service },
};
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(EnvsCmd);
const variables: EnvironmentVariableInfo[] = [];
options.all = options.all || isV12();
await Command.checkLoggedIn();
if (!options.application && !options.device) {
throw new ExpectedError('You must specify an application or device');
}
const balena = getBalenaSdk();
let appName = options.application;
let fullUUID: string | undefined; // as oppposed to the short, 7-char UUID
if (options.device) {
const { getDeviceAndMaybeAppFromUUID } = await import('../utils/cloud');
const [device, app] = await getDeviceAndMaybeAppFromUUID(
balena,
options.device,
['uuid'],
['app_name'],
);
fullUUID = device.uuid;
if (app) {
appName = app.app_name;
}
}
if (appName && options.service) {
await validateServiceName(balena, options.service, appName);
}
if (options.application || options.all) {
variables.push(...(await getAppVars(balena, appName, options)));
}
if (fullUUID) {
variables.push(
...(await getDeviceVars(balena, fullUUID, appName, options)),
);
}
if (!options.json && variables.length === 0) {
const target =
(options.service ? `service "${options.service}" of ` : '') +
(options.application
? `application "${options.application}"`
: `device "${options.device}"`);
throw new ExpectedError(`No environment variables found for ${target}`);
}
await this.printVariables(variables, options);
}
protected async printVariables(
varArray: EnvironmentVariableInfo[],
options: FlagsDef,
) {
const fields = ['id', 'name', 'value'];
if (options.all) {
// Replace undefined app names with 'N/A' or null
varArray = varArray.map((i: EnvironmentVariableInfo) => {
i.appName = i.appName || (options.json ? null : 'N/A');
return i;
});
fields.push(options.json ? 'appName' : 'appName => APPLICATION');
if (options.device) {
fields.push(options.json ? 'deviceUUID' : 'deviceUUID => DEVICE');
}
if (!options.config) {
fields.push(options.json ? 'serviceName' : 'serviceName => SERVICE');
}
}
if (options.json) {
this.log(
stringifyVarArray<SDK.EnvironmentVariableBase>(varArray, fields),
);
} else {
this.log(
getVisuals().table.horizontal(
_.sortBy(varArray, (v: SDK.EnvironmentVariableBase) => v.name),
fields,
),
);
}
}
}
async function validateServiceName(
sdk: SDK.BalenaSDK,
serviceName: string,
appName: string,
) {
const services = await sdk.models.service.getAllByApplication(appName, {
$filter: { service_name: serviceName },
});
if (services.length === 0) {
throw new ExpectedError(
`Service "${serviceName}" not found for application "${appName}"`,
);
}
}
/**
* Fetch application-wide config / env / service vars.
* If options.application is undefined, an attempt is made to obtain the
* application name from the device UUID (options.device). If this attempt
* fails because the device does not belong to any application, an emtpy
* array is returned.
*/
async function getAppVars(
sdk: SDK.BalenaSDK,
appName: string | undefined,
options: FlagsDef,
): Promise<EnvironmentVariableInfo[]> {
const appVars: EnvironmentVariableInfo[] = [];
if (!appName) {
return appVars;
}
if (options.config || options.all || !options.service) {
const vars = await sdk.models.application[
options.config ? 'configVar' : 'envVar'
].getAllByApplication(appName);
fillInInfoFields(vars, appName);
appVars.push(...vars);
}
if (!options.config && (options.service || options.all)) {
const pineOpts: SDK.PineOptions<SDK.ServiceEnvironmentVariable> = {
$expand: {
service: {},
},
};
if (options.service) {
pineOpts.$filter = {
service: {
service_name: options.service,
},
};
}
const serviceVars = await sdk.models.service.var.getAllByApplication(
appName,
pineOpts,
);
fillInInfoFields(serviceVars, appName);
appVars.push(...serviceVars);
}
return appVars;
}
/**
* Fetch config / env / service vars when the '--device' option is provided.
* Precondition: options.device must be defined.
*/
async function getDeviceVars(
sdk: SDK.BalenaSDK,
fullUUID: string,
appName: string | undefined,
options: FlagsDef,
): Promise<EnvironmentVariableInfo[]> {
const printedUUID = options.json ? fullUUID : options.device!;
const deviceVars: EnvironmentVariableInfo[] = [];
if (options.config) {
const deviceConfigVars = await sdk.models.device.configVar.getAllByDevice(
fullUUID,
);
fillInInfoFields(deviceConfigVars, appName, printedUUID);
deviceVars.push(...deviceConfigVars);
} else {
if (options.service || options.all) {
const pineOpts: SDK.PineOptions<SDK.DeviceServiceEnvironmentVariable> = {
$expand: {
service_install: {
$expand: 'installs__service',
},
},
};
if (options.service) {
pineOpts.$filter = {
service_install: {
installs__service: { service_name: options.service },
},
};
}
const deviceServiceVars = await sdk.models.device.serviceVar.getAllByDevice(
fullUUID,
pineOpts,
);
fillInInfoFields(deviceServiceVars, appName, printedUUID);
deviceVars.push(...deviceServiceVars);
}
if (!options.service || options.all) {
const deviceEnvVars = await sdk.models.device.envVar.getAllByDevice(
fullUUID,
);
fillInInfoFields(deviceEnvVars, appName, printedUUID);
deviceVars.push(...deviceEnvVars);
}
}
return deviceVars;
}
/**
* For each env var object in varArray, fill in its top-level serviceName
* and deviceUUID fields. An asterisk is used to indicate that the variable
* applies to "all services" or "all devices".
*/
function fillInInfoFields(
varArray:
| EnvironmentVariableInfo[]
| DeviceServiceEnvironmentVariableInfo[]
| ServiceEnvironmentVariableInfo[],
appName?: string,
deviceUUID?: string,
) {
for (const envVar of varArray) {
if ('service' in envVar) {
// envVar is of type ServiceEnvironmentVariableInfo
envVar.serviceName = (envVar.service as SDK.Service[])[0]?.service_name;
} else if ('service_install' in envVar) {
// envVar is of type DeviceServiceEnvironmentVariableInfo
envVar.serviceName = ((envVar.service_install as SDK.ServiceInstall[])[0]
?.installs__service as SDK.Service[])[0]?.service_name;
}
envVar.appName = appName;
envVar.serviceName = envVar.serviceName || '*';
envVar.deviceUUID = deviceUUID || '*';
}
}
/**
* Transform each object (item) of varArray to preserve only the
* fields (keys) listed in the fields argument.
*/
function stringifyVarArray<T = Dictionary<any>>(
varArray: T[],
fields: string[],
): string {
const transformed = varArray.map((o: Dictionary<any>) =>
_.transform(
o,
(result, value, key) => {
if (fields.includes(key)) {
result[key] = value;
}
},
{} as Dictionary<any>,
),
);
return JSON.stringify(transformed, null, 4);
}

View File

@ -15,8 +15,9 @@
* limitations under the License. * limitations under the License.
*/ */
import { Args, Command } from '@oclif/core'; import Command from '../../command';
import { stripIndent } from '../../utils/lazy'; import { stripIndent } from '../../utils/lazy';
import { CommandHelp } from '../../utils/oclif-utils';
// 'Internal' commands are called during the execution of other commands. // 'Internal' commands are called during the execution of other commands.
// `osinit` is called during `os initialize` // `osinit` is called during `os initialize`
@ -26,6 +27,12 @@ import { stripIndent } from '../../utils/lazy';
// - https://github.com/balena-io/balena-cli/pull/1455#discussion_r334308357 // - https://github.com/balena-io/balena-cli/pull/1455#discussion_r334308357
// - https://github.com/balena-io/balena-cli/pull/1455#discussion_r334308526 // - https://github.com/balena-io/balena-cli/pull/1455#discussion_r334308526
interface ArgsDef {
image: string;
type: string;
config: string;
}
export default class OsinitCmd extends Command { export default class OsinitCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
Do actual init of the device with the preconfigured os image. Do actual init of the device with the preconfigured os image.
@ -34,24 +41,31 @@ export default class OsinitCmd extends Command {
Use \`balena os initialize <image>\` instead. Use \`balena os initialize <image>\` instead.
`; `;
public static args = { public static args = [
image: Args.string({ {
name: 'image',
required: true, required: true,
}), },
type: Args.string({ {
name: 'type',
required: true, required: true,
}), },
config: Args.string({ {
name: 'config',
required: true, required: true,
}), },
}; ];
public static usage = (
'internal osinit ' +
new CommandHelp({ args: OsinitCmd.args }).defaultUsage()
).trim();
public static hidden = true; public static hidden = true;
public static root = true; public static root = true;
public static offlineCompatible = true;
public async run() { public async run() {
const { args: params } = await this.parse(OsinitCmd); const { args: params } = this.parse<{}, ArgsDef>(OsinitCmd);
const config = JSON.parse(params.config); const config = JSON.parse(params.config);

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