Compare commits

..

2 Commits

Author SHA1 Message Date
27363d34cc v11.18.3 2022-10-25 13:37:05 +03:00
7b3a8b4ecf Add NYC code coverage report
After running tests, a code coverage report can be found at ./coverage/lcov-report/index.html

Change-type: patch
Signed-off-by: Lucian <lucian.buzzo@gmail.com>
2019-11-19 11:51:27 +00:00
438 changed files with 35219 additions and 101192 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

16
.gitattributes vendored
View File

@ -1,16 +0,0 @@
# Set all files to use line feed endings (since we can't match only ones without an extension)
* eol=lf
# And then reset all the files with extensions back to default
*.* -eol
*.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
docs/balena-cli.md text eol=lf
# crlf for the eol conversion test files
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

1
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1 @@
* @CameronDiver @hedss @pdcastro @srlowe @thgreasi

View File

@ -1,76 +1,16 @@
# About this issue tracker
*The balena CLI (Command Line Interface) is a tool used to interact with the balena platform.
This GitHub issue tracker is used for bug reports and feature requests regarding the CLI
tool. General and troubleshooting questions (such as setting up your project to work with a
balenalib base image) are encouraged to be posted to the [balena
forums](https://forums.balena.io), which are monitored by balena's support team and where the
community can both contribute and benefit from the answers.*
*Please also check that this issue is not a duplicate. If there is another issue describing
the same problem or feature please add comments to the existing issue.*
*Thank you for your time and effort creating the issue report, and helping us improve
the balena CLI!*
- **balena CLI version:** e.g. 11.2.1 (output of the `"balena version"` command)
- **Operating system version:** e.g. Windows 10, Ubuntu 18.04, macOS 10.14.5
- **32/64 bit OS and processor:** e.g. 32-bit Windows on 64-bit Intel processor
- **Install method:** npm or zip or executable installer
- **If npm install, Node.js and npm version:** e.g. Node v8.16.0 and npm v6.4.1
---
# Expected Behavior
*Please keep in mind that we try to use the issue tracker of this repository for specific bug
reports & CLI feature requests. General & troubleshooting questions are encouraged to be posted to
the [balena forums](https://forums.balena.io), which are monitored by balena's support team and
where the community can both contribute and benefit from the answers.*
Please describe what you were expecting to happen. If applicable, please add links to
documentation you were following, or to projects that you were trying to push/build.
# Actual Behavior
Please describe what actually happened instead:
* Quoting logs and error message is useful. If possible, quote the **full** output of the
CLI, not just the error message.
* Please quote the **full command line** too. Sometimes users report that they were
"pushing" or "building" a project, but there are several ways to do so and several
possible "targets" such as balenaCloud, openBalena, local balenaOS device, etc.
Examples:
```
balena push myFleet
balena push 192.168.0.12
balena deploy myFleet
balena deploy myFleet --build
balena build . -f myFleet
balena build . -A armv7hf -d raspberrypi3
```
Each of the above command lines executes different code behind the scenes, so quoting the
full command line is very helpful.
Running the CLI in debug mode (`--debug` flag or `DEBUG=1` environment variable) may reveal
additional information. The `--logs` option reveals additional information for the commands:
```
balena build . --logs
balena deploy myFleet --build --logs
```
# Steps to Reproduce the Problem
This is the most important and helpful part of a bug report. If we cannot reproduce the
problem, it is difficult to tell what the fix should be, or whether code changes have
fixed it.
1.
1.
1.
# Specifications
- **balena CLI version:** e.g. 1.2.3 (output of the `"balena version -a"` command)
- **Cloud backend: openBalena or balenaCloud?** If unsure, it will be balenaCloud
- **Operating system version:** e.g. Windows 10, Ubuntu 18.04, macOS 10.14.5
- **32/64 bit OS and processor:** e.g. 32-bit Windows on 64-bit Intel processor
- **Install method:** npm or standalone package or executable installer
- **If npm install, Node.js and npm version:** e.g. Node v8.16.0 and npm v6.4.1
# Additional References
If applicable, please add additional links to GitHub projects, forums.balena.io threads,
gist.github.com, Google Drive attachments, etc.
*Before submitting this issue please check that this issue is not a duplicate. If there is another
issue describing the same problem or feature please add your information to the existing issue's
comments.*

View File

@ -1,26 +1,11 @@
<!-- You can remove tags that do not apply. -->
Resolves: # <!-- Refer an issue of this repository that this PR fixes -->
Change-type: major|minor|patch <!-- See https://semver.org/ -->
Depends-on: <url> <!-- This change depends on a PR to get merged/deployed first -->
See: <url> <!-- Refer to any external resource, like a PR, document or discussion -->
Resolves: # <!-- Refer an issue of this repository that this PR fixes -->
See: <url> <!-- Refer to any external resource, like a PR, document or discussion -->
Depends-on: <url> <!-- This change depends on a PR to get merged/deployed first -->
Change-type: major|minor|patch <!-- The change type of this PR -->
---
Please check the CONTRIBUTING.md file for relevant information and some
guidance. Keep in mind that the CLI is a cross-platform application that runs
on Windows, macOS and Linux. Tests will be automatically run by balena CI on
all three operating systems, but this will only help if you have added test
code that exercises the modified or added feature code.
Note that each commit message (currently only the first line) will be
automatically copied to the CHANGELOG.md file, so try writing it in a way
that describes the feature or fix for CLI users.
If there isn't a linked issue or if the linked issue doesn't quite match the
PR, please add a PR description to explain its purpose or the features that it
implements. Adding PR comments to blocks of code that aren't self explanatory
usually helps with the review process.
If the PR introduces security considerations or affects the development, build
or release process, please be sure to highlight this in the PR description.
Thank you very much for your contribution!
##### Contributor checklist
<!-- For completed items, change [ ] to [x]. -->
- [ ] Introduces security considerations
- [ ] Affects the development, build or deployment processes of the component

View File

@ -1,145 +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: '22.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@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
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@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # 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
smctl.exe windows certsync
/c/Windows/System32/certutil.exe -csp "DigiCert Signing Manager KSP" -key -user
# (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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: gh-release-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}-${{ strategy.job-index }}
path: |
dist
!dist/balena
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: '22.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@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # 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

70
.gitignore vendored
View File

@ -1,36 +1,46 @@
# Reminders:
# * A pattern without '/' matches in subdirectories as well (files and directories)
# * 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
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
/.idea/
/.lock-wscript
/.nvmrc
/.nyc_output/
/.vscode/
/coverage/
/lib-cov/
/logs
/pids
# OS cache files
.DS_Store
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# balena CLI config and build files
.balenaconf
.fast-boot.json
# Coverage directory used by tools like istanbul/nyc
coverage
.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
/balenarc.yml
/build/
/build-bin/
/dist/
/node_modules
/oclif.manifest.json
/package-lock.json
/resinrc.yml
/tmp/
.balenaconf
resinrc.yml
balenarc.yml
.DS_Store
.idea
.nvmrc
.vscode
/tmp
build/
build-bin/
build-zip/
dist/
# Ignore fast-boot cache file
**/.fast-boot.json

View File

@ -1,2 +1,5 @@
coffee_script:
config_file: coffeelint.json
javascript:
enabled: false

View File

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

View File

@ -1,6 +0,0 @@
const commonConfig = require('./.mocharc.js');
module.exports = {
...commonConfig,
spec: ['tests/auth/*.spec.ts', 'tests/commands/**/*.spec.ts'],
};

View File

@ -1,10 +0,0 @@
module.exports = {
reporter: 'spec',
require: 'ts-node/register/transpile-only',
file: './tests/config-tests',
timeout: 48000,
// 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',
};

36
.resinci.yml Normal file
View File

@ -0,0 +1,36 @@
---
npm:
platforms:
- name: linux
os: alpine
architecture: x86_64
node_versions:
- "8"
- "10"
- name: linux
os: alpine
architecture: x86
node_versions:
- "8"
- "10"
- name: darwin
os: macos
architecture: x86_64
node_versions:
- "8"
- "10"
- name: windows
os: windows
architecture: x86_64
node_versions:
- "8"
- "10"
- name: windows
os: windows
architecture: x86
node_versions:
- "8"
- "10"
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

@ -2,314 +2,64 @@
The balena CLI is an open source project and your contribution is welcome!
* Install the dependencies listed in the [NPM Installation
section](./INSTALL-ADVANCED.md#npm-installation) section of the installation instructions. Check
the section [Additional Dependencies](./INSTALL-ADVANCED.md#additional-dependencies) too.
* Clone the `balena-cli` repository (or a [forked
repo](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo),
if you are not in the balena team), `cd` to it and run `npm install`.
* Build the CLI with `npm run build` or `npm test`, and execute it with `./bin/balena`
(on a Windows command prompt, you may need to run `node .\bin\balena`).
In order to ease development:
After cloning this repository and running `npm install`, the CLI can be built with `npm run build`
and executed with `./bin/balena`. In order to ease development:
* `npm run build:fast` skips some of the build steps for interactive testing, or
* `npm run test:source` skips testing the standalone packages (which is rather slow)
* `./bin/balena-dev` uses `ts-node/register` to transpile on the fly.
* `./bin/balena-dev` uses `ts-node/register` and `coffeescript/register` to transpile on the fly.
Before opening a PR, test your changes with `npm test`. Keep compatibility in mind, as the CLI is
meant to run on Linux, macOS and Windows. balena CI will run test code on all three platforms, but
this will only help if you add some test cases for your new code!
Before opening a PR, please be sure to test your changes with `npm test`.
## Semantic versioning, commit messages and the ChangeLog
## Semantic versioning and commit messages
When a pull request is merged, Balena's versionbot / Continuous Integration system takes care of
automatically creating a new CLI release on both the [npm
registry](https://www.npmjs.com/package/balena-cli) and the GitHub [releases
page](https://github.com/balena-io/balena-cli/releases). The release version numbering adheres to
the [Semantic Versioning's](http://semver.org/) concept of patch, minor and major releases.
Generally, bug fixes and documentation changes are classed as patch changes, while new features are
classed as minor changes. If a change breaks backwards compatibility, it is a major change.
A new version entry is also automatically added to the
[CHANGELOG.md](https://github.com/balena-io/balena-cli/blob/master/CHANGELOG.md) file when a pull
request is merged. Each pull request corresponds to a single version / release. Each commit in the
pull request becomes a bullet point entry in the Changelog. The Changelog file should not be
manually edited.
To support this automation, a commit message should be structured as follows:
```text
The first line becomes a bullet point in the CHANGELOG file
Optionally, a more detailed description in one or more paragraphs.
The detailed description can be seen with `git log`, but it is not copied
to the CHANGELOG file.
The CLI version numbering adheres to [Semantic Versioning](http://semver.org/). The following
header/row is required in the body of a commit message, and will cause the CI build to fail if absent:
```
Change-type: patch|minor|major
```
Only the first line of the commit message is copied to the Changelog file. The `Change-type` footer
must be preceded by a blank line, and indicates the commit's semver change type. When a PR consists
of multiple commits, the commits may have different change type values. As a whole, the PR will
produce a release of the "highest" change type. For example, two commits mixing patch and minor
change types will produce a minor CLI release, while two commits mixing minor and major change
types will produce a major CLI release.
Version numbers and commit messages are automatically added to the `CHANGELOG.md` file by the CI
build flow, after a pull request is merged. It should not be manually edited.
The commit message is parsed / checked by versionbot with the
[resin-commit-lint](https://github.com/balena-io-modules/resin-commit-lint#resin-commit-lint)
package.
## Editing documentation files (CHANGELOG, README, website...)
Because of the way that the Changelog file is automatically updated from commit messages, which
become the source of "what's new" for CLI end users, we advocate "meaningful commits" and
user-focused commit messages. A meaningful commit is one that, in isolation, introduces a fix or
feature (or part of a fix or feature) that makes sense at the Changelog level, and which leaves the
CLI in a non-broken state. Sometimes, in the course of preparing a single pull request, a developer
creates several commits as a way of saving their "work in progress", which may even fail to build
(e.g. `npm run build` fails), and which is then fixed or undone by further commits in the same PR.
In this situation, the recommendation is to "squash" or "fixup" the work-in-progress commits into
fewer, meaningful commits. Interactive rebase is a good tool to achieve this:
[blog](https://thoughtbot.com/blog/git-interactive-rebase-squash-amend-rewriting-history),
[docs](https://git-scm.com/book/en/v2/Git-Tools-Rewriting-History).
Mixing multiple distinct features or bug fixes in a single commit is discouraged, because the
description will likely not fit in the single-line Changelog bullet point and also because it
makes it harder to review the pull request (especially a large one) and harder to isolate and
revert individual changes in case a bug is found later on. Create a separate commit for each
feature / bug fix, or even separate pull requests.
If you need to catch up with changes to the master branch while working on a pull request,
use rebase instead of merge: [docs](https://git-scm.com/book/en/v2/Git-Branching-Rebasing).
If `package.json` is updated for dependencies listed in the `repo.yml` file (like `balena-sdk`),
the commit message body should also include a line in the following format:
```
Update balena-sdk from 12.0.0 to 12.1.0
```
This allows versionbot to produce nested Changelog entries (with expandable arrows), pulling in
commit messages from the upstream repositories. The following npm script can be used to
automatically produce a commit with a suitable commit message:
```
npm run update balena-sdk ^12.1.0
```
The script will create a new branch (only if `master` is currently checked out), run `npm update`
with the given target version and commit the `package.json` and `npm-shrinkwrap.json` files. The
script by default will set the `Change-type` to `patch` or `minor`, depending on the semver change
of the updated dependency. A `major` change type can specified as an extra argument:
```
npm run update balena-sdk ^12.14.0 patch
npm run update balena-sdk ^13.0.0 major
```
## 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
[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/).
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
sections](https://github.com/balena-io/balena-cli/blob/v12.23.0/automation/capitanodoc/capitanodoc.ts#L199-L204)
of the README file.
* The CLI's command documentation in source code (`src/commands/` folder), for example:
* `src/commands/push.ts`
* `src/commands/env/add.ts`
* Selected sections of the README file.
* The CLI's command documentation in source code (both Capitano and oclif commands), for example:
* `lib/actions/build.coffee`
* `lib/actions-oclif/env/add.ts`
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).
**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.
## 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.
The `INSTALL.md` and `TROUBLESHOOTING.md` files are also manually edited.
## Windows
Besides the regular npm installation dependencies, the `npm run build:installer` script
that produces the `.exe` graphical installer on Windows also requires
[NSIS](https://sourceforge.net/projects/nsis/) and [MSYS2](https://www.msys2.org/) to be
installed. Be sure to add `C:\Program Files (x86)\NSIS` to the PATH, so that `makensis`
is available. MSYS2 is recommended when developing the balena CLI on Windows.
Please note that `npm run build:installer` (which generates the `.exe` executable installer on
Windows) requires [MSYS2](https://www.msys2.org/) to be installed. Other than that, the standard
Command Prompt or PowerShell can be used.
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.
## TypeScript vs CoffeeScript, and Capitano vs oclif
## Updating the 'npm-shrinkwrap.json' file
The CLI was originally written in [CoffeeScript](https://coffeescript.org), but we decided to
migrate to [TypeScript](https://www.typescriptlang.org/) in order to take advantage of static
typing and formal programming interfaces. The migration is taking place gradually, as part of
maintenance work or the implementation of new features.
The `npm-shrinkwrap.json` file is used to control package dependencies, as documented at
https://docs.npmjs.com/files/shrinkwrap.json.
Changes to `npm-shrinkwrap.json` can be automatically merged by git during operations like
`rebase`, `pull` and `cherry-pick`, but in some cases this results in suboptimal dependency
resolution (the `node_modules` folder may end up larger than necessary, with consequences to CLI
load time too). For this reason, the recommended way to update `npm-shrinkwrap.json` is to run
`npm install`, possibly alongside `npm dedupe` as well. The following commands can be used to
fix shrinkwrap issues and optimize the dependencies:
```sh
git checkout master -- npm-shrinkwrap.json
rm -rf node_modules
npm install # update npm-shrinkwrap.json to satisfy changes to package.json
npm dedupe # deduplicate dependencies from npm-shrinkwrap.json
npm install # re-add optional dependencies removed by dedupe
git add npm-shrinkwrap.json # add it for committing (solve merge errors)
```
Note that `npm dedupe` should always be followed by `npm install`, as shown above, even if
`npm install` had already been executed before `npm dedupe`.
Optionally, these steps may be automated by installing the
[npm-merge-driver](https://www.npmjs.com/package/npm-merge-driver):
```sh
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
The CLI currently contains a mix of plain JavaScript and
[TypeScript](https://www.typescriptlang.org/) code. The goal is to have all code written in
Typescript, in order to take advantage of static typing and formal programming interfaces.
The migration towards Typescript is taking place gradually, as part of maintenance work or
the implementation of new features.
Of historical interest, the CLI was originally written in [CoffeeScript](https://coffeescript.org)
and used the [Capitano](https://github.com/balena-io/capitano) framework. All CoffeeScript code was
migrated to either Javascript or Typescript, and Capitano was replaced with oclif. A few file or
variable names still refer to this legacy, for example `automation/capitanodoc/capitanodoc.ts`.
## Programming style
`npm run build` also runs [balena-lint](https://www.npmjs.com/package/@balena/lint), which automatically
reformats the code. Beyond that, we have a preference for Javascript promises over callbacks, and for
`async/await` over `.then()`.
## Common gotchas
One thing that most CLI bugs have in common is the absence of test cases exercising the broken
code, so writing some test code is a great idea. Having said that, there are also some common
gotchas to bear in mind:
* Forward slashes ('/') _vs._ backslashes ('\') in file paths. The Node.js
[path.sep](https://nodejs.org/docs/latest-v12.x/api/path.html#path_path_sep) variable stores a
platform-specific path separator character: the backslash on Windows and the forward slash on
Linux and macOS. The
[path.join](https://nodejs.org/docs/latest-v12.x/api/path.html#path_path_join_paths) function
builds paths using such platform-specific path separator. However:
* Note that Windows (kernel, cmd.exe, PowerShell, many applications) accepts ***both*** forward
slashes and backslashes as path separators (including mixing them in a path string), so code
like `mypath.split(path.sep)` may fail on Windows if `mypath` contains forward slashes. The
[path.parse](https://nodejs.org/docs/latest-v12.x/api/path.html#path_path_parse_path) function
understands both forward slashes and backslashes on Windows, and the
[path.normalize](https://nodejs.org/docs/latest-v12.x/api/path.html#path_path_normalize_path)
function will _replace_ forward slashes with backslashes.
* In [tar](https://en.wikipedia.org/wiki/Tar_(computing)#File_format) streams sent to the Docker
daemon and to balenaCloud, the forward slash is the only acceptable path separator, regardless
of the OS where the CLI is running. Therefore, `path.sep` and `path.join` should never be used
when handling paths in tar streams! `path.posix.join` may be used instead of `path.join`.
* Avoid using the system shell to execute external commands, for example:
`child_process.exec('ssh "arg1" "arg2"');`
`child_process.spawn('ssh "arg1" "arg2"', { shell: true });`
Besides the usual security concerns of unsanitized strings, another problem is to get argument
escaping right because of the differences between the Windows 'cmd.exe' shell and the Unix
'/bin/sh'. For example, 'cmd.exe' doesn't recognize single quotes like '/bin/sh', and uses the
caret (^) instead of the backslash as the escape character. Bug territory! Most of the time,
it is possible to avoid relying on the shell altogether by providing a Javascript array of
arguments:
`spawn('ssh', ['arg1', 'arg2'], { shell: false});`
To allow for logging and debugging, the [which](https://www.npmjs.com/package/which) package may
be used to get the full path of a command before executing it, without relying on any shell:
`const fullPath = await which('ssh');`
`console.log(fullPath); # 'C:\WINDOWS\System32\OpenSSH\ssh.EXE'`
`spawn(fullPath, ['arg1', 'arg2'], { shell: false });`
* Avoid the `instanceof` operator when testing against classes/types from external packages
(including base classes), because `npm install` may result in multiple versions of the same
package being installed (to satisfy declared dependencies) and a false negative may result when
comparing an object instance from one package version with a class of another package version
(even if the implementations are identical in both packages). For example, once we fixed a bug
where the test:
`error instanceof BalenaApplicationNotFound`
changed from true to false because `npm install` added an additional copy of the `balena-errors`
package to satisfy a minor `balena-sdk` version update:
`$ find node_modules -name balena-errors`
`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:
`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'],`
Similarly, [Capitano](https://github.com/balena-io/capitano) was originally adopted as the CLI's
framework, but we recently decided to take advantage of [oclif](https://oclif.io/)'s features such
as native installers for Windows, macOS and Linux, and support for custom flag parsing (for
example, we're still battling with Capitano's behavior of dropping leading zeros of arguments that
look like integers such as some abbreviated UUIDs, and migrating to oclif is a solution). Again the
migration is taking place gradually, with some CLI commands parsed by oclif and others by Capitano
(a simple command line pre-parsing takes place in `app.ts` to decide whether to route full parsing
to Capitano or oclif).

View File

@ -1,168 +0,0 @@
# balena CLI Advanced Installation Options
**These are alternative, advanced installation options. Most users would prefer the [recommended,
streamlined installation
instructions](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).**
There are 3 options to choose from to install balena's CLI:
* [Executable Installer](#executable-installer): the easiest method on Windows and macOS, using the
traditional graphical desktop application installers.
* [Standalone tar.gz Package](#standalone-targz-package): these are plain tar.gz files with the balena CLI
bundled within. Available for all platforms: Linux, Windows, macOS.
Recommended also for scripted installation in CI (continuous integration) environments.
* [NPM Installation](#npm-installation): recommended for Node.js developers who may be interested
in integrating the balena CLI in their existing projects or workflow.
Some specific CLI commands have a few extra installation steps: see section [Additional
Dependencies](#additional-dependencies).
## Executable Installer
This is the recommended installation option on macOS and Windows. Follow the specific OS
instructions:
* [Windows](./INSTALL-WINDOWS.md)
* [macOS](./INSTALL-MAC.md)
> Note regarding WSL ([Windows Subsystem for
> Linux](https://docs.microsoft.com/en-us/windows/wsl/about))
> If you would like to use WSL, follow the [installations instructions for
> Linux](./INSTALL-LINUX.md) rather than Windows, as WSL consists of a Linux environment.
If you had previously installed the CLI using a standalone tar package, it may be a good idea to
check your system's `PATH` environment variable for duplicate entries, as the terminal will use the
entry that comes first. Check the [Standalone tar.gz Package](#standalone-targz-package) instructions
for how to modify the PATH variable.
By default, the CLI is installed to the following folders:
OS | Folders
--- | ---
Windows: | `C:\Program Files\balena-cli\`
macOS: | `/usr/local/src/balena-cli/` <br> `/usr/local/bin/balena`
## Standalone tar.gz Package
1. Download the latest tar.gz file from the [releases page](https://github.com/balena-io/balena-cli/releases).
Look for a file name that ends with the word "standalone", for example:
`balena-cli-vX.Y.Z-linux-x64-standalone.tar.gz`_also for the Windows Subsystem for Linux_
`balena-cli-vX.Y.Z-macOS-x64-standalone.tar.gz`
`balena-cli-vX.Y.Z-windows-x64-standalone.tar.gz`
2. Extract the tar.gz file contents to any folder you choose. The extracted contents will be a `balena` folder containing a `bin` subdirectory.
3. Add the `balena/bin` folder to the system's `PATH` environment variable.
See instructions for:
[Linux](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix) |
[macOS](https://www.architectryan.com/2012/10/02/add-to-the-path-on-mac-os-x-mountain-lion/#.Uydjga1dXDg) |
[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
> workaround](https://github.com/balena-io/balena-cli/issues/2244).
> * **Linux Alpine** and **Busybox:** the standalone tar.gz package is not currently compatible with
> these "compact" Linux distributions, because of the alternative C libraries they ship with.
> For these, consider the [NPM Installation](#npm-installation) option.
> * Note that moving the `balena/bin/balena` executable out of the extracted `balena` folder on its own
> (e.g. moving it to `/usr/local/bin/balena`) will **not** work, as it depends on the other
> folders and files also present in the `balena` folder.
To update the CLI to a new version, download a new release tar.gz file and replace the previous
installation folder. To uninstall, simply delete the folder and edit the PATH environment variable
as described above.
## NPM Installation
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
some development tools to be installed first, as follows.
> **The balena CLI currently requires Node.js version >=20.6.0**
> **Versions 23 and later are not yet fully supported.**
### Install development tools
#### **Linux or WSL** (Windows Subsystem for Linux)
```sh
$ sudo apt-get update && sudo apt-get -y install curl python3 git make g++
$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash
$ . ~/.bashrc
$ nvm install 22
```
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 22
```
#### **Windows** (not WSL)
Install:
* If you'd like the ability to switch between Node.js versions, install
- Node.js v22 from the [Nodejs.org releases page](https://nodejs.org/en/download/releases/).
[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`
* 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
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**
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 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,
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/):
`npm install --global --production windows-build-tools`
### Install the balena CLI
After installing the development tools, install the balena CLI with:
```sh
$ npm install balena-cli --global --production --unsafe-perm
```
`--unsafe-perm` is needed when `npm install` is executed as the `root` user (e.g. in a Docker
container) in order to allow npm scripts like `postinstall` to be executed.
## Additional Dependencies
The `balena device ssh`, `device detect`, `build`, `deploy` and `preload` commands may require
additional software to be installed. Check the Additional Dependencies sections for each operating
system:
* [Windows](./INSTALL-WINDOWS.md#additional-dependencies)
* [macOS](./INSTALL-MAC.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
balena CLI is executed), on a remote 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
may be desirable include:
* To avoid having to install Docker on the development machine / laptop.
* To take advantage of a more powerful server (CPU, memory).
* 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
port number with the `--dockerHost` and `--dockerPort` command-line options. The `preload` command
has additional requirements because the bind mount feature is used. For more details, see
`balena help` for each command or the [online
reference](https://www.balena.io/docs/reference/cli/#cli-command-reference).

View File

@ -1,85 +0,0 @@
# balena CLI Installation Instructions for Linux
These instructions are suitable for most Linux distributions on Intel x86, such as
Ubuntu, Debian, Fedora, Arch Linux and other glibc-based distributions.
For the ARM architecture and for Linux distributions not based on glibc, such as
Alpine Linux, follow the [NPM Installation](./INSTALL-ADVANCED.md#npm-installation)
method.
Selected operating system: **Linux**
1. Download the latest tar.gz file from the [latest release
page](https://github.com/balena-io/balena-cli/releases/latest). Look for a file name that ends
with "-standalone.tar.gz", for example:
`balena-cli-vX.Y.Z-linux-x64-standalone.tar.gz`
2. Extract the tar.gz file contents to any folder you choose, for example `/home/james`.
The extracted contents will include a `balena/bin` folder.
3. Add that folder (e.g. `/home/james/balena/bin`) to the `PATH` environment variable.
Check this [StackOverflow
post](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix)
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
terminal window:
* `balena version` - should print the CLI's version
* `balena help` - should print a list of available commands
To update the balena CLI to a new version, download a new release tar.gz file and replace the previous
installation folder. To uninstall, simply delete the folder and edit the PATH environment variable
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
### build, deploy
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
machine. Most users will follow [Docker's installation
instructions](https://docs.docker.com/install/overview/) to install Docker on the same
workstation as the balena CLI. The [advanced installation
options](./INSTALL-ADVANCED.md#additional-dependencies) document describes other possibilities.
### balena device ssh
The `balena device 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`
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
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`.
### balena device detect
The `balena device detect` command requires a multicast DNS (mDNS) service like
[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
`sudo apt-get install avahi-daemon`.
### balena preload
Like the `build` and `deploy` commands, the `preload` command requires Docker, with the additional
restriction that Docker must be installed on the local machine (because Docker's bind mounting
feature is used).

View File

@ -1,74 +0,0 @@
# balena CLI Installation Instructions for macOS
These instructions are for the recommended installation option. Advanced users may also be
interested in [advanced installation options](./INSTALL-ADVANCED.md).
Selected operating system: **macOS**
1. Download the installer from the [latest release
page](https://github.com/balena-io/balena-cli/releases/latest).
Look for a file name that ends with "-installer.pkg":
`balena-cli-vX.Y.Z-macOS-x64-installer.pkg`
2. Double click on the downloaded file to run the installer and follow the installer's
instructions.
3. Check that the installation was successful:
- [Open the Terminal
app](https://support.apple.com/en-gb/guide/terminal/apd5265185d-f365-44cb-8b09-71a064a42125/mac).
- On the terminal prompt, type `balena version` and hit Enter. It should display
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`
and `preload` commands may require additional software to be installed, as described
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
### build and deploy
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
machine. Most users will follow [Docker's installation
instructions](https://docs.docker.com/install/overview/) to install Docker on the same
workstation as the balena CLI. The [advanced installation
options](./INSTALL-ADVANCED.md#additional-dependencies) document describes other possibilities.
### balena device ssh
The `balena device 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
include:
* Download the Xcode Command Line Tools from https://developer.apple.com/downloads
* Or, if you have Xcode installed, open Xcode, choose Preferences → General → Downloads →
Components → Command Line Tools → Install.
* 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
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`.
### balena preload
Like the `build` and `deploy` commands, the `preload` command requires Docker.
Preloading balenaOS images for some older device types (like the Raspberry
Pi 3, but not the Raspberry 4) requires Docker to support the [AUFS storage
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
18.06.1. The present workarounds are 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
Mac](https://docs.docker.com/docker-for-mac/release-notes/#docker-community-edition-18061-ce-mac73-2018-08-29)
We are working on replacing AUFS with overlay2 in balenaOS images of the affected device types.

View File

@ -1,73 +0,0 @@
# balena CLI Installation Instructions for Windows
These instructions are for the recommended installation option. Advanced users may also be
interested in [advanced installation options](./INSTALL-ADVANCED.md).
Selected operating system: **Windows**
1. Download the installer from the [latest release
page](https://github.com/balena-io/balena-cli/releases/latest).
Look for a file name that ends with "-installer.exe":
`balena-cli-vX.Y.Z-windows-x64-installer.exe`
2. Double click on the downloaded file to run the installer and follow the installer's
instructions.
3. Check that the installation was successful:
- Click on the Windows Start Menu, type PowerShell, and then click
on Windows PowerShell.
- On the command prompt, type `balena version` and hit Enter. It should display
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`,
`deploy` and `preload` commands may require additional software to be installed, as
described below.
## Additional Dependencies
### build and deploy
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
machine. Most users will follow [Docker's installation
instructions](https://docs.docker.com/install/overview/) to install Docker on the same
workstation as the balena CLI. The [advanced installation
options](./INSTALL-ADVANCED.md#additional-dependencies) document describes other possibilities.
### balena device ssh
The `balena device 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
Update. To check whether it is installed, run `ssh` on a Windows Command Prompt or PowerShell. It
can also be [manually
installed](https://docs.microsoft.com/en-us/windows-server/administration/openssh/openssh_install_firstuse)
if needed. For older versions of Windows, there are several ssh/OpenSSH clients provided by 3rd
parties.
The `balena device 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*`
command set can also be used to list and manage SSH keys: see `balena help -v`.
### balena device detect
The `balena device detect` 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
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
### balena preload
Like the `build` and `deploy` commands, the `preload` command requires Docker.
Preloading balenaOS images for some older device types (like the Raspberry
Pi 3, but not the Raspberry 4) requires Docker to support the [AUFS storage
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
18.06.1. The present workarounds are 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
Windows](https://docs.docker.com/docker-for-windows/release-notes/#docker-community-edition-18061-ce-win73-2018-08-29)
We are working on replacing AUFS with overlay2 in balenaOS images of the affected device types.

View File

@ -1,12 +1,204 @@
# balena CLI Installation Instructions
Please select your operating system:
There are 3 options to choose from to install balena's CLI:
* [Windows](./INSTALL-WINDOWS.md)
* [macOS](./INSTALL-MAC.md)
* [Linux](./INSTALL-LINUX.md)
* [Executable Installer](#executable-installer): the easiest method, using the traditional
graphical desktop application installers for Windows and macOS (coming soon for Linux users too).
* [Standalone Zip Package](#standalone-zip-package): these are plain zip files with the balena CLI
executable in them. Recommended for scripted installation in CI (continuous integration)
environments.
* [NPM Installation](#npm-installation): recommended for developers who may be interested in
integrating the balena CLI in their existing Node.js projects or workflow.
> Note regarding WSL ([Windows Subsystem for
> Linux](https://docs.microsoft.com/en-us/windows/wsl/about))
> If you would like to use WSL, follow the installations instructions for Linux
> rather than Windows, as WSL consists of a Linux environment.
Some specific CLI commands have a few extra installation steps: see section [Additional
Dependencies](#additional-dependencies).
> **Windows users:**
> * There is a [YouTube video tutorial](https://www.youtube.com/watch?v=2LApclXFqsg) for installing
> and getting started with the balena CLI on Windows. (The video uses the standalone zip package
> option.)
> * If you are using Microsoft's [Windows Subsystem for
> Linux](https://docs.microsoft.com/en-us/windows/wsl/about) (WSL), the recommendation is to
> install a balena CLI release for Linux rather than Windows, like the Linux standalone zip
> package. An installation with the graphical executable installer for Windows will not run on
> WSL.
## Executable Installer
1. Download the latest installer from the [releases page](https://github.com/balena-io/balena-cli/releases).
Look for a file name that ends with "-installer", for example:
`balena-cli-v11.6.0-windows-x64-installer.exe`
`balena-cli-v11.6.0-macOS-x64-installer.pkg`
2. Double click the downloaded file to run the installer.
_If you are using macOS Catalina (10.15), [check this known issue and
workaround](https://github.com/balena-io/balena-cli/issues/1479)._
3. After the installation completes, 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. Check that the installation was successful by running the following commands on a
command terminal:
* `balena version` - should print the installed CLI version
* `balena help` - should print the balena CLI help
> Note: If you had previously installed the CLI using a standalone zip package, it may be a good
> idea to check your system's `PATH` environment variable for duplicate entries, as the terminal
> will use the entry that comes first. Check the [Standalone Zip Package](#standalone-zip-package)
> instructions for how to modify the PATH variable.
By default, the CLI is installed to the following folders:
OS | Folders
--- | ---
Windows: | `C:\Program Files\balena-cli\`
macOS: | `/usr/local/lib/balena-cli/` <br> `/usr/local/bin/balena`
## Standalone Zip Package
1. Download the latest zip file from the [releases page](https://github.com/balena-io/balena-cli/releases).
Look for a file name that ends with the word "standalone", for example:
`balena-cli-v10.13.6-linux-x64-standalone.zip`
`balena-cli-v10.13.6-macOS-x64-standalone.zip`
`balena-cli-v10.13.6-windows-x64-standalone.zip`
2. Extract the zip file contents to any folder you choose. The extracted contents will include a
`balena-cli` folder.
3. Add the `balena-cli` folder to the system's `PATH` environment variable.
See instructions for:
[Linux](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix) |
[macOS](https://www.architectryan.com/2012/10/02/add-to-the-path-on-mac-os-x-mountain-lion/#.Uydjga1dXDg) |
[Windows](https://www.computerhope.com/issues/ch000549.htm)
> _If you are using macOS Catalina (10.15), [check this known issue and
> workaround](https://github.com/balena-io/balena-cli/issues/1479)._
To update the CLI to a new version, download a new release zip file and replace the previous
installation folder. To uninstall, simply delete the folder and edit the PATH environment variable
as described above.
## NPM Installation
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
some additional development tools to be installed first:
* [Node.js](https://nodejs.org/) version 8, 10 or 12
* **Linux, macOS** and **Windows Subsystem for Linux (WSL):**
Installing Node via [nvm](https://github.com/nvm-sh/nvm/blob/master/README.md) is recommended.
With some Linux distributions like Ubuntu, users sometimes report permission errors when using
the system's Node installation (i.e. when Node is installed via `apt-get`), hence the
[nvm](https://github.com/creationix/nvm) recommendation. This [sample
Dockerfile](https://gist.github.com/pdcastro/5d4d96652181e7da685a32caf629dd44) shows the CLI
installation steps on an Ubuntu 18.04 base image.
* If using **Node v8,** upgrade `npm` to version 6.9.0 or later with `"npm install -g npm"`
* [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`
On **Windows (not WSL),** the dependencies above and additional ones can be met by installing:
* 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`
and more:
* `pacman -S git openssh rsync gcc make`
* [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 menus to misbehave. [Check this Github issue for a
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**
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-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)
* The [windows-build-tools](https://www.npmjs.com/package/windows-build-tools) npm package (which
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/):
`npm install -g --production windows-build-tools`
With these dependencies in place, the balena CLI installation command is:
```sh
$ npm install balena-cli -g --production --unsafe-perm
```
`--unsafe-perm` is only required on systems where the global install directory is not user-writable.
This allows npm install steps to download and save prebuilt native binaries. You may be able to omit it,
especially if you're using a user-managed node install such as [nvm](https://github.com/creationix/nvm).
## Additional Dependencies
* The `balena ssh` command requires a recent version of the `ssh` command-line tool to be available:
* macOS and Linux usually already have it installed. Otherwise, search for the available packages
on your specific Linux distribution, or for the Mac consider the [Xcode command-line
tools](https://developer.apple.com/xcode/features/) or [homebrew](https://brew.sh/).
* Microsoft started distributing an SSH client with Windows 10, which we understand is
automatically installed through Windows Update, but can be manually installed too
([more information](https://docs.microsoft.com/en-us/windows-server/administration/openssh/openssh_install_firstuse)).
For other versions of Windows, there are several ssh/OpenSSH clients provided by 3rd parties.
* If you need SSH to work behind a proxy, you will also need to install
[`proxytunnel`](http://proxytunnel.sourceforge.net/) (available as a `proxytunnel` package
for Ubuntu, for example).
Check the [README](https://github.com/balena-io/balena-cli/blob/master/README.md) file
for proxy configuration instructions.
* The `balena preload`, `balena build` and `balena deploy --build` commands require
[Docker](https://docs.docker.com/install/overview/) or [balenaEngine](https://www.balena.io/engine/)
to be available:
* The `balena preload` command requires the Docker Engine to support the [AUFS storage
driver](https://docs.docker.com/storage/storagedriver/aufs-driver/). Docker Desktop for Mac and
Windows dropped support for the AUFS filesystem in Docker CE versions greater than 18.06.1, so
the workaround is to downgrade to version 18.06.1 (links: [Docker CE for
Windows](https://docs.docker.com/docker-for-windows/release-notes/#docker-community-edition-18061-ce-win73-2018-08-29)
and [Docker CE for
Mac](https://docs.docker.com/docker-for-mac/release-notes/#docker-community-edition-18061-ce-mac73-2018-08-29)).
See more details in [CLI issue 1099](https://github.com/balena-io/balena-cli/issues/1099).
* Commonly, Docker is installed on the same machine where the CLI is being used, but the
`balena build` and `balena deploy` commands can also use a remote Docker Engine (daemon)
or balenaEngine (which could be a remote device running a [balenaOS development
image](https://www.balena.io/docs/reference/OS/overview/2.x/#dev-vs-prod-images)) by specifying
its IP address and port number as command-line options. Check the documentation for each
command, e.g. `balena help build`, or the [online
reference](https://www.balena.io/docs/reference/cli/#cli-command-reference).
* If you are using Microsoft's [Windows Subsystem for
Linux](https://docs.microsoft.com/en-us/windows/wsl/about) (WSL) and Docker Desktop for
Windows, check the [FAQ item "Docker seems to be
unavailable"](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md#docker-seems-to-be-unavailable-error-when-using-windows-subsystem-for-linux-wsl).
## Configuring SSH keys
The `balena ssh` command requires an SSH key to be added to your balena account. If you had
already added a SSH key in order to [deploy with 'git push'](https://www.balena.io/docs/learn/getting-started/raspberrypi3/nodejs/#adding-an-ssh-key),
then you are probably done and may skip this section. You can check whether you already have
an SSH key in your balena account with the `balena keys` command, or by visiting the
[balena web dashboard](https://dashboard.balena-cloud.com/), clicking on your name -> Preferences
-> SSH Keys.
> Note: An "SSH key" actually consists of a public/private key pair. A typical name for the private
> key file is "id_rsa", and a typical name for the public key file is "id_rsa.pub". Both key files
> are saved to your computer (with the private key optionally protected by a password), but only
> the public key is saved to your balena account. This means that if you change computers or
> otherwise lose the private key, _you cannot recover the private key through your balena account._
> You can however add new keys, and delete the old ones.
If you don't have an SSH key in your balena account:
* If you have an existing SSH key in your computer that you would like to use, you can add it
to your balena account through the balena web dashboard (Preferences -> SSH Keys), or through
the CLI itself:
```bash
# Windows 10 (cmd.exe prompt) example:
$ balena key add MyKey %userprofile%\.ssh\id_rsa.pub
# Linux / macOS example:
$ balena key add MyKey ~/.ssh/id_rsa.pub
```
* To generate a new key, you can follow [GitHub's documentation](https://help.github.com/en/articles/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent),
skipping the step about adding the key to your GitHub account, and instead adding the key to
your balena account as described above.

View File

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

162
README.md
View File

@ -1,175 +1,91 @@
# balena CLI
The official balena Command Line Interface.
The official balena CLI tool.
[![npm version](https://badge.fury.io/js/balena-cli.svg)](http://badge.fury.io/js/balena-cli)
[![dependencies](https://david-dm.org/balena-io/balena-cli.svg)](https://david-dm.org/balena-io/balena-cli)
## About
The balena CLI is a Command Line Interface for [balenaCloud](https://www.balena.io/cloud/) or
[openBalena](https://www.balena.io/open/). It is a software tool available for Windows, macOS and
Linux, used through a command prompt / terminal window. It can be used interactively or invoked in
scripts. The balena CLI builds on the [balena API](https://www.balena.io/docs/reference/api/overview/)
and the [balena SDK](https://www.balena.io/docs/reference/sdk/node-sdk/), and can also be directly
imported in Node.js applications. The balena CLI is an [open-source project on
GitHub](https://github.com/balena-io/balena-cli/), and your contribution is also welcome!
The balena CLI (Command-Line Interface) allows you to interact with the balenaCloud and the
[balena API](https://www.balena.io/docs/reference/api/overview/) through a terminal window
on Linux, macOS or Windows. You can also write shell scripts around it, or import its Node.js
modules to use it programmatically.
As an [open-source project on GitHub](https://github.com/balena-io/balena-cli/), your contribution
is also welcome!
## Installation
Check the [balena CLI installation instructions on
GitHub](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).
Check the [balena CLI installation instructions on GitHub](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).
Note for v22 Migration: If you are upgrading to balena CLI v22 from a previous standalone installation, please [see the v22 Migration Guide](https://github.com/balena-io/balena-cli/blob/master/MIGRATIONS.md) for installation changes.
## Getting Started
## Choosing a shell (command prompt/terminal)
### Choosing a shell (command prompt/terminal)
On **Windows,** the standard Command Prompt (`cmd.exe`) and
[PowerShell](https://docs.microsoft.com/en-us/powershell/scripting/getting-started/getting-started-with-windows-powershell?view=powershell-6)
are supported. Alternative shells include:
are supported. We are aware of users also having a good experience with alternative shells,
including:
* [MSYS2](https://www.msys2.org/):
* 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`
* 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
workaround](https://github.com/msys2/MINGW-packages/issues/1633#issuecomment-240583890).
* [MSYS](http://www.mingw.org/wiki/MSYS)
* [MSYS](http://www.mingw.org/wiki/MSYS): select the `msys-rsync` and `msys-openssh` packages too
* [Git for Windows](https://git-for-windows.github.io/)
* 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
bug](https://github.com/msys2/MINGW-packages/issues/1633) mentioned above (Git for Windows
actually uses MSYS2). For a screenshot, check this
[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)
(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
[FAQ](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md) for using the
balena CLI with WSL and Docker Desktop for Windows.
balena CLI release **for Linux** is recommended. See
[FAQ](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md) for using balena
CLI with WSL and Docker Desktop for Windows.
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
[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
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
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
$ balena login
```
## Proxy support
### Proxy support
HTTP(S) proxies can be configured through any of the following methods, in precedence order
(from higher to lower):
HTTP(S) proxies can be configured through any of the following methods, in order of preference:
* The `BALENARC_PROXY` environment variable in URL format, with protocol (`http` or `https`),
host, port and optionally basic auth. Examples:
* `export BALENARC_PROXY='https://bob:secret@proxy.company.com:12345'`
* `export BALENARC_PROXY='http://localhost:8000'`
* Set the `BALENARC_PROXY` environment variable in URL format (with protocol, host, port, and
optionally basic auth).
* Alternatively, use the [balena config file](https://www.npmjs.com/package/balena-settings-client#documentation)
(project-specific or user-level) and set the `proxy` setting. It can be:
* A string in URL format, or
* An object in the [global-tunnel-ng options format](https://www.npmjs.com/package/global-tunnel-ng#options) (which allows more control).
* Alternatively, set the conventional `https_proxy` / `HTTPS_PROXY` / `http_proxy` / `HTTP_PROXY`
environment variable (in the same standard URL format).
* The `proxy` setting in the [CLI config
file](https://www.npmjs.com/package/balena-settings-client#documentation). It may be:
* A string in URL format, e.g. `proxy: 'http://localhost:8000'`
* An object in the format:
```yaml
proxy:
protocol: 'http'
host: 'proxy.company.com'
port: 12345
proxyAuth: 'bob:secret'
```
* The `HTTPS_PROXY` and/or `HTTP_PROXY` environment variables, in the same URL format as
`BALENARC_PROXY`.
### Proxy setup for balena device ssh
In order to work behind a proxy server, the `balena device ssh` command requires the
[`proxytunnel`](http://proxytunnel.sourceforge.net/) package (command-line tool) to be installed.
`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
Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about) (e.g., by installing
Ubuntu through the Microsoft App Store).
Ensure that the proxy server is configured to allow proxy requests to ssh port 22, using
SSL encryption. For example, in the case of the [Squid](http://www.squid-cache.org/) proxy
server, it should be configured with the following rules in the `squid.conf` file:
`acl SSL_ports port 22`
`acl Safe_ports port 22`
### Proxy exclusion
The `BALENARC_NO_PROXY` variable may be used to exclude specified destinations from proxying.
> * 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
> Node.js version 10.16.0 or later.
> * To exclude a `balena device ssh` target from proxying (IP address or `.local` hostname), the
> `--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
addresses](https://en.wikipedia.org/wiki/Private_network) and `'*.local'` hostnames are excluded
from proxying. Other hostnames that resolve to private IPv4 addresses are **not** excluded by
default, because matching takes place before name resolution.
`localhost` and `127.0.0.1` are always excluded from proxying, regardless of the value of
BALENARC_NO_PROXY.
The format of the `BALENARC_NO_PROXY` environment variable is a comma-separated list of patterns
that are matched against hostnames or IP addresses. For example:
```
export BALENARC_NO_PROXY='*.local,dev*.mycompany.com,192.168.*'
```
Matched patterns are excluded from proxying. Wildcard expressions are documented at
[matcher](https://www.npmjs.com/package/matcher#usage). Matching takes place _before_ name
resolution, so a pattern like `'192.168.*'` will **not** match a hostname that resolves to an IP
address like `192.168.1.2`.
To get a proxy to work with the `balena ssh` command, check the
[installation instructions](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).
## Command reference documentation
The full CLI command reference is available [on the web](https://www.balena.io/docs/reference/cli/
) or by running `balena help --verbose`.
) or by running `balena help` and `balena help --verbose`.
## Support, FAQ and troubleshooting
To learn more, troubleshoot issues, or to contact us for support:
If you come across any problems or would like to get in touch:
* 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)
* Ask us a question in the [balena forums](https://forums.balena.io/c/product-support)
For CLI bug reports or feature requests, check the
[CLI GitHub issues](https://github.com/balena-io/balena-cli/issues/).
## Deprecation policy
The balena CLI uses [semver versioning](https://semver.org/), with the concepts
of major, minor and patch version releases.
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
following major version is released. For example, balena CLI v11.36.0, as the
latest v11 release, would remain compatible with the balenaCloud backend for one
year from the date when v12.0.0 was released.
Half way through to that period (6 months after the release of the next major
version), older major versions of the balena CLI will start printing a deprecation
warning message when it is used interactively (when `stderr` is attached to a TTY
device file). At the end of that period, older major versions will exit with an
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.
* Check our [FAQ / troubleshooting document](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md).
* Ask us a question through the [balenaCloud forum](https://forums.balena.io/c/balena-cloud).
* For bug reports or feature requests,
[have a look at the GitHub issues or create a new one](https://github.com/balena-io/balena-cli/issues/).
## Contributing (including editing documentation files)

View File

@ -1,6 +1,8 @@
# balena CLI FAQ & Troubleshooting
# FAQ & Troubleshooting
## Where is the balena CLI's configuration file located?
This document contains some common issues, questions and answers related to the balena CLI.
## Where is my configuration file?
The per-user configuration file lives in `$HOME/.balenarc.yml` or `%UserProfile%\_balenarc.yml`, in
Unix based operating systems and Windows respectively.
@ -8,48 +10,53 @@ Unix based operating systems and Windows respectively.
The balena CLI also attempts to read a `balenarc.yml` file in the current directory, which takes
precedence over the per-user configuration file.
## How do I point the balena CLI to the staging environment?
## How do I point the balena CLI to staging?
Set the `BALENARC_BALENA_URL=balena-staging.com` environment variable, or add
`balenaUrl: balena-staging.com` to the balena CLI's configuration file.
The easiest way is to set the `BALENARC_BALENA_URL=balena-staging.com` environment variable.
Alternatively, you can edit your configuration file and set `balenaUrl: balena-staging.com` to
persist this setting.
## How do I make the balena CLI persist data in another directory?
The balena CLI persists the session token, as well as cached assets, to `$HOME/.balena` or
`%UserProfile%\_balena`. This directory can be changed by setting an environment variable,
`BALENARC_DATA_DIRECTORY=/opt/balena`, or by adding `dataDirectory: /opt/balena` to the CLI's
configuration file, replacing `/opt/balena` with the desired directory.
The balena CLI persists your session token, as well as cached images in `$HOME/.balena` or
`%UserProfile%\_balena`.
## After burning to an SD card, my device doesn't boot
Pointing the balena CLI to persist data in another location is necessary in certain environments,
like a server, where there is no home directory, or a device running balenaOS, which erases all
data after a restart.
Check whether the downloaded image is incomplete (download was interrupted) or corrupted.
You can accomplish this by setting `BALENARC_DATA_DIRECTORY=/opt/balena` or adding `dataDirectory:
/opt/balena` to your configuration file, replacing `/opt/balena` with your desired directory.
Try clearing the cache (`%HOME/.balena/cache` or `C:\Users\<user>\_balena\cache`) and running the
command again.
## After burning to an sdcard, my device doesn't boot
## I get a permission error when burning to an SD card
- The downloaded image is not complete (download was interrupted).
Check whether the SD card is locked (a physical switch on the side of the card).
Please clean the cache (`%HOME/.balena/cache` or `C:\Users\<user>\_balena\cache`) and run the command again. In the future, the CLI will check that the image is not complete and clean the cache for you.
## I get `connect ETIMEDOUT` with `balena device tunnel`
## I get a permission error when burning to an sdcard
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
- The SDCard is locked.
## I get EINVAL errors on Cygwin
### I get EINVAL errors on Cygwin
The errors may look something like this:
The errors look something like this:
```
net.js:156
this._handle.open(options.fd);
^
Error: EINVAL, invalid argument
at new Socket (net.js:156:18)
at process.stdin (node.js:664:19)
at Object.Interface.createInterface (C:\cygwin\home\Juan Cruz Viotti\Projects\balena-cli\node_modules\inquirer\node_modules\readline2\index.js:31:43)
at PromptUI.UI (C:\cygwin\home\Juan Cruz Viotti\Projects\balena-cli\node_modules\inquirer\lib\ui\baseUI.js:23:40)
at new PromptUI (C:\cygwin\home\Juan Cruz Viotti\Projects\balena-cli\node_modules\inquirer\lib\ui\prompt.js:26:8)
at Object.promptModule [as prompt] (C:\cygwin\home\Juan Cruz Viotti\Projects\balena-cli\node_modules\inquirer\lib\inquirer.js:27:14)
```
Some interactive widgets don't work on `Cygwin`. On Windows, PowerShell or `cmd.exe` are better
supported. Alternative shells are [listed in the README
file](./README.md#choosing-a-shell-command-promptterminal).
- Some interactive widgets don't work on `Cygwin`. If you're running Windows, it's preferrable that you use `cmd.exe`, as `Cygwin` is [not official supported by Node.js](https://github.com/chjj/blessed/issues/56#issuecomment-42671945).
## I get `Invalid MBR boot signature` when configuring a device
@ -69,9 +76,7 @@ Or in Windows:
## I get `EACCES: permission denied` when logging in
The balena CLI stores the session token in `$HOME/.balena` or `C:\Users\<user>\_balena` in UNIX based
operating systems and Windows respectively. This error usually indicates that the user doesn't have
permissions over that directory, which can happen if the CLI was executed as the `root` user.
The balena CLI stores the session token in `$HOME/.balena` or `C:\Users\<user>\_balena` in UNIX based operating systems and Windows respectively. This error usually indicates that the user doesn't have permissions over that directory, which can happen if you ran the balena CLI as `root`, and thus the directory got owned by him.
Try resetting the ownership by running:
@ -79,17 +84,9 @@ Try resetting the ownership by running:
$ 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
when long command lines are typed in a `balena device 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
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`
and the like on the remote machine), including UTF-8 misconfiguration, the use of unsupported ASCII
control characters in shell prompt formatting (e.g. the `$PS1` env var) or the output of tools or
log files that use colored text. The issue can sometimes be fixed by simply resizing the client
terminal window, or by running one or more of the following commands on the shell:
Users sometimes come across broken line wrapping or cursor behavior in text terminals, for example 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 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` and the like), including UTF-8 misconfiguration, the use of unsupported ASCII control characters in shell prompt formatting (e.g. the `$PS1` env var) or the output of tools or log files that use colored text. The issue can sometimes be fixed by resizing the client terminal window, or by running one or more of the following commands on the shell:
```sh
export TERMINAL=linux
@ -115,10 +112,10 @@ If nothing seems to help, consider also using a different client-side terminal a
## "Docker seems to be unavailable" error when using Windows Subsystem for Linux (WSL)
When running on WSL, the recommendation is to install a CLI release for Linux, like the standalone
tar.gz package for Linux. However, commands like "balena build" will, by default, attempt to reach the
Docker daemon at the Unix socket path `/var/run/docker.sock`, while Docker Desktop for Windows uses
a Windows named pipe at `//./pipe/docker_engine` (which the Linux CLI on WSL cannot use). A
solution is:
zip package for Linux. However, commands like "balena build" that contact a local Docker daemon,
like the Docker Desktop for Windows, will try to reach Docker at the Unix socket path
`/var/run/docker.sock`, while Docker Desktop for Windows uses a Windows named pipe at
`//./pipe/docker_engine` (which the Linux CLI on WSL cannot use). A solution is:
- Open the Docker Desktop for Windows settings panel and tick the checkbox _"Expose daemon on tcp://localhost:2375 without TLS"._
- On the WSL command line, set an env var:

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,23 +15,27 @@
* limitations under the License.
*/
import { run as oclifRun } from '@oclif/core';
import { exec, execFile } from 'child_process';
import type { Stats } from 'fs';
import { run as oclifRun } from '@oclif/dev-cli';
import * as archiver from 'archiver';
import * as Bluebird from 'bluebird';
import { execFile, spawn } from 'child_process';
import { stripIndent } from 'common-tags';
import * as filehound from 'filehound';
import * as fs from 'fs-extra';
import * as klaw from 'klaw';
import * as _ from 'lodash';
import * as path from 'path';
import { exec as execPkg } from 'pkg';
import * as rimraf from 'rimraf';
import { promisify } from 'util';
import { notarize } from '@electron/notarize';
import * as semver from 'semver';
import * as shellEscape from 'shell-escape';
import * as util from 'util';
import { loadPackageJson, ROOT, whichSpawn } from './utils';
const execFileAsync = promisify(execFile);
const execAsync = promisify(exec);
const rimrafAsync = promisify(rimraf);
export const packageJSON = loadPackageJson();
export const ROOT = path.join(__dirname, '..');
// Note: the following 'tslint disable' line was only required to
// satisfy ts-node under Appveyor's MSYS2 on Windows -- oddly specific.
// Maybe something to do with '/' vs '\' in paths in some tslint file.
// tslint:disable-next-line:no-var-requires
export const packageJSON = require(path.join(ROOT, 'package.json'));
export const version = 'v' + packageJSON.version;
const arch = process.arch;
@ -43,13 +47,15 @@ interface PathByPlatform {
[platform: string]: string;
}
const getOclifInstallersOriginalNames = async (): Promise<PathByPlatform> => {
const { stdout } = await execAsync('git rev-parse --short HEAD');
const sha = stdout.trim();
return {
darwin: dPath('macos', `balena-${version}-${sha}-${arch}.pkg`),
win32: dPath('win32', `balena-${version}-${sha}-${arch}.exe`),
};
const standaloneZips: PathByPlatform = {
linux: dPath(`balena-cli-${version}-linux-${arch}-standalone.zip`),
darwin: dPath(`balena-cli-${version}-macOS-${arch}-standalone.zip`),
win32: dPath(`balena-cli-${version}-windows-${arch}-standalone.zip`),
};
const oclifInstallers: PathByPlatform = {
darwin: dPath('macos', `balena-${version}.pkg`),
win32: dPath('win', `balena-${version}-${arch}.exe`),
};
const renamedOclifInstallers: PathByPlatform = {
@ -57,149 +63,181 @@ const renamedOclifInstallers: PathByPlatform = {
win32: dPath(`balena-cli-${version}-windows-${arch}-installer.exe`),
};
const getOclifStandaloneOriginalNames = async (): Promise<PathByPlatform> => {
const { stdout } = await execAsync('git rev-parse --short HEAD');
const sha = stdout.trim();
return {
linux: dPath(`balena-${version}-${sha}-linux-${arch}.tar.gz`),
darwin: dPath(`balena-${version}-${sha}-darwin-${arch}.tar.gz`),
win32: dPath(`balena-${version}-${sha}-win32-${arch}.tar.gz`),
};
export const finalReleaseAssets: { [platform: string]: string[] } = {
win32: [standaloneZips['win32'], renamedOclifInstallers['win32']],
darwin: [standaloneZips['darwin'], renamedOclifInstallers['darwin']],
linux: [standaloneZips['linux']],
};
const renamedOclifStandalone: PathByPlatform = {
linux: dPath(`balena-cli-${version}-linux-${arch}-standalone.tar.gz`),
darwin: dPath(`balena-cli-${version}-macOS-${arch}-standalone.tar.gz`),
win32: dPath(`balena-cli-${version}-windows-${arch}-standalone.tar.gz`),
};
const MSYS2_BASH = 'C:\\msys64\\usr\\bin\\bash.exe';
export async function signFilesForNotarization() {
console.log('Signing files for notarization');
// If signFilesForNotarization is called on the test CI environment (which will not set CSC_LINK)
// then we skip the signing process.
if (process.platform !== 'darwin' || !process.env.CSC_LINK) {
console.log('Skipping signing for notarization');
return;
}
console.log('Deleting unneeded zip files...');
/**
* 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) => {
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);
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();
}
});
});
// 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 buildStandalone() {
console.log(`Building standalone tarball for CLI ${version}`);
fs.rmSync('./tmp', { recursive: true, force: true });
fs.rmSync('./dist', { recursive: true, force: true });
fs.mkdirSync('./dist');
try {
let packOpts = ['-r', ROOT, '--no-xz'];
if (process.platform === 'darwin') {
packOpts = packOpts.concat('--targets', `darwin-${arch}`);
} else if (process.platform === 'win32') {
packOpts = packOpts.concat('--targets', 'win32-x64');
} else if (process.platform === 'linux') {
packOpts = packOpts.concat('--targets', `linux-${arch}`);
/**
* Use the 'pkg' module to create a single large executable file with
* the contents of 'node_modules' and the CLI's javascript code.
* Also copy a number of native modules (binary '.node' files) that are
* compiled during 'npm install' to the 'build-bin' folder, alongside
* the single large executable file created by pkg. (This is necessary
* because of a pkg limitation that does not allow binary executables
* to be directly executed from inside another binary executable.)
*/
async function buildPkg() {
const args = [
'--target',
'host',
'--output',
'build-bin/balena',
'package.json',
];
console.log('=======================================================');
console.log(`execPkg ${args.join(' ')}`);
console.log(`cwd="${process.cwd()}" ROOT="${ROOT}"`);
console.log('=======================================================');
await execPkg(args);
const xpaths: Array<[string, string[]]> = [
// [platform, [path, to, file]]
['*', ['opn', 'xdg-open']],
['darwin', ['denymount', 'bin', 'denymount']],
];
await Bluebird.map(xpaths, ([platform, xpath]) => {
if (platform === '*' || platform === process.platform) {
// eg copy from node_modules/opn/xdg-open to build-bin/xdg-open
return fs.copy(
path.join(ROOT, 'node_modules', ...xpath),
path.join(ROOT, 'build-bin', xpath.pop()!),
);
}
});
const nativeExtensionPaths: string[] = await filehound
.create()
.paths(path.join(ROOT, 'node_modules'))
.ext(['node', 'dll'])
.find();
console.log(`Building oclif installer for CLI ${version}`);
const packCmd = `pack:tarballs`;
console.log('=======================================================');
console.log(`oclif ${packCmd} ${packOpts.join(' ')}`);
console.log(`cwd="${process.cwd()}" ROOT="${ROOT}"`);
console.log('=======================================================');
const oclifPath = path.join(ROOT, 'node_modules', 'oclif');
await oclifRun([packCmd].concat(...packOpts), oclifPath);
await renameStandalone();
console.log(`\nCopying to build-bin:\n${nativeExtensionPaths.join('\n')}`);
console.log(`Standalone tarball package build completed`);
} catch (error) {
console.error(`Error creating or testing standalone tarball package`);
throw error;
}
await Bluebird.map(nativeExtensionPaths, extPath =>
fs.copy(
extPath,
extPath.replace(
path.join(ROOT, 'node_modules'),
path.join(ROOT, 'build-bin'),
),
),
);
}
async function renameInstallers() {
const oclifInstallers = await getOclifInstallersOriginalNames();
/**
* Run some basic tests on the built pkg executable.
* TODO: test more than just `balena version -j`; integrate with the
* existing mocha/chai CLI command testing.
*/
async function testPkg() {
type JsonVersions = import('../lib/actions-oclif/version').JsonVersions;
const pkgBalenaPath = path.join(
ROOT,
'build-bin',
process.platform === 'win32' ? 'balena.exe' : 'balena',
);
console.log(`Testing standalone package "${pkgBalenaPath}"...`);
// Run `balena version -j`, parse its stdout as JSON, and check that the
// reported Node.js major version matches semver.major(process.version)
const stdout = await getSubprocessStdout(pkgBalenaPath, ['version', '-j']);
let pkgNodeVersion = '';
let pkgNodeMajorVersion = 0;
try {
const balenaVersions: JsonVersions = JSON.parse(stdout);
pkgNodeVersion = balenaVersions['Node.js'];
pkgNodeMajorVersion = semver.major(pkgNodeVersion);
} catch (err) {
throw new Error(stripIndent`
Error parsing JSON output of "balena version -j": ${err}
Original output: "${stdout}"`);
}
if (semver.major(process.version) !== pkgNodeMajorVersion) {
throw new Error(
`Mismatched major version: built-in pkg Node version="${pkgNodeVersion}" vs process.version="${
process.version
}"`,
);
}
console.log('Success! (standalone package test successful)');
}
/**
* Create the zip file for the standalone 'pkg' bundle previously created
* by the buildPkg() function in 'build-bin.ts'.
*/
async function zipPkg() {
const outputFile = standaloneZips[process.platform];
if (!outputFile) {
throw new Error(
`Standalone installer unavailable for platform "${process.platform}"`,
);
}
await fs.mkdirp(path.dirname(outputFile));
await new Promise((resolve, reject) => {
console.log(`Zipping standalone package to "${outputFile}"...`);
const archive = archiver('zip', {
zlib: { level: 7 },
});
archive.directory(path.join(ROOT, 'build-bin'), 'balena-cli');
const outputStream = fs.createWriteStream(outputFile);
outputStream.on('close', resolve);
outputStream.on('error', reject);
archive.on('error', reject);
archive.on('warning', console.warn);
archive.pipe(outputStream);
archive.finalize();
});
}
export async function buildStandaloneZip() {
console.log(`Building standalone zip package for CLI ${version}`);
try {
await buildPkg();
await testPkg();
await zipPkg();
} catch (error) {
console.log(`Error creating or testing standalone zip package:\n ${error}`);
process.exit(1);
}
console.log(`Standalone zip package build completed`);
}
async function renameInstallerFiles() {
if (await fs.pathExists(oclifInstallers[process.platform])) {
await fs.rename(
oclifInstallers[process.platform],
@ -208,42 +246,24 @@ async function renameInstallers() {
}
}
async function renameStandalone() {
const oclifStandalone = await getOclifStandaloneOriginalNames();
if (await fs.pathExists(oclifStandalone[process.platform])) {
await fs.rename(
oclifStandalone[process.platform],
renamedOclifStandalone[process.platform],
);
}
}
/**
* If the CSC_LINK and CSC_KEY_PASSWORD env vars are set, digitally sign the
* executable installer using Microsoft SignTool.exe (Sign Tool)
* https://learn.microsoft.com/en-us/dotnet/framework/tools/signtool-exe
* executable installer by running the balena-io/scripts/shared/sign-exe.sh
* script (which must be in the PATH) using a MSYS2 bash shell.
*/
async function signWindowsInstaller() {
if (process.env.SM_CODE_SIGNING_CERT_SHA1_HASH) {
const exeName = (await getOclifInstallersOriginalNames())[process.platform];
if (process.env.CSC_LINK && process.env.CSC_KEY_PASSWORD) {
const exeName = renamedOclifInstallers[process.platform];
const execFileAsync = util.promisify<string, string[], void>(execFile);
console.log(`Signing installer "${exeName}"`);
// trust ...
await execFileAsync('signtool.exe', [
'sign',
'-sha1',
process.env.SM_CODE_SIGNING_CERT_SHA1_HASH,
'-tr',
process.env.TIMESTAMP_SERVER || 'http://timestamp.comodoca.com',
'-td',
'SHA256',
'-fd',
'SHA256',
await execFileAsync(MSYS2_BASH, [
'sign-exe.sh',
'-f',
exeName,
'-d',
`balena-cli ${version}`,
exeName,
]);
// ... but verify
await execFileAsync('signtool.exe', ['verify', '-pa', '-v', exeName]);
} else {
console.log(
'Skipping installer signing step because CSC_* env vars are not set',
@ -252,29 +272,7 @@ async function signWindowsInstaller() {
}
/**
* Wait for Apple Installer Notarization to continue
*/
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;
const appPath = (await getOclifInstallersOriginalNames())[process.platform];
console.log(`Notarizing file "${appPath}"`);
if (appleIdPassword && teamId) {
await notarize({
tool: 'notarytool',
teamId,
appPath,
appleId,
appleIdPassword,
});
}
}
/**
* Run the `oclif pack:win` or `pack:macos` command (depending on the value
* Run the `oclif-dev pack:win` or `pack:macos` command (depending on the value
* of process.platform) to generate the native installers (which end up under
* the 'dist' folder). There are some harcoded options such as selecting only
* 64-bit binaries under Windows.
@ -284,10 +282,9 @@ export async function buildOclifInstaller() {
let packOpts = ['-r', ROOT];
if (process.platform === 'darwin') {
packOS = 'macos';
packOpts = packOpts.concat('--targets', `darwin-${arch}`);
} else if (process.platform === 'win32') {
packOS = 'win';
packOpts = packOpts.concat('--targets', 'win32-x64');
packOpts = packOpts.concat('-t', 'win32-x64');
}
if (packOS) {
console.log(`Building oclif installer for CLI ${version}`);
@ -298,60 +295,82 @@ export async function buildOclifInstaller() {
}
for (const dir of dirs) {
console.log(`rimraf(${dir})`);
await rimrafAsync(dir);
await Bluebird.fromCallback(cb => rimraf(dir, cb));
}
console.log('=======================================================');
console.log(`oclif ${packCmd} ${packOpts.join(' ')}`);
console.log(`oclif-dev "${packCmd}" "${packOpts.join('" "')}"`);
console.log(`cwd="${process.cwd()}" ROOT="${ROOT}"`);
console.log('=======================================================');
const oclifPath = path.join(ROOT, 'node_modules', 'oclif');
await oclifRun([packCmd].concat(...packOpts), oclifPath);
await oclifRun([packCmd].concat(...packOpts));
await renameInstallerFiles();
// The Windows installer is explicitly signed here (oclif doesn't do it).
// The macOS installer is automatically signed by oclif (which runs the
// `pkgbuild` tool), using the certificate name given in package.json
// (`oclif.macos.sign` section).
if (process.platform === 'win32') {
await signWindowsInstaller();
} else if (process.platform === 'darwin') {
console.log('Notarizing package...');
await notarizeMacInstaller(); // Notarize
console.log('Package notarized.');
}
await renameInstallers();
console.log(`oclif installer build completed`);
}
}
/**
* Wrapper around the npm `catch-uncommitted` package in order to run it
* conditionally, only when:
* - A CI env var is set (CI=true), and
* - The OS is not Windows. (`catch-uncommitted` fails on Windows)
* 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 async function catchUncommitted(): Promise<void> {
if (process.env.DEBUG) {
console.error(`[debug] CI=${process.env.CI} platform=${process.platform}`);
}
if (
process.env.CI &&
['true', 'yes', '1'].includes(process.env.CI.toLowerCase()) &&
process.platform !== 'win32'
) {
await whichSpawn('npx', [
'catch-uncommitted',
'--catch-no-git',
'--skip-node-versionbot-changes',
'--ignore-space-at-eol',
]);
}
export function fixPathForMsys(p: string): string {
return p.replace(/\\/g, '/').replace(/^([a-zA-Z]):/, '/$1');
}
export async function testShrinkwrap(): Promise<void> {
if (process.env.DEBUG) {
console.error(`[debug] platform=${process.platform}`);
}
if (process.platform !== 'win32') {
await whichSpawn(path.resolve(__dirname, 'test-lock-deduplicated.sh'));
}
await Promise.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
*/
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);
}
});
});
}

View File

@ -16,8 +16,8 @@
*/
import * as path from 'path';
import { MarkdownFileParser } from './utils';
import { GlobSync } from 'glob';
/**
* This is the skeleton of CLI documentation/reference web page at:
@ -25,111 +25,107 @@ import { GlobSync } from 'glob';
*
* The `getCapitanoDoc` function in this module parses README.md and adds
* 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.
*
*/
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',
const capitanoDoc = {
title: 'Balena CLI Documentation',
introduction: '',
categories: [],
categories: [
{
title: 'API keys',
files: ['build/actions/api-key.js'],
},
{
title: 'Application',
files: ['build/actions/app.js'],
},
{
title: 'Authentication',
files: ['build/actions/auth.js'],
},
{
title: 'Device',
files: ['build/actions/device.js'],
},
{
title: 'Environment Variables',
files: [
'build/actions-oclif/envs.js',
'build/actions-oclif/env/add.js',
'build/actions-oclif/env/rename.js',
'build/actions-oclif/env/rm.js',
],
},
{
title: 'Tags',
files: ['build/actions/tags.js'],
},
{
title: 'Help and Version',
files: ['build/actions/help.js', 'build/actions-oclif/version.js'],
},
{
title: 'Keys',
files: ['build/actions/keys.js'],
},
{
title: 'Logs',
files: ['build/actions/logs.js'],
},
{
title: 'Network',
files: [
'build/actions/scan.js',
'build/actions/ssh.js',
'build/actions/tunnel.js',
],
},
{
title: 'Notes',
files: ['build/actions/notes.js'],
},
{
title: 'OS',
files: ['build/actions/os.js', 'build/actions-oclif/os/configure.js'],
},
{
title: 'Config',
files: ['build/actions/config.js'],
},
{
title: 'Preload',
files: ['build/actions/preload.js'],
},
{
title: 'Push',
files: ['build/actions/push.js'],
},
{
title: 'Settings',
files: ['build/actions/settings.js'],
},
{
title: 'Local',
files: ['build/actions/local/index.js'],
},
{
title: 'Deploy',
files: ['build/actions/build.js', 'build/actions/deploy.js'],
},
{
title: 'Platform',
files: ['build/actions/join.js', 'build/actions/leave.js'],
},
{
title: 'Utilities',
files: ['build/actions/util.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
* CLI documentation at docs/balena-cli.md
* Modify and return the `capitanoDoc` object above in order to render the
* CLI documentation/reference web page at:
* https://www.balena.io/docs/reference/cli/
*
* This function parses the README.md file to extract relevant sections
* for the documentation web page.
@ -145,14 +141,11 @@ export async function getCapitanoDoc(): Promise<typeof capitanoDoc> {
throw new Error(`Error parsing section title`);
}
// match[1] has the title, match[2] has the rest
return match?.[2];
return match && match[2];
}),
mdParser.getSectionOfTitle('Installation'),
mdParser.getSectionOfTitle('Choosing a shell (command prompt/terminal)'),
mdParser.getSectionOfTitle('Logging in'),
mdParser.getSectionOfTitle('Proxy support'),
mdParser.getSectionOfTitle('Getting Started'),
mdParser.getSectionOfTitle('Support, FAQ and troubleshooting'),
mdParser.getSectionOfTitle('Deprecation policy'),
]);
capitanoDoc.introduction = sections.join('\n');
return capitanoDoc;

View File

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

View File

@ -14,9 +14,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as _ from 'lodash';
import * as path from 'path';
import { getCapitanoDoc } from './capitanodoc';
import type { Category, Document, OclifCommand } from './doc-types';
import { CapitanoCommand, Category, Document, OclifCommand } from './doc-types';
import * as markdown from './markdown';
/**
@ -38,7 +40,11 @@ export async function renderMarkdown(): Promise<string> {
};
for (const jsFilename of commandCategory.files) {
category.commands.push(await importOclifCommands(jsFilename));
category.commands.push(
...(jsFilename.includes('actions-oclif')
? importOclifCommands(jsFilename)
: importCapitanoCommands(jsFilename)),
);
}
result.categories.push(category);
}
@ -46,23 +52,25 @@ export async function renderMarkdown(): Promise<string> {
return markdown.render(result);
}
async function importOclifCommands(jsFilename: string) {
const command = (await import(path.join(process.cwd(), jsFilename)))
.default as OclifCommand;
function importCapitanoCommands(jsFilename: string): CapitanoCommand[] {
const actions = require(path.join(process.cwd(), jsFilename));
const commands: CapitanoCommand[] = [];
return {
...command,
// build/commands/device/index.js -> device
// build/commands/device/list.js -> device list
name: jsFilename
.split('/')
.slice(2)
.join(' ')
.split('.')
.slice(0, 1)
.join(' ')
.split(' index')[0],
} as Category['commands'][0];
if (actions.signature) {
commands.push(_.omit(actions, 'action'));
} else {
for (const actionName of Object.keys(actions)) {
const actionCommand = actions[actionName];
commands.push(_.omit(actionCommand, 'action'));
}
}
return commands;
}
function importOclifCommands(jsFilename: string): OclifCommand[] {
const command: OclifCommand = require(path.join(process.cwd(), jsFilename))
.default as OclifCommand;
return [command];
}
/**
@ -74,9 +82,8 @@ async function printMarkdown() {
console.log(await renderMarkdown());
} catch (error) {
console.error(error);
process.exitCode = 1;
process.exit(1);
}
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
printMarkdown();

View File

@ -14,31 +14,36 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Parser } from '@oclif/core';
import { flagUsages } from '@oclif/parser';
import * as ent from 'ent';
import * as _ from 'lodash';
import { capitanoizeOclifUsage } from '../../src/utils/oclif-utils';
import type { Category, Document } from './doc-types';
import { getManualSortCompareFunction } from '../../lib/utils/helpers';
import { capitanoizeOclifUsage } from '../../lib/utils/oclif-utils';
import { CapitanoCommand, Category, Document, OclifCommand } from './doc-types';
import * as utils from './utils';
function renderOclifCommand(command: Category['commands'][0]): string[] {
const result = [`## ${ent.encode(command.name || '')}`];
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.`,
);
function renderCapitanoCommand(command: CapitanoCommand): string[] {
const result = [`## ${ent.encode(command.signature)}`, command.help];
if (!_.isEmpty(command.options)) {
result.push('### Options');
for (const option of command.options!) {
if (option == null) {
throw new Error(`Undefined option in markdown generation!`);
}
result.push(
`#### ${utils.parseCapitanoOption(option)}`,
option.description,
);
}
}
return result;
}
result.push('### Description');
function renderOclifCommand(command: OclifCommand): string[] {
const result = [`## ${ent.encode(command.usage)}`];
const description = (command.description || '')
.split('\n')
.slice(1) // remove the first line, which oclif uses as help header
@ -47,13 +52,13 @@ function renderOclifCommand(command: Category['commands'][0]): string[] {
result.push(description);
if (!_.isEmpty(command.examples)) {
result.push('Examples:', command.examples!.map((v) => `\t${v}`).join('\n'));
result.push('Examples:', command.examples!.map(v => `\t${v}`).join('\n'));
}
if (!_.isEmpty(command.args)) {
result.push('### Arguments');
for (const [name, arg] of Object.entries(command.args!)) {
result.push(`#### ${name.toUpperCase()}`, arg.description || '');
for (const arg of command.args!) {
result.push(`#### ${arg.name.toUpperCase()}`, arg.description || '');
}
}
@ -64,7 +69,7 @@ function renderOclifCommand(command: Category['commands'][0]): string[] {
continue;
}
flag.name = name;
const flagUsage = Parser.flagUsages([flag])
const flagUsage = flagUsages([flag])
.map(([usage, _description]) => usage)
.join()
.trim();
@ -78,7 +83,11 @@ function renderOclifCommand(command: Category['commands'][0]): string[] {
function renderCategory(category: Category): string[] {
const result = [`# ${category.title}`];
for (const command of category.commands) {
result.push(...renderOclifCommand(command));
result.push(
...(typeof command === 'object'
? renderCapitanoCommand(command)
: renderOclifCommand(command)),
);
}
return result;
}
@ -94,8 +103,11 @@ function renderToc(categories: Category[]): string[] {
result.push(`- ${category.title}`);
result.push(
category.commands
.map((command) => {
const signature = capitanoizeOclifUsage(command.name);
.map(command => {
const signature =
typeof command === 'object'
? command.signature // Capitano
: capitanoizeOclifUsage(command.usage); // oclif
return `\t- [${ent.encode(signature)}](${getAnchor(signature)})`;
})
.join('\n'),
@ -104,7 +116,38 @@ function renderToc(categories: Category[]): string[] {
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<CapitanoCommand | OclifCommand, string>(
manualCategorySorting[category.title],
(cmd: CapitanoCommand | OclifCommand, x: string) =>
typeof cmd === 'object' // Capitano vs oclif command
? cmd.signature.replace(/\W+/g, ' ').includes(x)
: (cmd.usage || '')
.toString()
.replace(/\W+/g, ' ')
.includes(x),
),
);
}
}
}
export function render(doc: Document) {
sortCommands(doc);
const result = [
`# ${doc.title}`,
doc.introduction,

View File

@ -15,9 +15,42 @@
* limitations under the License.
*/
import { OptionDefinition } from 'capitano';
import * as ent from 'ent';
import * as fs from 'fs';
import * as _ from 'lodash';
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 (_.isArray(option.alias)) {
for (const alias of option.alias) {
result += `, ${getOptionSignature(alias)}`;
}
} else if (_.isString(option.alias)) {
result += `, ${getOptionSignature(option.alias)}`;
}
if (option.parameter) {
result += ` <${option.parameter}>`;
}
return ent.encode(result);
}
export class MarkdownFileParser {
constructor(public mdFilePath: string) {}
@ -61,7 +94,7 @@ export class MarkdownFileParser {
crlfDelay: Infinity,
});
rl.on('line', (line) => {
rl.on('line', line => {
// try to match a line like "## Getting Started", where the number
// of '#' characters is the sectionLevel ('##' -> 2), and the
// sectionTitle is "Getting Started"
@ -94,7 +127,9 @@ export class MarkdownFileParser {
} else {
reject(
new Error(
`Markdown section not found: title="${title}" file="${this.mdFilePath}"`,
`Markdown section not found: title="${title}" file="${
this.mdFilePath
}"`,
),
);
}

View File

@ -1,86 +0,0 @@
/**
* @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.
*/
// eslint-disable-next-line no-restricted-imports
import { stripIndent } from 'common-tags';
import * as _ from 'lodash';
import { promises as fs } from 'fs';
import * as path from 'path';
import { simpleGit } from 'simple-git';
const ROOT = path.normalize(path.join(__dirname, '..'));
/**
* Compare the timestamp of balena-cli.md with the timestamp of staged files,
* issuing an error if balena-cli.md is older.
* If balena-cli.md does not require updating and the developer cannot run
* `npm run build` on their laptop, the error message suggests a workaround
* using `touch`.
*/
async function checkBuildTimestamps() {
const git = simpleGit(ROOT);
const docFile = path.join(ROOT, 'docs', 'balena-cli.md');
const [docStat, gitStatus] = await Promise.all([
fs.stat(docFile),
git.status(),
]);
const stagedFiles = _.uniq([
...gitStatus.created,
...gitStatus.staged,
...gitStatus.renamed.map((o) => o.to),
])
// select only staged files that start with src/ or typings/
.filter((f) => f.match(/^(src|typings)[/\\]/))
.map((f) => path.join(ROOT, f));
const fStats = await Promise.all(stagedFiles.map((f) => fs.stat(f)));
fStats.forEach((fStat, index) => {
if (fStat.mtimeMs > docStat.mtimeMs) {
const fPath = stagedFiles[index];
throw new Error(stripIndent`
--------------------------------------------------------------------------------
ERROR: at least one staged file: "${fPath}"
has a more recent modification timestamp than the documentation file:
"${docFile}"
This probably means that \`npm run build\` or \`npm test\` have not been executed,
and this error can be fixed by doing so. Running \`npm run build\` or \`npm test\`
before commiting is required in order to update the CLI markdown documentation
(in case any command-line options were updated, added or removed) and also to
catch Typescript type check errors sooner and reduce overall waiting time, given
that the CI build/tests are currently rather lengthy.
If you need/wish to bypass this check without running \`npm run build\`, run:
npx touch -am "${docFile}"
and then try again.
--------------------------------------------------------------------------------
`);
}
});
}
async function run() {
try {
await checkBuildTimestamps();
} catch (err) {
console.error(err.message);
process.exitCode = 1;
}
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
run();

View File

@ -6,25 +6,17 @@
*
* 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.
*
* @param {string} version
*/
function parseSemver(version) {
const match = /v?(\d+)\.(\d+).(\d+)/.exec(version);
if (match == null) {
throw new Error(`Invalid semver version: ${version}`);
}
const [, major, minor, patch] = match;
return [parseInt(major, 10), parseInt(minor, 10), parseInt(patch, 10)];
}
/**
* @param {string} v1
* @param {string} v2
*/
function semverGte(v1, v2) {
const v1Array = parseSemver(v1);
const v2Array = parseSemver(v2);
let v1Array, v2Array; // number[]
try {
const [, major1, minor1, patch1] = /v?(\d+)\.(\d+).(\d+)/.exec(v1);
const [, major2, minor2, patch2] = /v?(\d+)\.(\d+).(\d+)/.exec(v2);
v1Array = [parseInt(major1), parseInt(minor1), parseInt(patch1)];
v2Array = [parseInt(major2), parseInt(minor2), parseInt(patch2)];
} catch (err) {
throw new Error(`Invalid semver versions: '${v1}' or '${v2}'`);
}
for (let i = 0; i < 3; i++) {
if (v1Array[i] < v2Array[i]) {
return false;
@ -35,9 +27,29 @@ function semverGte(v1, v2) {
return true;
}
function _testSemverGet() {
const assert = require('assert').strict;
assert(semverGte('6.4.1', '6.4.1'));
assert(semverGte('6.4.1', 'v6.4.1'));
assert(semverGte('v6.4.1', '6.4.1'));
assert(semverGte('v6.4.1', 'v6.4.1'));
assert(semverGte('6.4.1', '6.4.0'));
assert(semverGte('6.4.1', '6.3.1'));
assert(semverGte('6.4.1', '5.4.1'));
assert(!semverGte('6.4.1', '6.4.2'));
assert(!semverGte('6.4.1', '6.5.1'));
assert(!semverGte('6.4.1', '7.4.1'));
assert(semverGte('v6.4.1', 'v4.0.0'));
assert(!semverGte('v6.4.1', 'v6.9.0'));
assert(!semverGte('v6.4.1', 'v7.0.0'));
}
function checkNpmVersion() {
const execSync = require('child_process').execSync;
const npmVersion = execSync('npm --version').toString().trim();
const npmVersion = execSync('npm --version')
.toString()
.trim();
const requiredVersion = '6.9.0';
if (!semverGte(npmVersion, requiredVersion)) {
// In case you take issue with the error message below:
@ -47,25 +59,17 @@ function checkNpmVersion() {
// 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
// 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
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
damaged. Please revert it to the master branch state with a command such as:
"git checkout master -- npm-shrinkwrap.json"
Then re-run "npm install" using npm version ${requiredVersion} or later.
-----------------------------------------------------------------------------`);
-------------------------------------------------------------------------------`);
process.exit(1);
}
}
function main() {
try {
checkNpmVersion();
} catch (e) {
console.error(e.message || e);
process.exitCode = 1;
}
}
main();
checkNpmVersion();

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

@ -0,0 +1,258 @@
/**
* @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);
}
}
let cachedOctokit: any;
/** Return a cached Octokit instance, creating a new one as needed. */
function getOctokit(): any {
if (cachedOctokit) {
return cachedOctokit;
}
const Octokit = require('@octokit/rest').plugin(
require('@octokit/plugin-throttling'),
);
return (cachedOctokit = 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');
const parsed = parse(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

@ -19,69 +19,97 @@ import * as _ from 'lodash';
import {
buildOclifInstaller,
buildStandalone,
catchUncommitted,
signFilesForNotarization,
testShrinkwrap,
buildStandaloneZip,
fixPathForMsys,
ROOT,
runUnderMsys,
} from './build-bin';
import {
release,
updateDescriptionOfReleasesAffectedByIssue1359,
} from './deploy-bin';
// DEBUG set to falsy for negative values else is truthy
process.env.DEBUG = ['0', 'no', 'false', '', undefined].includes(
process.env.DEBUG?.toLowerCase(),
)
? ''
: '1';
function exitWithError(error: Error | string): never {
console.error(`Error: ${error}`);
process.exit(1);
throw error; // to please the Typescript compiler
}
/**
* Trivial command-line parser. Check whether the command-line argument is one
* of the following strings, then call the appropriate functions:
* 'build:installer' (to build a native oclif installer)
* 'build:standalone' (to build a standalone 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))
*/
async function parse(args?: string[]) {
export async function run(args?: string[]) {
args = args || process.argv.slice(2);
console.error(`[debug] automation/run.ts process.argv=[${process.argv}]`);
console.error(`[debug] automation/run.ts args=[${args}]`);
console.log(`automation/run.ts process.argv=[${process.argv}]\n`);
console.log(`automation/run.ts args=[${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 } = {
'build:installer': buildOclifInstaller,
'build:standalone': buildStandalone,
'sign:binaries': signFilesForNotarization,
'catch-uncommitted': catchUncommitted,
'test-shrinkwrap': testShrinkwrap,
'build:standalone': buildStandaloneZip,
fix1359: updateDescriptionOfReleasesAffectedByIssue1359,
release,
};
for (const arg of args) {
if (!Object.hasOwn(commands, arg)) {
throw new Error(`command unknown: ${arg}`);
if (!commands.hasOwnProperty(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 = require('crypto')
.randomBytes(6)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_'); // base64url (RFC 4648)
process.env.BUILD_TMP = `C:\\tmp\\${randID}`;
}
for (const arg of args) {
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];
await cmdFunc();
} catch (err) {
if (typeof err === 'object') {
err.message = `"${arg}": ${err.message}`;
}
throw err;
return exitWithError(`"${arg}": ${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();

View File

@ -1,20 +0,0 @@
#!/bin/bash
set -e
cp npm-shrinkwrap.json npm-shrinkwrap.json.old
npm i
npm dedupe
npm i
if ! diff -q npm-shrinkwrap.json npm-shrinkwrap.json.old > /dev/null; then
rm npm-shrinkwrap.json.old
echo "** npm-shrinkwrap.json was not deduplicated or not fully committed - FAIL **";
echo "** This can usually be fixed with: **";
echo "** git checkout master -- npm-shrinkwrap.json **";
echo "** rm -rf node_modules **";
echo "** npm install && npm dedupe && npm install **";
exit 1;
fi
rm npm-shrinkwrap.json.old

19
automation/tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es2017",
"strict": true,
"strictPropertyInitialization": false,
"noUnusedLocals": true,
"noUnusedParameters": true,
"preserveConstEnums": true,
"removeComments": true,
"resolveJsonModule": true,
"sourceMap": true,
"skipLibCheck": true,
"typeRoots" : [
"../node_modules/@types",
"../typings"
]
}
}

View File

@ -1,139 +0,0 @@
import { exec } from 'child_process';
import * as semver from 'semver';
const changeTypes = ['major', 'minor', 'patch'] as const;
const validateChangeType = (maybeChangeType = 'minor') => {
maybeChangeType = maybeChangeType.toLowerCase();
switch (maybeChangeType) {
case 'patch':
case 'minor':
case 'major':
return maybeChangeType;
default:
throw new Error(`Invalid change type: '${maybeChangeType}'`);
}
};
const compareSemverChangeType = (oldVersion: string, newVersion: string) => {
const oldSemver = semver.parse(oldVersion)!;
const newSemver = semver.parse(newVersion)!;
for (const changeType of changeTypes) {
if (oldSemver[changeType] !== newSemver[changeType]) {
return changeType;
}
}
};
const run = async (cmd: string) => {
console.info(`Running '${cmd}'`);
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
const p = exec(cmd, { encoding: 'utf8' }, (err, stdout, stderr) => {
if (err) {
reject(err);
return;
}
resolve({ stdout, stderr });
});
p.stdout?.pipe(process.stdout);
p.stderr?.pipe(process.stderr);
});
};
const getVersion = async (module: string): Promise<string> => {
const { stdout } = await run(`npm ls --json --depth 0 ${module}`);
return JSON.parse(stdout).dependencies[module].version;
};
interface Upstream {
repo: string;
url: string;
module?: string;
}
const getUpstreams = async () => {
const fs = await import('fs');
const repoYaml = fs.readFileSync(__dirname + '/../repo.yml', 'utf8');
const yaml = await import('js-yaml');
const { upstream } = yaml.load(repoYaml) as {
upstream: Upstream[];
};
return upstream;
};
const getUsage = (upstreams: Upstream[], upstreamName: string) => `
Usage: npm run update ${upstreamName} $version [$changeType=minor]
Upstream names: ${upstreams.map(({ repo }) => repo).join(', ')}
`;
async function $main() {
const upstreams = await getUpstreams();
if (process.argv.length < 3) {
throw new Error(getUsage(upstreams, '$upstreamName'));
}
const upstreamName = process.argv[2];
const upstream = upstreams.find((v) => v.repo === upstreamName);
if (!upstream) {
throw new Error(
`Invalid upstream name '${upstreamName}', valid options: ${upstreams
.map(({ repo }) => repo)
.join(', ')}`,
);
}
if (process.argv.length < 4) {
throw new Error(getUsage(upstreams, upstreamName));
}
const packageName = upstream.module || upstream.repo;
const oldVersion = await getVersion(packageName);
await run(`npm install ${packageName}@${process.argv[3]}`);
const newVersion = await getVersion(packageName);
if (newVersion === oldVersion) {
throw new Error(`Already on version '${newVersion}'`);
}
console.log(`Updated ${upstreamName} from ${oldVersion} to ${newVersion}`);
const semverChangeType = compareSemverChangeType(oldVersion, newVersion);
const changeType = process.argv[4]
? // if the caller specified a change type, use that one
validateChangeType(process.argv[4])
: // use the same change type as in the dependency, but avoid major bumps
semverChangeType && semverChangeType !== 'major'
? semverChangeType
: 'minor';
console.log(`Using Change-type: ${changeType}`);
let { stdout: currentBranch } = await run('git rev-parse --abbrev-ref HEAD');
currentBranch = currentBranch.trim();
console.log(`Currenty on branch: '${currentBranch}'`);
if (currentBranch === 'master') {
await run(`git checkout -b "update-${upstreamName}-${newVersion}"`);
}
await run(`git add package.json npm-shrinkwrap.json`);
await run(
`git commit --message "Update ${upstreamName} to ${newVersion}" --message "Update ${upstreamName} from ${oldVersion} to ${newVersion}" --message "Change-type: ${changeType}"`,
);
}
async function main() {
try {
await $main();
} catch (e) {
console.error(e);
process.exitCode = 1;
}
}
void main();

View File

@ -1,88 +0,0 @@
/**
* @license
* Copyright 2019-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 { spawn } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
import * as whichMod from 'which';
export const ROOT = path.join(__dirname, '..');
export function loadPackageJson() {
const packageJsonPath = path.join(ROOT, 'package.json');
const packageJson = fs.readFileSync(packageJsonPath, 'utf8');
return JSON.parse(packageJson);
}
/**
* Error handling wrapper around the npm `which` package:
* "Like the unix which utility. Finds the first instance of a specified
* executable in the PATH environment variable. Does not cache the results,
* so hash -r is not needed when the PATH changes."
*
* @param program Basename of a program, for example 'ssh'
* @returns The program's full path, e.g. 'C:\WINDOWS\System32\OpenSSH\ssh.EXE'
*/
export async function which(program: string): Promise<string> {
let programPath: string;
try {
programPath = await whichMod(program);
} catch (err) {
if (err.code === 'ENOENT') {
throw new Error(`'${program}' program not found. Is it installed?`);
}
throw err;
}
return programPath;
}
/**
* Call which(programName) and spawn() with the given arguments. Throw an error
* if the process exit code is not zero.
*/
export async function whichSpawn(
programName: string,
args: string[] = [],
): Promise<void> {
const program = await which(programName);
let error: Error | undefined;
let exitCode: number | undefined;
try {
exitCode = await new Promise<number>((resolve, reject) => {
try {
spawn(program, args, { stdio: 'inherit' })
.on('error', reject)
.on('close', resolve);
} catch (err) {
reject(err as Error);
}
});
} catch (err) {
error = err;
}
if (error || exitCode) {
const msg = [
`${programName} failed with exit code ${exitCode}:`,
`"${program}" [${args}]`,
];
if (error) {
msg.push(`${error}`);
}
throw new Error(msg.join('\n'));
}
}

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

12
bin/balena Executable file
View File

@ -0,0 +1,12 @@
#!/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';
// Use fast-boot to cache require lookups, speeding up startup
require('fast-boot2').start({
cacheFile: __dirname + '/.fast-boot.json'
})
// Run the CLI
require('../build/app').run();

View File

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

29
bin/balena-dev Executable file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env node
// ****************************************************************************
// THIS IS FOR DEV PERROSES 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';
// Use fast-boot to cache require lookups, speeding up startup
require('fast-boot2').start({
cacheFile: '.fast-boot.json',
});
require('coffeescript/register');
const path = require('path');
const rootDir = path.join(__dirname, '..');
// 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();

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();

127
coffeelint.json Normal file
View File

@ -0,0 +1,127 @@
{
"coffeescript_error": {
"level": "error"
},
"arrow_spacing": {
"name": "arrow_spacing",
"level": "error"
},
"no_tabs": {
"name": "no_tabs",
"level": "ignore"
},
"no_trailing_whitespace": {
"name": "no_trailing_whitespace",
"level": "error",
"allowed_in_comments": false,
"allowed_in_empty_lines": false
},
"max_line_length": {
"name": "max_line_length",
"value": 120,
"level": "error",
"limitComments": true
},
"line_endings": {
"name": "line_endings",
"level": "ignore",
"value": "unix"
},
"no_trailing_semicolons": {
"name": "no_trailing_semicolons",
"level": "error"
},
"indentation": {
"name": "indentation",
"value": 1,
"level": "error"
},
"camel_case_classes": {
"name": "camel_case_classes",
"level": "error"
},
"colon_assignment_spacing": {
"name": "colon_assignment_spacing",
"level": "error",
"spacing": {
"left": 0,
"right": 1
}
},
"no_implicit_braces": {
"name": "no_implicit_braces",
"level": "ignore",
"strict": false
},
"no_plusplus": {
"name": "no_plusplus",
"level": "ignore"
},
"no_throwing_strings": {
"name": "no_throwing_strings",
"level": "error"
},
"no_backticks": {
"name": "no_backticks",
"level": "error"
},
"no_implicit_parens": {
"name": "no_implicit_parens",
"strict": false,
"level": "ignore"
},
"no_empty_param_list": {
"name": "no_empty_param_list",
"level": "error"
},
"no_stand_alone_at": {
"name": "no_stand_alone_at",
"level": "ignore"
},
"space_operators": {
"name": "space_operators",
"level": "error"
},
"duplicate_key": {
"name": "duplicate_key",
"level": "error"
},
"empty_constructor_needs_parens": {
"name": "empty_constructor_needs_parens",
"level": "ignore"
},
"cyclomatic_complexity": {
"name": "cyclomatic_complexity",
"value": 10,
"level": "ignore"
},
"newlines_after_classes": {
"name": "newlines_after_classes",
"value": 3,
"level": "ignore"
},
"no_unnecessary_fat_arrows": {
"name": "no_unnecessary_fat_arrows",
"level": "error"
},
"missing_fat_arrows": {
"name": "missing_fat_arrows",
"level": "ignore"
},
"non_empty_constructor_needs_parens": {
"name": "non_empty_constructor_needs_parens",
"level": "ignore"
},
"no_unnecessary_double_quotes": {
"name": "no_unnecessary_double_quotes",
"level": "error"
},
"no_debugger": {
"name": "no_debugger",
"level": "warn"
},
"no_interpolation_in_single_quotes": {
"name": "no_interpolation_in_single_quotes",
"level": "error"
}
}

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.

1937
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: '^_',
}],
},
}),
];

33
gulpfile.coffee Normal file
View File

@ -0,0 +1,33 @@
path = require('path')
gulp = require('gulp')
coffee = require('gulp-coffee')
inlinesource = require('gulp-inline-source')
shell = require('gulp-shell')
packageJSON = require('./package.json')
OPTIONS =
files:
coffee: [ 'lib/**/*.coffee', 'gulpfile.coffee' ]
app: 'lib/**/*.coffee'
tests: 'tests/**/*.spec.js'
pages: 'lib/auth/pages/*.ejs'
directories:
build: 'build/'
gulp.task 'pages', ->
gulp.src(OPTIONS.files.pages)
.pipe(inlinesource())
.pipe(gulp.dest('build/auth/pages'))
gulp.task 'coffee', ->
gulp.src(OPTIONS.files.app)
.pipe(coffee(bare: true, header: true))
.pipe(gulp.dest(OPTIONS.directories.build))
gulp.task 'build', gulp.series [
'coffee',
'pages'
]
gulp.task 'watch', gulp.series [ 'build' ], ->
gulp.watch([ OPTIONS.files.coffee ], [ 'build' ])

138
lib/actions-oclif/env/add.ts vendored Normal file
View File

@ -0,0 +1,138 @@
/**
* @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 { Command, flags } from '@oclif/command';
import { stripIndent } from 'common-tags';
import * as _ from 'lodash';
import * as cf from '../../utils/common-flags';
import { CommandHelp } from '../../utils/oclif-utils';
interface FlagsDef {
application?: string;
device?: string;
help: void;
quiet: boolean;
}
interface ArgsDef {
name: string;
value?: string;
}
export default class EnvAddCmd extends Command {
public static description = stripIndent`
Add an environment or config variable to an application or device.
Add an environment or config variable to an application or device, as selected
by the respective command-line options.
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
message will be printed. Use \`--quiet\` to suppress it.
Service-specific variables are not currently supported. The given command line
examples variables that apply to all services in an app or device.
`;
public static examples = [
'$ balena env add TERM --application MyApp',
'$ balena env add EDITOR vim --application MyApp',
'$ balena env add EDITOR vim --device 7cf02a6',
];
public static args = [
{
name: 'name',
required: true,
description: 'environment or config variable name',
},
{
name: 'value',
required: false,
description:
"variable value; if omitted, use value from CLI's environment",
},
];
// hardcoded 'env add' to avoid oclif's 'env:add' topic syntax
public static usage =
'env add ' + new CommandHelp({ args: EnvAddCmd.args }).defaultUsage();
public static flags: flags.Input<FlagsDef> = {
application: _.assign({ exclusive: ['device'] }, cf.application),
device: _.assign({ exclusive: ['application'] }, cf.device),
help: cf.help,
quiet: cf.quiet,
};
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
EnvAddCmd,
);
const cmd = this;
const balena = (await import('balena-sdk')).fromSharedOptions();
const { exitWithExpectedError } = await import('../../utils/patterns');
if (params.value == null) {
params.value = process.env[params.name];
if (params.value == null) {
throw new Error(
`Environment value not found for variable: ${params.name}`,
);
} else if (!options.quiet) {
cmd.warn(
`Using ${params.name}=${params.value} from CLI process environment`,
);
}
}
const reservedPrefixes = await getReservedPrefixes();
const isConfigVar = _.some(reservedPrefixes, prefix =>
_.startsWith(params.name, prefix),
);
const varType = isConfigVar ? 'configVar' : 'envVar';
if (options.application) {
await balena.models.application[varType].set(
options.application,
params.name,
params.value,
);
} else if (options.device) {
await balena.models.device[varType].set(
options.device,
params.name,
params.value,
);
} else {
exitWithExpectedError('You must specify an application or device');
}
}
}
async function getReservedPrefixes(): Promise<string[]> {
const balena = (await import('balena-sdk')).fromSharedOptions();
const settings = await balena.settings.getAll();
const response = await balena.request.send({
baseUrl: settings.apiUrl,
url: '/config/vars',
});
return response.body.reservedNamespaces;
}

96
lib/actions-oclif/env/rename.ts vendored Normal file
View File

@ -0,0 +1,96 @@
/**
* @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 { Command, flags } from '@oclif/command';
import { stripIndent } from 'common-tags';
import * as cf from '../../utils/common-flags';
import { CommandHelp } from '../../utils/oclif-utils';
type IArg<T> = import('@oclif/parser').args.IArg<T>;
interface FlagsDef {
device: boolean;
help: void;
}
interface ArgsDef {
id: number;
value: string;
}
export default class EnvRenameCmd extends Command {
public static description = stripIndent`
Change the value of an environment variable for an app or device.
Change the value of an environment variable for an application or device,
as selected by the '--device' option. The variable is identified by its
database ID, rather than its name. The 'balena envs' command can be used
to list the variable's ID.
Service-specific variables are not currently supported. The following
examples modify variables that apply to all services in an app or device.
`;
public static examples = [
'$ balena env rename 376 emacs',
'$ balena env rename 376 emacs --device',
];
public static args: Array<IArg<any>> = [
{
name: 'id',
required: true,
description: 'environment variable numeric database ID',
parse: input => parseInt(input, 10),
},
{
name: 'value',
required: true,
description:
"variable value; if omitted, use value from CLI's environment",
},
];
// hardcoded 'env add' to avoid oclif's 'env:add' topic syntax
public static usage =
'env rename ' + new CommandHelp({ args: EnvRenameCmd.args }).defaultUsage();
public static flags: flags.Input<FlagsDef> = {
device: flags.boolean({
char: 'd',
description:
'select a device variable instead of an application variable',
}),
help: cf.help,
};
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
EnvRenameCmd,
);
const balena = (await import('balena-sdk')).fromSharedOptions();
await balena.pine.patch({
resource: options.device
? 'device_environment_variable'
: 'application_environment_variable',
id: params.id,
body: {
value: params.value,
},
});
}
}

120
lib/actions-oclif/env/rm.ts vendored Normal file
View File

@ -0,0 +1,120 @@
/**
* @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 { Command, flags } from '@oclif/command';
import { stripIndent } from 'common-tags';
import { CommandHelp } from '../../utils/oclif-utils';
interface FlagsDef {
config: boolean;
device: boolean;
yes: boolean;
}
interface ArgsDef {
id: number;
}
export default class EnvRmCmd extends Command {
public static description = stripIndent`
Remove an environment variable from an application or device.
Remove a configuration or environment variable from an application or device,
as selected by command-line options.
Note that this command asks for confirmation interactively.
You can avoid this by passing the \`--yes\` boolean option.
The --device option selects a device instead of an application.
The --config option selects a config var instead of an env var.
Service-specific variables are not currently supported. The following
examples remove variables that apply to all services in an app or device.
`;
public static examples = [
'$ balena env rm 215',
'$ balena env rm 215 --yes',
'$ balena env rm 215 --config',
'$ balena env rm 215 --device',
'$ balena env rm 215 --device --config',
];
public static args = [
{
name: 'id',
required: true,
description: 'environment variable numeric database ID',
},
];
// hardcoded 'env add' to avoid oclif's 'env:add' topic syntax
public static usage =
'env rm ' + new CommandHelp({ args: EnvRmCmd.args }).defaultUsage();
public static flags: flags.Input<FlagsDef> = {
device: flags.boolean({
char: 'd',
description:
'Selects a device environment variable instead of an application environment variable',
default: false,
}),
config: flags.boolean({
char: 'c',
description:
'Selects a configuration variable instead of an environment variable',
default: false,
}),
yes: flags.boolean({
char: 'y',
description: 'Run in non-interactive mode',
default: false,
}),
};
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
EnvRmCmd,
);
const balena = (await import('balena-sdk')).fromSharedOptions();
const patterns = await import('../../utils/patterns');
if (isNaN(params.id) || !Number.isInteger(Number(params.id))) {
patterns.exitWithExpectedError(
'The environment variable id must be an integer',
);
}
await patterns.confirm(
options.yes || false,
'Are you sure you want to delete the environment variable?',
undefined,
true,
);
await balena.pine.delete({
resource: options.device
? options.config
? 'device_config_variable'
: 'device_environment_variable'
: options.config
? 'application_config_variable'
: 'application_environment_variable',
id: params.id,
});
}
}

95
lib/actions-oclif/envs.ts Normal file
View File

@ -0,0 +1,95 @@
/**
* @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 { Command, flags } from '@oclif/command';
import { ApplicationVariable, DeviceVariable } from 'balena-sdk';
import { stripIndent } from 'common-tags';
import * as _ from 'lodash';
import * as cf from '../utils/common-flags';
import { CommandHelp } from '../utils/oclif-utils';
interface FlagsDef {
application?: string;
config: boolean;
device?: string;
help: void;
verbose: boolean;
}
export default class EnvsCmd extends Command {
public static description = stripIndent`
List the environment or config variables of an app or device.
List the environment or config variables of an application or device,
as selected by the respective command-line options.
The --config option is used to list "configuration variables" that
control balena features.
Service-specific variables are not currently supported. The following
examples list variables that apply to all services in an app or device.
`;
public static examples = [
'$ balena envs --application MyApp',
'$ balena envs --application MyApp --config',
'$ balena envs --device 7cf02a6',
];
public static usage = (
'envs ' + new CommandHelp({ args: EnvsCmd.args }).defaultUsage()
).trim();
public static flags: flags.Input<FlagsDef> = {
application: _.assign({ exclusive: ['device'] }, cf.application),
config: flags.boolean({
char: 'c',
description: 'show config variables',
}),
device: _.assign({ exclusive: ['application'] }, cf.device),
help: cf.help,
verbose: cf.verbose,
};
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(EnvsCmd);
const balena = (await import('balena-sdk')).fromSharedOptions();
const visuals = await import('resin-cli-visuals');
const { exitWithExpectedError } = await import('../utils/patterns');
const cmd = this;
let environmentVariables: ApplicationVariable[] | DeviceVariable[];
if (options.application) {
environmentVariables = await balena.models.application[
options.config ? 'configVar' : 'envVar'
].getAllByApplication(options.application);
} else if (options.device) {
environmentVariables = await balena.models.device[
options.config ? 'configVar' : 'envVar'
].getAllByDevice(options.device);
} else {
return exitWithExpectedError('You must specify an application or device');
}
if (_.isEmpty(environmentVariables)) {
return exitWithExpectedError('No environment variables found');
}
cmd.log(
visuals.table.horizontal(environmentVariables, ['id', 'name', 'value']),
);
}
}

View File

@ -15,184 +15,165 @@
* limitations under the License.
*/
import { Flags, Args, Command } from '@oclif/core';
import type { Interfaces } from '@oclif/core';
import type * as BalenaSdk from 'balena-sdk';
import { Command, flags } from '@oclif/command';
import BalenaSdk = require('balena-sdk');
import { stripIndent } from 'common-tags';
import * as _ from 'lodash';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent, getCliForm } from '../../utils/lazy';
import {
applicationIdInfo,
devModeInfo,
secureBootInfo,
} from '../../utils/messages';
import { CommandHelp } from '../../utils/oclif-utils';
const CONNECTIONS_FOLDER = '/system-connections';
interface FlagsDef {
advanced?: boolean;
app?: string;
application?: string;
config?: string;
'config-app-update-poll-interval'?: number;
'config-network'?: string;
'config-wifi-key'?: string;
'config-wifi-ssid'?: string;
device?: string; // device UUID
'device-api-key'?: string;
'device-type'?: string;
help?: void;
version?: string;
}
type FlagsDef = Interfaces.InferredFlags<typeof OsConfigureCmd.flags>;
interface ArgsDef {
image: string;
}
interface DeferredDevice extends BalenaSdk.Device {
belongs_to__application: BalenaSdk.PineDeferred;
}
interface Answers {
appUpdatePollInterval: number; // in minutes
developmentMode?: boolean; // balenaOS development variant
secureBoot?: boolean;
deviceType: string; // e.g. "raspberrypi3"
network: 'ethernet' | 'wifi';
version: string; // e.g. "2.32.0+rev1"
wifiSsid?: string;
wifiKey?: string;
provisioningKeyName?: string;
provisioningKeyExpiryDate?: string;
}
const deviceApiKeyDeprecationMsg = stripIndent`
The --device-api-key option is deprecated and will be removed in a future release.
A suitable key is automatically generated or fetched if this option is omitted.`;
export default class OsConfigureCmd extends Command {
public static description = stripIndent`
Configure a previously downloaded balenaOS image.
Configure a previously downloaded balenaOS image for a specific device type
or fleet.
Configure a previously downloaded balenaOS image for a specific device type or
balena application.
Configuration settings such as WiFi authentication will be taken from the
following sources, in precedence order:
1. Command-line options like \`--config-wifi-ssid\`
2. A given \`config.json\` file specified with the \`--config\` option.
3. User input through interactive prompts (text menus).
The --device-type option is used to override the fleet's default device type,
in case of a fleet with mixed device types.
The --device-type option may be used to override the application's default
device type, in case of an application with mixed device types.
${devModeInfo.split('\n').join('\n\t\t')}
${secureBootInfo.split('\n').join('\n\t\t')}
The --system-connection (-c) option is used to inject NetworkManager connection
profiles for additional network interfaces, such as cellular/GSM or additional
WiFi or ethernet connections. This option may be passed multiple times in case there
are multiple files to inject. See connection profile examples and reference at:
https://www.balena.io/docs/reference/OS/network/2.x/
https://developer.gnome.org/NetworkManager/stable/ref-settings.html
${applicationIdInfo.split('\n').join('\n\t\t')}
${deviceApiKeyDeprecationMsg.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena os configure ../path/rpi3.img --device 7cf02a6',
'$ balena os configure ../path/rpi3.img --fleet myorg/myfleet',
'$ balena os configure ../path/rpi3.img --fleet MyFleet --version 2.12.7',
'$ balena os configure ../path/rpi3.img -f MyFinFleet --device-type raspberrypi3',
'$ balena os configure ../path/rpi3.img -f MyFinFleet --device-type raspberrypi3 --config myWifiConfig.json',
'$ balena os configure ../path/rpi3.img --device 7cf02a6 --device-api-key <existingDeviceKey>',
'$ balena os configure ../path/rpi3.img --app MyApp',
'$ balena os configure ../path/rpi3.img --app MyApp --version 2.12.7',
'$ balena os configure ../path/rpi3.img --app MyFinApp --device-type raspberrypi3',
'$ balena os configure ../path/rpi3.img --app MyFinApp --device-type raspberrypi3 --config myWifiConfig.json',
];
public static args = {
image: Args.string({
public static args = [
{
name: 'image',
required: true,
description: 'path to a balenaOS image file, e.g. "rpi3.img"',
}),
};
},
];
public static flags = {
advanced: Flags.boolean({
// hardcoded 'os configure' to avoid oclif's 'os:configure' topic syntax
public static usage =
'os configure ' +
new CommandHelp({ args: OsConfigureCmd.args }).defaultUsage();
public static flags: flags.Input<FlagsDef> = {
advanced: flags.boolean({
char: 'v',
description:
'ask advanced configuration questions (when in interactive mode)',
}),
fleet: { ...cf.fleet, exclusive: ['device'] },
config: Flags.string({
app: flags.string({
description: "same as '--application'",
exclusive: ['application', 'device'],
}),
application: _.assign({ exclusive: ['app', 'device'] }, cf.application),
config: flags.string({
description:
'path to a pre-generated config.json file to be injected in the OS image',
exclusive: ['provisioning-key-name', 'provisioning-key-expiry-date'],
}),
'config-app-update-poll-interval': Flags.integer({
'config-app-update-poll-interval': flags.integer({
description:
'supervisor cloud polling interval in minutes (e.g. for variable updates)',
'interval (in minutes) for the on-device balena supervisor periodic app update check',
}),
'config-network': Flags.string({
'config-network': flags.string({
description: 'device network type (non-interactive configuration)',
options: ['ethernet', 'wifi'],
}),
'config-wifi-key': Flags.string({
'config-wifi-key': flags.string({
description: 'WiFi key (password) (non-interactive configuration)',
}),
'config-wifi-ssid': Flags.string({
'config-wifi-ssid': flags.string({
description: 'WiFi SSID (network name) (non-interactive configuration)',
}),
dev: cf.dev,
secureBoot: cf.secureBoot,
device: {
...cf.device,
exclusive: [
'fleet',
'provisioning-key-name',
'provisioning-key-expiry-date',
],
},
'device-type': Flags.string({
device: _.assign({ exclusive: ['app', 'application'] }, cf.device),
'device-api-key': flags.string({
char: 'k',
description:
'device type slug (e.g. "raspberrypi3") to override the fleet device type',
'custom device API key (DEPRECATED and only supported with balenaOS 2.0.3+)',
}),
'initial-device-name': Flags.string({
'device-type': flags.string({
description:
'This option will set the device name when the device provisions',
'device type slug (e.g. "raspberrypi3") to override the application device type',
}),
version: Flags.string({
help: cf.help,
version: flags.string({
description: 'balenaOS version, for example "2.32.0" or "2.44.0+rev1"',
}),
'system-connection': Flags.string({
multiple: true,
char: 'c',
required: false,
description:
"paths to local files to place into the 'system-connections' directory",
}),
'provisioning-key-name': Flags.string({
description: 'custom key name assigned to generated provisioning api key',
exclusive: ['config', 'device'],
}),
'provisioning-key-expiry-date': Flags.string({
description:
'expiry date assigned to generated provisioning api key (format: YYYY-MM-DD)',
exclusive: ['config', 'device'],
}),
};
public static authenticated = true;
public async run() {
const { args: params, flags: options } = await this.parse(OsConfigureCmd);
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
OsConfigureCmd,
);
// Prefer options.application over options.app
options.application = options.application || options.app;
options.app = undefined;
await validateOptions(options);
const devInit = await import('balena-device-init');
const { promises: fs } = await import('fs');
const balena = (await import('balena-sdk')).fromSharedOptions();
const fs = await import('mz/fs');
const { generateDeviceConfig, generateApplicationConfig } = await import(
'../../utils/config'
);
const helpers = await import('../../utils/helpers');
const { getApplication } = await import('../../utils/sdk');
let app: ApplicationWithDeviceTypeSlug | undefined;
let device;
let app: BalenaSdk.Application | undefined;
let device: BalenaSdk.Device | undefined;
let deviceTypeSlug: string;
const balena = getBalenaSdk();
if (options.device) {
device = (await balena.models.device.get(options.device, {
$expand: {
is_of__device_type: { $select: 'slug' },
},
})) as DeviceWithDeviceType & {
belongs_to__application: BalenaSdk.PineDeferred;
};
deviceTypeSlug = device.is_of__device_type[0].slug;
device = await balena.models['device'].get(options.device);
deviceTypeSlug = device.device_type;
} else {
app = (await getApplication(balena, options.fleet!, {
$expand: {
is_for__device_type: { $select: 'slug' },
},
})) as ApplicationWithDeviceTypeSlug;
await checkDeviceTypeCompatibility(options, app);
deviceTypeSlug =
options['device-type'] || app.is_for__device_type[0].slug;
app = await balena.models['application'].get(options.application!);
await checkDeviceTypeCompatibility(balena, options, app);
deviceTypeSlug = options['device-type'] || app.device_type;
}
const deviceTypeManifest = await helpers.getManifest(
@ -206,118 +187,77 @@ export default class OsConfigureCmd extends Command {
configJson = JSON.parse(rawConfig);
}
const { normalizeOsVersion } = await import('../../utils/normalization');
const osVersion = normalizeOsVersion(
options.version ||
(await getOsVersionFromImage(
params.image,
deviceTypeManifest,
devInit,
)),
);
const { validateDevOptionAndWarn } = await import('../../utils/config');
await validateDevOptionAndWarn(options.dev, osVersion);
const { validateSecureBootOptionAndWarn } = await import(
'../../utils/config'
);
await validateSecureBootOptionAndWarn(
options.secureBoot,
deviceTypeSlug,
osVersion,
);
const answers: Answers = await askQuestionsForDeviceType(
deviceTypeManifest,
options,
configJson,
);
if (options.fleet) {
if (options.application) {
answers.deviceType = deviceTypeSlug;
}
answers.version = osVersion;
answers.developmentMode = options.dev;
answers.secureBoot = options.secureBoot;
answers.provisioningKeyName = options['provisioning-key-name'];
answers.provisioningKeyExpiryDate = options['provisioning-key-expiry-date'];
answers.version =
options.version ||
(await getOsVersionFromImage(params.image, deviceTypeManifest, devInit));
if (_.isEmpty(configJson)) {
if (device) {
configJson = await generateDeviceConfig(device, undefined, answers);
configJson = await generateDeviceConfig(
device as DeferredDevice,
options['device-api-key'],
answers,
);
} else {
configJson = await generateApplicationConfig(app!, answers);
}
}
if (
options['initial-device-name'] &&
options['initial-device-name'] !== ''
) {
configJson!.initialDeviceName = options['initial-device-name'];
}
console.info('Configuring operating system image');
const image = params.image;
await helpers.osProgressHandler(
await devInit.configure(
image,
params.image,
deviceTypeManifest,
configJson || {},
answers,
),
);
if (options['system-connection']) {
const path = await import('path');
const files = await Promise.all(
options['system-connection'].map(async (filePath) => {
const content = await fs.readFile(filePath, 'utf8');
const name = path.basename(filePath);
return {
name,
content,
};
}),
);
const { getBootPartition } = await import('balena-config-json');
const bootPartition = await getBootPartition(params.image);
const imagefs = await import('balena-image-fs');
for (const { name, content } of files) {
await imagefs.interact(image, bootPartition, async (_fs) => {
await _fs.promises.writeFile(
path.join(CONNECTIONS_FOLDER, name),
content,
);
});
console.info(`Copied system-connection file: ${name}`);
}
}
}
}
async function validateOptions(options: FlagsDef) {
if (process.platform === 'win32') {
throw new ExpectedError(stripIndent`
Unsupported platform error: the 'balena os configure' command currently requires
the Windows Subsystem for Linux in order to run on Windows. It was tested with
the Ubuntu 18.04 distribution from the Microsoft Store. With WSL, a balena CLI
release for Linux (rather than Windows) should be installed: for example, the
standalone zip package for Linux. (It is possible to have both a Windows CLI
release and a Linux CLI release installed simultaneously.) For more information
on WSL and the balena CLI installation options, please check:
- https://docs.microsoft.com/en-us/windows/wsl/about
- https://github.com/balena-io/balena-cli/blob/master/INSTALL.md
`);
}
// The 'device' and 'application' options are declared "exclusive" in the oclif
// flag definitions above, so oclif will enforce that they are not both used together.
if (!options.device && !options.fleet) {
if (!options.device && !options.application) {
throw new ExpectedError(
"Either the '--device' or the '--fleet' option must be provided",
"Either the '--device' or the '--application' option must be provided",
);
}
if (!options.fleet && options['device-type']) {
if (!options.application && options['device-type']) {
throw new ExpectedError(
"The '--device-type' option can only be used in conjunction with the '--fleet' option",
"The '--device-type' option can only be used in conjunction with the '--application' option",
);
}
if (options['device-api-key']) {
console.error(stripIndent`
-------------------------------------------------------------------------------------------
Warning: ${deviceApiKeyDeprecationMsg.split('\n').join('\n\t\t\t')}
-------------------------------------------------------------------------------------------
`);
}
const { checkLoggedIn } = await import('../../utils/patterns');
await checkLoggedIn();
}
@ -330,7 +270,7 @@ async function validateOptions(options: FlagsDef) {
*/
async function getOsVersionFromImage(
imagePath: string,
deviceTypeManifest: BalenaSdk.DeviceTypeJson.DeviceType,
deviceTypeManifest: BalenaSdk.DeviceType,
devInit: typeof import('balena-device-init'),
): Promise<string> {
const osVersion = await devInit.getImageOsVersion(
@ -354,21 +294,21 @@ async function getOsVersionFromImage(
* @param app Balena SDK Application model object
*/
async function checkDeviceTypeCompatibility(
sdk: BalenaSdk.BalenaSDK,
options: FlagsDef,
app: {
is_for__device_type: [Pick<BalenaSdk.DeviceType, 'slug'>];
},
app: BalenaSdk.Application,
) {
if (options['device-type']) {
const [appDeviceType, optionDeviceType] = await Promise.all([
sdk.models.device.getManifestBySlug(app.device_type),
sdk.models.device.getManifestBySlug(options['device-type']),
]);
const helpers = await import('../../utils/helpers');
if (
!(await helpers.areDeviceTypesCompatible(
app.is_for__device_type[0].slug,
options['device-type'],
))
) {
if (!helpers.areDeviceTypesCompatible(appDeviceType, optionDeviceType)) {
throw new ExpectedError(
`Device type ${options['device-type']} is incompatible with fleet ${options.fleet}`,
`Device type ${
options['device-type']
} is incompatible with application ${options.application}`,
);
}
}
@ -389,17 +329,13 @@ async function checkDeviceTypeCompatibility(
* The questions are extracted from the given deviceType "manifest".
*/
async function askQuestionsForDeviceType(
deviceType: BalenaSdk.DeviceTypeJson.DeviceType,
deviceType: BalenaSdk.DeviceType,
options: FlagsDef,
configJson?: import('../../utils/config').ImgConfig,
): Promise<Answers> {
const answerSources: any[] = [
{
...camelifyConfigOptions(options),
app: options.fleet,
application: options.fleet,
},
];
const form = await import('resin-cli-form');
const helpers = await import('../../utils/helpers');
const answerSources: any[] = [camelifyConfigOptions(options)];
const defaultAnswers: Partial<Answers> = {};
const questions: any = deviceType.options;
let extraOpts: { override: object } | undefined;
@ -414,7 +350,6 @@ async function askQuestionsForDeviceType(
isGroup: true,
});
if (!_.isEmpty(advancedGroup)) {
const helpers = await import('../../utils/helpers');
answerSources.push(helpers.getGroupDefaults(advancedGroup));
}
}
@ -438,7 +373,7 @@ async function askQuestionsForDeviceType(
extraOpts = { override: defaultAnswers };
}
return getCliForm().run(questions, extraOpts);
return form.run(questions, extraOpts);
}
/**
@ -463,17 +398,14 @@ async function askQuestionsForDeviceType(
* [ 'network', 'wifiSsid', 'wifiKey', 'appUpdatePollInterval' ]
*/
function getQuestionNames(
deviceType: BalenaSdk.DeviceTypeJson.DeviceType,
deviceType: BalenaSdk.DeviceType,
): Array<keyof Answers> {
const questionNames: string[] = _.chain(deviceType.options)
.flatMap(
(group: BalenaSdk.DeviceTypeJson.DeviceTypeOptions) =>
(group: BalenaSdk.DeviceTypeOptions) =>
(group.isGroup && group.options) || [],
)
.map(
(groupOption: BalenaSdk.DeviceTypeJson.DeviceTypeOptionsGroup) =>
groupOption.name,
)
.map((groupOption: BalenaSdk.DeviceTypeOptionsGroup) => groupOption.name)
.filter()
.value();
return questionNames as Array<keyof Answers>;
@ -492,7 +424,7 @@ function camelifyConfigOptions(options: FlagsDef): { [key: string]: any } {
if (key.startsWith('config-')) {
return key
.substring('config-'.length)
.replace(/-[a-z]/g, (match) => match.substring(1).toUpperCase());
.replace(/-[a-z]/g, match => match.substring(1).toUpperCase());
}
return key;
});

View File

@ -15,8 +15,14 @@
* limitations under the License.
*/
import { Flags, Command } from '@oclif/core';
import { stripIndent } from '../../utils/lazy';
import { Command, flags } from '@oclif/command';
import { stripIndent } from 'common-tags';
interface FlagsDef {
all?: boolean;
json?: boolean;
help: void;
}
export interface JsonVersions {
'balena-cli': string;
@ -27,51 +33,42 @@ export default class VersionCmd extends Command {
public static description = stripIndent`
Display version information for the balena CLI and/or Node.js.
Display version information for the balena CLI and/or Node.js. Note that the
balena CLI executable installers for Windows and macOS, and the standalone
zip packages, ship with a built-in copy of Node.js. In this case, the
reported version of Node.js regards this built-in copy, rather than any
other \`node\` engine that may also be available on the command prompt.
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/).
This command can also be invoked with 'balena --version' or 'balena -v'.
Display version information for the balena CLI and/or Node.js.
If you intend to parse the output, please use the -j option for
JSON output, as its format is more stable.
`;
public static examples = [
'$ balena version',
'$ balena version -a',
'$ balena version -j',
`$ balena --version`,
`$ balena -v`,
];
public static offlineCompatible = true;
public static usage = 'version';
public static flags = {
all: Flags.boolean({
default: false,
public static flags: flags.Input<FlagsDef> = {
all: flags.boolean({
char: 'a',
default: false,
description:
'include version information for additional components (Node.js)',
}),
json: Flags.boolean({
default: false,
json: flags.boolean({
char: 'j',
default: false,
description:
'output version information in JSON format for programmatic use',
}),
help: flags.help({ char: 'h' }),
};
public async run() {
const { flags: options } = await this.parse(VersionCmd);
const { flags: options } = this.parse<FlagsDef, {}>(VersionCmd);
const versions: JsonVersions = {
'balena-cli': (await import('../../../package.json')).version,
'Node.js': process.version.startsWith('v')
? process.version.slice(1)
: process.version,
'balena-cli': (await import('../../package.json')).version,
'Node.js':
process.version && process.version.startsWith('v')
? process.version.slice(1)
: process.version,
};
if (options.json) {
console.log(JSON.stringify(versions, null, 4));

36
lib/actions/api-key.ts Normal file
View File

@ -0,0 +1,36 @@
import { CommandDefinition } from 'capitano';
import { stripIndent } from 'common-tags';
export const generate: CommandDefinition<{
name: string;
}> = {
signature: 'api-key generate <name>',
description: 'Generate a new API key with the given name',
help: stripIndent`
This command generates a new API key for the current user, with the given
name. The key will be logged to the console.
This key can be used to log into the CLI using 'balena login --token <key>',
or to authenticate requests to the API with an 'Authorization: Bearer <key>' header.
Examples:
$ balena api-key generate "Jenkins Key"
`,
async action(params, _options, done) {
const balena = (await import('balena-sdk')).fromSharedOptions();
balena.models.apiKey
.create(params.name)
.then(key => {
console.log(stripIndent`
Registered api key '${params.name}':
${key}
This key will not be shown again, so please save it now.
`);
})
.finally(done);
},
};

173
lib/actions/app.coffee Normal file
View File

@ -0,0 +1,173 @@
###
Copyright 2016-2017 Balena
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.
###
commandOptions = require('./command-options')
exports.create =
signature: 'app create <name>'
description: 'create an application'
help: '''
Use this command to 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
Examples:
$ balena app create MyApp
$ balena app create MyApp --type raspberry-pi
'''
options: [
{
signature: 'type'
parameter: 'type'
description: 'application device type (Check available types with `balena devices supported`)'
alias: 't'
}
]
permission: 'user'
action: (params, options, done) ->
balena = require('balena-sdk').fromSharedOptions()
patterns = require('../utils/patterns')
# Validate the the application name is available
# before asking the device type.
# https://github.com/balena-io/balena-cli/issues/30
balena.models.application.has(params.name).then (hasApplication) ->
if hasApplication
patterns.exitWithExpectedError('You already have an application with that name!')
.then ->
return options.type or patterns.selectDeviceType()
.then (deviceType) ->
return balena.models.application.create({
name: params.name
deviceType
})
.then (application) ->
console.info("Application created: #{application.app_name} (#{application.device_type}, id #{application.id})")
.nodeify(done)
exports.list =
signature: 'apps'
description: 'list all applications'
help: '''
Use this command to list all your applications.
Notice this command only shows the most important bits of information for each app.
If you want detailed information, use balena app <name> instead.
Examples:
$ balena apps
'''
permission: 'user'
primary: true
action: (params, options, done) ->
_ = require('lodash')
balena = require('balena-sdk').fromSharedOptions()
visuals = require('resin-cli-visuals')
balena.models.application.getAll
$select: [
'id'
'app_name'
'device_type'
]
$expand: owns__device: $select: 'is_online'
.then (applications) ->
applications.forEach (application) ->
application.device_count = _.size(application.owns__device)
application.online_devices = _.filter(application.owns__device, (d) -> d.is_online == true).length
console.log visuals.table.horizontal applications, [
'id'
'app_name'
'device_type'
'online_devices'
'device_count'
]
.nodeify(done)
exports.info =
signature: 'app <name>'
description: 'list a single application'
help: '''
Use this command to show detailed information for a single application.
Examples:
$ balena app MyApp
'''
permission: 'user'
primary: true
action: (params, options, done) ->
balena = require('balena-sdk').fromSharedOptions()
visuals = require('resin-cli-visuals')
balena.models.application.get(params.name).then (application) ->
console.log visuals.table.vertical application, [
"$#{application.app_name}$"
'id'
'device_type'
'git_repository'
'commit'
]
.nodeify(done)
exports.restart =
signature: 'app restart <name>'
description: 'restart an application'
help: '''
Use this command to restart all devices that belongs to a certain application.
Examples:
$ balena app restart MyApp
'''
permission: 'user'
action: (params, options, done) ->
balena = require('balena-sdk').fromSharedOptions()
balena.models.application.restart(params.name).nodeify(done)
exports.remove =
signature: 'app rm <name>'
description: 'remove an application'
help: '''
Use this command to remove a balena application.
Notice this command asks for confirmation interactively.
You can avoid this by passing the `--yes` boolean option.
Examples:
$ balena app rm MyApp
$ balena app rm MyApp --yes
'''
options: [ commandOptions.yes ]
permission: 'user'
action: (params, options, done) ->
balena = require('balena-sdk').fromSharedOptions()
patterns = require('../utils/patterns')
patterns.confirm(options.yes, 'Are you sure you want to delete the application?').then ->
balena.models.application.remove(params.name)
.nodeify(done)

170
lib/actions/auth.coffee Normal file
View File

@ -0,0 +1,170 @@
###
Copyright 2016-2017 Balena
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.
###
exports.login =
signature: 'login'
description: 'login to balena'
help: '''
Use this command to login to your balena account.
This command will prompt you to login using the following login types:
- Web authorization: open your web browser and prompt you to authorize the CLI
from the dashboard.
- Credentials: using email/password and 2FA.
- Token: using a session token or API key from the preferences page.
Examples:
$ balena login
$ balena login --web
$ balena login --token "..."
$ balena login --credentials
$ balena login --credentials --email johndoe@gmail.com --password secret
'''
options: [
{
signature: 'token'
description: 'session token or API key'
parameter: 'token'
alias: 't'
}
{
signature: 'web'
description: 'web-based login'
boolean: true
alias: 'w'
}
{
signature: 'credentials'
description: 'credential-based login'
boolean: true
alias: 'c'
}
{
signature: 'email'
parameter: 'email'
description: 'email'
alias: [ 'e', 'u' ]
}
{
signature: 'password'
parameter: 'password'
description: 'password'
alias: 'p'
}
]
primary: true
action: (params, options, done) ->
_ = require('lodash')
Promise = require('bluebird')
balena = require('balena-sdk').fromSharedOptions()
auth = require('../auth')
form = require('resin-cli-form')
patterns = require('../utils/patterns')
messages = require('../utils/messages')
login = (options) ->
if options.token?
return Promise.try ->
return options.token if _.isString(options.token)
return form.ask
message: 'Session token or API key from the preferences page'
name: 'token'
type: 'input'
.then(balena.auth.loginWithToken)
.tap ->
balena.auth.whoami()
.then (username) ->
if !username
patterns.exitWithExpectedError('Token authentication failed')
else if options.credentials
return patterns.authenticate(options)
else if options.web
console.info('Connecting to the web dashboard')
return auth.login()
return patterns.askLoginType().then (loginType) ->
if loginType is 'register'
signupUrl = 'https://dashboard.balena-cloud.com/signup'
require('opn')(signupUrl, { wait: false })
patterns.exitWithExpectedError("Please sign up at #{signupUrl}")
options[loginType] = true
return login(options)
balena.settings.get('balenaUrl').then (balenaUrl) ->
console.log(messages.balenaAsciiArt)
console.log("\nLogging in to #{balenaUrl}")
return login(options)
.then(balena.auth.whoami)
.tap (username) ->
console.info("Successfully logged in as: #{username}")
console.info """
Find out about the available commands by running:
$ balena help
#{messages.reachingOut}
"""
.nodeify(done)
exports.logout =
signature: 'logout'
description: 'logout from balena'
help: '''
Use this command to logout from your balena account.
Examples:
$ balena logout
'''
action: (params, options, done) ->
balena = require('balena-sdk').fromSharedOptions()
balena.auth.logout().nodeify(done)
exports.whoami =
signature: 'whoami'
description: 'get current username and email address'
help: '''
Use this command to find out the current logged in username and email address.
Examples:
$ balena whoami
'''
permission: 'user'
action: (params, options, done) ->
Promise = require('bluebird')
balena = require('balena-sdk').fromSharedOptions()
visuals = require('resin-cli-visuals')
Promise.props
username: balena.auth.whoami()
email: balena.auth.getEmail()
url: balena.settings.get('balenaUrl')
.then (results) ->
console.log visuals.table.vertical results, [
'$account information$'
'username'
'email'
'url'
]
.nodeify(done)

155
lib/actions/build.coffee Normal file
View File

@ -0,0 +1,155 @@
# Imported here because it's needed for the setup
# of this action
Promise = require('bluebird')
dockerUtils = require('../utils/docker')
compose = require('../utils/compose')
{ registrySecretsHelp } = require('../utils/messages')
###
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
###
buildProject = (docker, logger, composeOpts, opts) ->
compose.loadProject(
logger
composeOpts.projectPath
composeOpts.projectName
undefined # image: name of pre-built image
composeOpts.dockerfilePath # ok if undefined
)
.then (project) ->
appType = opts.app?.application_type?[0]
if appType? and project.descriptors.length > 1 and not appType.supports_multicontainer
logger.logWarn(
'Target application does not support multiple containers.\n' +
'Continuing with build, but you will not be able to deploy.'
)
compose.buildProject(
docker
logger
project.path
project.name
project.composition
opts.arch
opts.deviceType
opts.buildEmulated
opts.buildOpts
composeOpts.inlineLogs
)
.then ->
logger.logSuccess('Build succeeded!')
.tapCatch (e) ->
logger.logError('Build failed')
module.exports =
signature: 'build [source]'
description: 'Build a single image or a multicontainer project locally'
primary: true
help: """
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 to use
the balena Dockerfile pre-processor (e.g. Dockerfile.template -> Dockerfile).
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. If it is found,
this command will build each service defined in the compose file. If a compose
file isn't 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.
#{registrySecretsHelp}
Examples:
$ balena build
$ balena build ./source/
$ balena build --deviceType raspberrypi3 --arch armv7hf --emulated
$ balena build --application MyApp ./source/
$ balena build --docker /var/run/docker.sock # Linux, Mac
$ balena build --docker //./pipe/docker_engine # Windows
$ balena build --dockerHost my.docker.host --dockerPort 2376 --ca ca.pem --key key.pem --cert cert.pem
"""
options: dockerUtils.appendOptions compose.appendOptions [
{
signature: 'arch'
parameter: 'arch'
description: 'The architecture to build for'
alias: 'A'
},
{
signature: 'deviceType'
parameter: 'deviceType'
description: 'The type of device this build is for'
alias: 'd'
},
{
signature: 'application'
parameter: 'application'
description: 'The target balena application this build is for'
alias: 'a'
},
]
action: (params, options, done) ->
# compositions with many services trigger misleading warnings
require('events').defaultMaxListeners = 1000
sdk = (require('balena-sdk')).fromSharedOptions()
{ validateComposeOptions } = require('../utils/compose_ts')
{ exitWithExpectedError } = require('../utils/patterns')
helpers = require('../utils/helpers')
Logger = require('../utils/logger')
logger = Logger.getLogger()
logger.logDebug('Parsing input...')
# `build` accepts `[source]` as a parameter, but compose expects it
# as an option. swap them here
options.source ?= params.source
delete params.source
Promise.resolve(validateComposeOptions(sdk, options))
.then ->
{ application, arch, deviceType } = options
if (not (arch? and deviceType?) and not application?) or (application? and (arch? or deviceType?))
exitWithExpectedError('You must specify either an application or an arch/deviceType pair to build for')
if arch? and deviceType?
[ undefined, arch, deviceType ]
else
Promise.join(
helpers.getApplication(application)
helpers.getArchAndDeviceType(application)
(app, { arch, device_type }) ->
app.arch = arch
app.device_type = device_type
return app
)
.then (app) ->
[ app, app.arch, app.device_type ]
.then ([ app, arch, deviceType ]) ->
Promise.join(
dockerUtils.getDocker(options)
dockerUtils.generateBuildOpts(options)
compose.generateOpts(options)
(docker, buildOpts, composeOpts) ->
buildProject(docker, logger, composeOpts, {
app
arch
deviceType
buildEmulated: !!options.emulated
buildOpts
})
)
.asCallback(done)

View File

@ -0,0 +1,157 @@
/*
Copyright 2016-2017 Balena
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 _ = require('lodash');
export const yes = {
signature: 'yes',
description: 'confirm non interactively',
boolean: true,
alias: 'y',
};
export interface YesOption {
yes: boolean;
}
export const optionalApplication = {
signature: 'application',
parameter: 'application',
description: 'application name',
alias: ['a', 'app'],
};
export const application = _.defaults(
{ required: 'You have to specify an application' },
optionalApplication,
);
export const optionalRelease = {
signature: 'release',
parameter: 'release',
description: 'release id',
alias: 'r',
};
export const optionalDevice = {
signature: 'device',
parameter: 'device',
description: 'device uuid',
alias: 'd',
};
export const optionalDeviceApiKey = {
signature: 'deviceApiKey',
description:
'custom device key - note that this is only supported on balenaOS 2.0.3+',
parameter: 'device-api-key',
alias: 'k',
};
export const optionalDeviceType = {
signature: 'deviceType',
description: 'device type slug',
parameter: 'device-type',
};
export const optionalOsVersion = {
signature: 'version',
description: 'a balenaOS version',
parameter: 'version',
};
export type OptionalOsVersionOption = Partial<OsVersionOption>;
export const osVersion = _.defaults(
{
required: 'You have to specify an exact os version',
},
exports.optionalOsVersion,
);
export interface OsVersionOption {
version?: string;
}
export const booleanDevice = {
signature: 'device',
description: 'device',
boolean: true,
alias: 'd',
};
export const osVersionOrSemver = {
signature: 'version',
description: `\
exact version number, or a valid semver range,
or 'latest' (includes pre-releases),
or 'default' (excludes pre-releases if at least one stable version is available),
or 'recommended' (excludes pre-releases, will fail if only pre-release versions are available),
or 'menu' (will show the interactive menu)\
`,
parameter: 'version',
};
export const network = {
signature: 'network',
parameter: 'network',
description: 'network type',
alias: 'n',
};
export const wifiSsid = {
signature: 'ssid',
parameter: 'ssid',
description: 'wifi ssid, if network is wifi',
alias: 's',
};
export const wifiKey = {
signature: 'key',
parameter: 'key',
description: 'wifi key, if network is wifi',
alias: 'k',
};
export const forceUpdateLock = {
signature: 'force',
description: 'force action if the update lock is set',
boolean: true,
alias: 'f',
};
export const drive = {
signature: 'drive',
description: `the drive to write the image to, like \`/dev/sdb\` or \`/dev/mmcblk0\`. \
Careful with this as you can erase your hard drive. \
Check \`balena util available-drives\` for available options.`,
parameter: 'drive',
alias: 'd',
};
export const advancedConfig = {
signature: 'advanced',
description: 'show advanced configuration options',
boolean: true,
alias: 'v',
};
export const hostOSAccess = {
signature: 'host',
boolean: true,
description: 'access host OS (for devices with balenaOS >= 2.0.0+rev1)',
alias: 's',
};

357
lib/actions/config.coffee Normal file
View File

@ -0,0 +1,357 @@
###
Copyright 2016-2018 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.
###
commandOptions = require('./command-options')
{ normalizeUuidProp } = require('../utils/normalization')
exports.read =
signature: 'config read'
description: 'read a device configuration'
help: '''
Use this command to read the config.json file from the mounted filesystem (e.g. SD card) of a provisioned device"
Examples:
$ balena config read --type raspberry-pi
$ balena config read --type raspberry-pi --drive /dev/disk2
'''
options: [
{
signature: 'type'
description: 'device type (Check available types with `balena devices supported`)'
parameter: 'type'
alias: 't'
required: 'You have to specify a device type'
}
{
signature: 'drive'
description: 'drive'
parameter: 'drive'
alias: 'd'
}
]
permission: 'user'
root: true
action: (params, options, done) ->
Promise = require('bluebird')
config = require('balena-config-json')
visuals = require('resin-cli-visuals')
umountAsync = Promise.promisify(require('umount').umount)
prettyjson = require('prettyjson')
Promise.try ->
return options.drive or visuals.drive('Select the device drive')
.tap(umountAsync)
.then (drive) ->
return config.read(drive, options.type)
.tap (configJSON) ->
console.info(prettyjson.render(configJSON))
.nodeify(done)
exports.write =
signature: 'config write <key> <value>'
description: 'write a device configuration'
help: '''
Use this command to write the config.json file to the mounted filesystem (e.g. SD card) of a provisioned device
Examples:
$ balena config write --type raspberry-pi username johndoe
$ balena config write --type raspberry-pi --drive /dev/disk2 username johndoe
$ balena config write --type raspberry-pi files.network/settings "..."
'''
options: [
{
signature: 'type'
description: 'device type (Check available types with `balena devices supported`)'
parameter: 'type'
alias: 't'
required: 'You have to specify a device type'
}
{
signature: 'drive'
description: 'drive'
parameter: 'drive'
alias: 'd'
}
]
permission: 'user'
root: true
action: (params, options, done) ->
Promise = require('bluebird')
_ = require('lodash')
config = require('balena-config-json')
visuals = require('resin-cli-visuals')
umountAsync = Promise.promisify(require('umount').umount)
Promise.try ->
return options.drive or visuals.drive('Select the device drive')
.tap(umountAsync)
.then (drive) ->
config.read(drive, options.type).then (configJSON) ->
console.info("Setting #{params.key} to #{params.value}")
_.set(configJSON, params.key, params.value)
return configJSON
.tap ->
return umountAsync(drive)
.then (configJSON) ->
return config.write(drive, options.type, configJSON)
.tap ->
console.info('Done')
.nodeify(done)
exports.inject =
signature: 'config inject <file>'
description: 'inject a device configuration file'
help: '''
Use this command to inject a config.json file to the mounted filesystem
(e.g. SD card or mounted balenaOS image) of a provisioned device"
Examples:
$ balena config inject my/config.json --type raspberry-pi
$ balena config inject my/config.json --type raspberry-pi --drive /dev/disk2
'''
options: [
{
signature: 'type'
description: 'device type (Check available types with `balena devices supported`)'
parameter: 'type'
alias: 't'
required: 'You have to specify a device type'
}
{
signature: 'drive'
description: 'drive'
parameter: 'drive'
alias: 'd'
}
]
permission: 'user'
root: true
action: (params, options, done) ->
Promise = require('bluebird')
config = require('balena-config-json')
visuals = require('resin-cli-visuals')
umountAsync = Promise.promisify(require('umount').umount)
readFileAsync = Promise.promisify(require('fs').readFile)
Promise.try ->
return options.drive or visuals.drive('Select the device drive')
.tap(umountAsync)
.then (drive) ->
readFileAsync(params.file, 'utf8').then(JSON.parse).then (configJSON) ->
return config.write(drive, options.type, configJSON)
.tap ->
console.info('Done')
.nodeify(done)
exports.reconfigure =
signature: 'config reconfigure'
description: 'reconfigure a provisioned device'
help: '''
Use this command to reconfigure a provisioned device
Examples:
$ balena config reconfigure --type raspberry-pi
$ balena config reconfigure --type raspberry-pi --advanced
$ balena config reconfigure --type raspberry-pi --drive /dev/disk2
'''
options: [
{
signature: 'type'
description: 'device type (Check available types with `balena devices supported`)'
parameter: 'type'
alias: 't'
required: 'You have to specify a device type'
}
{
signature: 'drive'
description: 'drive'
parameter: 'drive'
alias: 'd'
}
{
signature: 'advanced'
description: 'show advanced commands'
boolean: true
alias: 'v'
}
]
permission: 'user'
root: true
action: (params, options, done) ->
Promise = require('bluebird')
config = require('balena-config-json')
visuals = require('resin-cli-visuals')
{ runCommand } = require('../utils/helpers')
umountAsync = Promise.promisify(require('umount').umount)
Promise.try ->
return options.drive or visuals.drive('Select the device drive')
.tap(umountAsync)
.then (drive) ->
config.read(drive, options.type).get('uuid')
.tap ->
umountAsync(drive)
.then (uuid) ->
configureCommand = "os configure #{drive} --device #{uuid}"
if options.advanced
configureCommand += ' --advanced'
return runCommand(configureCommand)
.then ->
console.info('Done')
.nodeify(done)
exports.generate =
signature: 'config generate'
description: 'generate a config.json file'
help: '''
Use this command to generate a config.json for a device or application.
Calling this command with the exact version number of the targeted image is required.
This 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.
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
'''
options: [
commandOptions.osVersion
commandOptions.optionalApplication
commandOptions.optionalDevice
commandOptions.optionalDeviceApiKey
commandOptions.optionalDeviceType
{
signature: 'generate-device-api-key'
description: 'generate a fresh device key for the device'
boolean: true
}
{
signature: 'output'
description: 'output'
parameter: 'output'
alias: 'o'
}
# Options for non-interactive configuration
{
signature: 'network'
description: 'the network type to use: ethernet or wifi'
parameter: 'network'
}
{
signature: 'wifiSsid'
description: 'the wifi ssid to use (used only if --network is set to wifi)'
parameter: 'wifiSsid'
}
{
signature: 'wifiKey'
description: 'the wifi key to use (used only if --network is set to wifi)'
parameter: 'wifiKey'
}
{
signature: 'appUpdatePollInterval'
description: 'how frequently (in minutes) to poll for application updates'
parameter: 'appUpdatePollInterval'
}
]
permission: 'user'
action: (params, options, done) ->
normalizeUuidProp(options, 'device')
Promise = require('bluebird')
writeFileAsync = Promise.promisify(require('fs').writeFile)
balena = require('balena-sdk').fromSharedOptions()
form = require('resin-cli-form')
prettyjson = require('prettyjson')
{ generateDeviceConfig, generateApplicationConfig } = require('../utils/config')
helpers = require('../utils/helpers')
{ exitWithExpectedError } = require('../utils/patterns')
if not options.device? and not options.application?
exitWithExpectedError '''
You have to pass either a device or an application.
See the help page for examples:
$ balena help config generate
'''
if !options.application and options.deviceType
exitWithExpectedError '''
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
'''
Promise.try ->
if options.device?
return balena.models.device.get(options.device)
return balena.models.application.get(options.application)
.then (resource) ->
deviceType = options.deviceType || resource.device_type
manifestPromise = balena.models.device.getManifestBySlug(deviceType)
if options.application && options.deviceType
app = resource
appManifestPromise = balena.models.device.getManifestBySlug(app.device_type)
manifestPromise = manifestPromise.tap (paramDeviceType) ->
appManifestPromise.then (appDeviceType) ->
if not helpers.areDeviceTypesCompatible(appDeviceType, paramDeviceType)
throw new balena.errors.BalenaInvalidDeviceType(
"Device type #{options.deviceType} is incompatible with application #{options.application}"
)
manifestPromise.get('options')
.then (formOptions) ->
# 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)
form.run(formOptions, override: options)
.then (answers) ->
answers.version = options.version
if resource.uuid?
generateDeviceConfig(resource, options.deviceApiKey || options['generate-device-api-key'], answers)
else
answers.deviceType = deviceType
generateApplicationConfig(resource, answers)
.then (config) ->
if options.output?
return writeFileAsync(options.output, JSON.stringify(config))
console.log(prettyjson.render(config))
.nodeify(done)

234
lib/actions/deploy.coffee Normal file
View File

@ -0,0 +1,234 @@
# Imported here because it's needed for the setup
# of this action
Promise = require('bluebird')
dockerUtils = require('../utils/docker')
compose = require('../utils/compose')
{ registrySecretsHelp } = require('../utils/messages')
###
Opts must be an object with the following keys:
app: the application instance to deploy to
image: the image to deploy; optional
dockerfilePath: name of an alternative Dockerfile; optional
shouldPerformBuild
shouldUploadLogs
buildEmulated
buildOpts: arguments to forward to docker build command
###
deployProject = (docker, logger, composeOpts, opts) ->
_ = require('lodash')
doodles = require('resin-doodles')
sdk = require('balena-sdk').fromSharedOptions()
compose.loadProject(
logger
composeOpts.projectPath
composeOpts.projectName
opts.image
composeOpts.dockerfilePath # ok if undefined
)
.then (project) ->
if project.descriptors.length > 1 and !opts.app.application_type?[0]?.supports_multicontainer
throw new Error('Target application does not support multiple containers. Aborting!')
# find which services use images that already exist locally
Promise.map project.descriptors, (d) ->
# unconditionally build (or pull) if explicitly requested
return d if opts.shouldPerformBuild
docker.getImage(d.image.tag ? d.image).inspect()
.return(d.serviceName)
.catchReturn()
.filter (d) -> !!d
.then (servicesToSkip) ->
# multibuild takes in a composition and always attempts to
# build or pull all services. we workaround that here by
# passing a modified composition.
compositionToBuild = _.cloneDeep(project.composition)
compositionToBuild.services = _.omit(compositionToBuild.services, servicesToSkip)
if _.size(compositionToBuild.services) is 0
logger.logInfo('Everything is up to date (use --build to force a rebuild)')
return {}
compose.buildProject(
docker
logger
project.path
project.name
compositionToBuild
opts.app.arch
opts.app.device_type
opts.buildEmulated
opts.buildOpts
composeOpts.inlineLogs
)
.then (builtImages) ->
_.keyBy(builtImages, 'serviceName')
.then (builtImages) ->
project.descriptors.map (d) ->
builtImages[d.serviceName] ? {
serviceName: d.serviceName,
name: d.image.tag ? d.image
logs: 'Build skipped; image for service already exists.'
props: {}
}
.then (images) ->
if opts.app.application_type?[0]?.is_legacy
chalk = require('chalk')
legacyDeploy = require('../utils/deploy-legacy')
msg = chalk.yellow('Target application requires legacy deploy method.')
logger.logWarn(msg)
return Promise.join(
docker
logger
sdk.auth.getToken()
sdk.auth.whoami()
sdk.settings.get('balenaUrl')
{
# opts.appName may be prefixed by 'owner/', unlike opts.app.app_name
appName: opts.appName
imageName: images[0].name
buildLogs: images[0].logs
shouldUploadLogs: opts.shouldUploadLogs
}
legacyDeploy
)
.then (releaseId) ->
sdk.models.release.get(releaseId, $select: [ 'commit' ])
Promise.join(
sdk.auth.getUserId()
sdk.auth.getToken()
sdk.settings.get('apiUrl')
(userId, auth, apiEndpoint) ->
compose.deployProject(
docker
logger
project.composition
images
opts.app.id
userId
"Bearer #{auth}"
apiEndpoint
!opts.shouldUploadLogs
)
)
.then (release) ->
logger.logSuccess('Deploy succeeded!')
logger.logSuccess("Release: #{release.commit}")
console.log()
console.log(doodles.getDoodle()) # Show charlie
console.log()
.tapCatch (e) ->
logger.logError('Deploy failed')
module.exports =
signature: 'deploy <appName> [image]'
description: 'Deploy a single image or a multicontainer project to a balena application'
help: """
Usage: `deploy <appName> ([image] | --build [--source build-dir])`
Use this command to deploy an image or a complete multicontainer project to an
application, optionally building it first. The source images are searched for
(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
the image in the balenaCloud build servers.)
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
found, this command will deploy each service defined in the compose file,
building it first if an image for it doesn't exist. If a compose file isn't
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.
To deploy to an app on which you're a collaborator, use
`balena deploy <appOwnerUsername>/<appName>`.
When --build is used, all options supported by `balena build` are also supported
by this command.
#{registrySecretsHelp}
Examples:
$ balena deploy myApp
$ balena deploy myApp --build --source myBuildDir/
$ balena deploy myApp myApp/myImage
"""
permission: 'user'
primary: true
options: dockerUtils.appendOptions compose.appendOptions [
{
signature: 'source'
parameter: 'source'
description: 'Specify an alternate source directory; default is the working directory'
alias: 's'
},
{
signature: 'build'
boolean: true
description: 'Force a rebuild before deploy'
alias: 'b'
},
{
signature: 'nologupload'
description: "Don't upload build logs to the dashboard with image (if building)"
boolean: true
}
]
action: (params, options, done) ->
# compositions with many services trigger misleading warnings
require('events').defaultMaxListeners = 1000
sdk = (require('balena-sdk')).fromSharedOptions()
{ validateComposeOptions } = require('../utils/compose_ts')
helpers = require('../utils/helpers')
Logger = require('../utils/logger')
logger = Logger.getLogger()
logger.logDebug('Parsing input...')
# when Capitano converts a positional parameter (but not an option)
# to a number, the original value is preserved with the _raw suffix
{ appName, appName_raw, image } = params
# look into "balena build" options if appName isn't given
appName = appName_raw || appName || options.application
delete options.application
Promise.resolve(validateComposeOptions(sdk, options))
.then ->
if not appName?
throw new Error('Please specify the name of the application to deploy')
if image? and options.build
throw new Error('Build option is not applicable when specifying an image')
Promise.join(
helpers.getApplication(appName)
helpers.getArchAndDeviceType(appName)
(app, { arch, device_type }) ->
app.arch = arch
app.device_type = device_type
return app
)
.then (app) ->
[ app, image, !!options.build, !options.nologupload ]
.then ([ app, image, shouldPerformBuild, shouldUploadLogs ]) ->
Promise.join(
dockerUtils.getDocker(options)
dockerUtils.generateBuildOpts(options)
compose.generateOpts(options)
(docker, buildOpts, composeOpts) ->
deployProject(docker, logger, composeOpts, {
app
appName # may be prefixed by 'owner/', unlike app.app_name
image
shouldPerformBuild
shouldUploadLogs
buildEmulated: !!options.emulated
buildOpts
})
)
.asCallback(done)

451
lib/actions/device.coffee Normal file
View File

@ -0,0 +1,451 @@
###
Copyright 2016-2017 Balena
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.
###
commandOptions = require('./command-options')
_ = require('lodash')
{ normalizeUuidProp } = require('../utils/normalization')
expandForAppName = {
$expand: belongs_to__application: $select: 'app_name'
}
exports.list =
signature: 'devices'
description: 'list all devices'
help: '''
Use this command to list all devices that belong to you.
You can filter the devices by application by using the `--application` option.
Examples:
$ balena devices
$ balena devices --application MyApp
$ balena devices --app MyApp
$ balena devices -a MyApp
'''
options: [ commandOptions.optionalApplication ]
permission: 'user'
primary: true
action: (params, options, done) ->
Promise = require('bluebird')
balena = require('balena-sdk').fromSharedOptions()
visuals = require('resin-cli-visuals')
Promise.try ->
if options.application?
return balena.models.device.getAllByApplication(options.application, expandForAppName)
return balena.models.device.getAll(expandForAppName)
.tap (devices) ->
devices = _.map devices, (device) ->
device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid)
device.application_name = device.belongs_to__application[0].app_name
device.uuid = device.uuid.slice(0, 7)
return device
console.log visuals.table.horizontal devices, [
'id'
'uuid'
'device_name'
'device_type'
'application_name'
'status'
'is_online'
'supervisor_version'
'os_version'
'dashboard_url'
]
.nodeify(done)
exports.info =
signature: 'device <uuid>'
description: 'list a single device'
help: '''
Use this command to show information about a single device.
Examples:
$ balena device 7cf02a6
'''
permission: 'user'
primary: true
action: (params, options, done) ->
normalizeUuidProp(params)
balena = require('balena-sdk').fromSharedOptions()
visuals = require('resin-cli-visuals')
balena.models.device.get(params.uuid, expandForAppName)
.then (device) ->
balena.models.device.getStatus(device).then (status) ->
device.status = status
device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid)
device.application_name = device.belongs_to__application[0].app_name
device.commit = device.is_on__commit
console.log visuals.table.vertical device, [
"$#{device.device_name}$"
'id'
'device_type'
'status'
'is_online'
'ip_address'
'application_name'
'last_seen'
'uuid'
'commit'
'supervisor_version'
'is_web_accessible'
'note'
'os_version'
'dashboard_url'
]
.nodeify(done)
exports.register =
signature: 'device register <application>'
description: 'register a device'
help: '''
Use this command to register a device to an application.
Examples:
$ balena device register MyApp
$ balena device register MyApp --uuid <uuid>
'''
permission: 'user'
options: [
{
signature: 'uuid'
description: 'custom uuid'
parameter: 'uuid'
alias: 'u'
}
]
action: (params, options, done) ->
Promise = require('bluebird')
balena = require('balena-sdk').fromSharedOptions()
Promise.join(
balena.models.application.get(params.application)
options.uuid ? balena.models.device.generateUniqueKey()
(application, uuid) ->
console.info("Registering to #{application.app_name}: #{uuid}")
return balena.models.device.register(application.id, uuid)
)
.get('uuid')
.nodeify(done)
exports.remove =
signature: 'device rm <uuid>'
description: 'remove a device'
help: '''
Use this command to remove a device from balena.
Notice this command asks for confirmation interactively.
You can avoid this by passing the `--yes` boolean option.
Examples:
$ balena device rm 7cf02a6
$ balena device rm 7cf02a6 --yes
'''
options: [ commandOptions.yes ]
permission: 'user'
action: (params, options, done) ->
normalizeUuidProp(params)
balena = require('balena-sdk').fromSharedOptions()
patterns = require('../utils/patterns')
patterns.confirm(options.yes, 'Are you sure you want to delete the device?').then ->
balena.models.device.remove(params.uuid)
.nodeify(done)
exports.identify =
signature: 'device identify <uuid>'
description: 'identify a device with a UUID'
help: '''
Use this command to identify a device.
In the Raspberry Pi, the ACT led is blinked several times.
Examples:
$ balena device identify 23c73a1
'''
permission: 'user'
action: (params, options, done) ->
normalizeUuidProp(params)
balena = require('balena-sdk').fromSharedOptions()
balena.models.device.identify(params.uuid).nodeify(done)
exports.reboot =
signature: 'device reboot <uuid>'
description: 'restart a device'
help: '''
Use this command to remotely reboot a device
Examples:
$ balena device reboot 23c73a1
'''
options: [ commandOptions.forceUpdateLock ]
permission: 'user'
action: (params, options, done) ->
normalizeUuidProp(params)
balena = require('balena-sdk').fromSharedOptions()
balena.models.device.reboot(params.uuid, options).nodeify(done)
exports.shutdown =
signature: 'device shutdown <uuid>'
description: 'shutdown a device'
help: '''
Use this command to remotely shutdown a device
Examples:
$ balena device shutdown 23c73a1
'''
options: [ commandOptions.forceUpdateLock ]
permission: 'user'
action: (params, options, done) ->
normalizeUuidProp(params)
balena = require('balena-sdk').fromSharedOptions()
balena.models.device.shutdown(params.uuid, options).nodeify(done)
exports.enableDeviceUrl =
signature: 'device public-url enable <uuid>'
description: 'enable public URL for a device'
help: '''
Use this command to enable public URL for a device
Examples:
$ balena device public-url enable 23c73a1
'''
permission: 'user'
action: (params, options, done) ->
normalizeUuidProp(params)
balena = require('balena-sdk').fromSharedOptions()
balena.models.device.enableDeviceUrl(params.uuid).nodeify(done)
exports.disableDeviceUrl =
signature: 'device public-url disable <uuid>'
description: 'disable public URL for a device'
help: '''
Use this command to disable public URL for a device
Examples:
$ balena device public-url disable 23c73a1
'''
permission: 'user'
action: (params, options, done) ->
normalizeUuidProp(params)
balena = require('balena-sdk').fromSharedOptions()
balena.models.device.disableDeviceUrl(params.uuid).nodeify(done)
exports.getDeviceUrl =
signature: 'device public-url <uuid>'
description: 'gets the public URL of a device'
help: '''
Use this command to get the public URL of a device
Examples:
$ balena device public-url 23c73a1
'''
permission: 'user'
action: (params, options, done) ->
normalizeUuidProp(params)
balena = require('balena-sdk').fromSharedOptions()
balena.models.device.getDeviceUrl(params.uuid).then (url) ->
console.log(url)
.nodeify(done)
exports.hasDeviceUrl =
signature: 'device public-url status <uuid>'
description: 'Returns true if public URL is enabled for a device'
help: '''
Use this command to determine if public URL is enabled for a device
Examples:
$ balena device public-url status 23c73a1
'''
permission: 'user'
action: (params, options, done) ->
normalizeUuidProp(params)
balena = require('balena-sdk').fromSharedOptions()
balena.models.device.hasDeviceUrl(params.uuid).then (hasDeviceUrl) ->
console.log(hasDeviceUrl)
.nodeify(done)
exports.rename =
signature: 'device rename <uuid> [newName]'
description: 'rename a balena device'
help: '''
Use this command to rename a device.
If you omit the name, you'll get asked for it interactively.
Examples:
$ balena device rename 7cf02a6
$ balena device rename 7cf02a6 MyPi
'''
permission: 'user'
action: (params, options, done) ->
normalizeUuidProp(params)
Promise = require('bluebird')
balena = require('balena-sdk').fromSharedOptions()
form = require('resin-cli-form')
Promise.try ->
return params.newName if not _.isEmpty(params.newName)
form.ask
message: 'How do you want to name this device?'
type: 'input'
.then(_.partial(balena.models.device.rename, params.uuid))
.nodeify(done)
exports.move =
signature: 'device move <uuid>'
description: 'move a device to another application'
help: '''
Use this command to move a device to another application you own.
If you omit the application, you'll get asked for it interactively.
Examples:
$ balena device move 7cf02a6
$ balena device move 7cf02a6 --application MyNewApp
'''
permission: 'user'
options: [ commandOptions.optionalApplication ]
action: (params, options, done) ->
normalizeUuidProp(params)
balena = require('balena-sdk').fromSharedOptions()
patterns = require('../utils/patterns')
balena.models.device.get(params.uuid, expandForAppName).then (device) ->
return options.application if options.application
return Promise.all([
balena.models.device.getManifestBySlug(device.device_type)
balena.models.config.getDeviceTypes()
]).then ([deviceDeviceType, deviceTypes]) ->
compatibleDeviceTypes = deviceTypes.filter (dt) ->
balena.models.os.isArchitectureCompatibleWith(deviceDeviceType.arch, dt.arch) &&
!!dt.isDependent == !!deviceDeviceType.isDependent &&
dt.state != 'DISCONTINUED'
return patterns.selectApplication (application) ->
return _.every [
_.some(compatibleDeviceTypes, (dt) -> dt.slug == application.device_type)
device.belongs_to__application[0].app_name isnt application.app_name
]
.tap (application) ->
return balena.models.device.move(params.uuid, application)
.then (application) ->
console.info("#{params.uuid} was moved to #{application}")
.nodeify(done)
exports.init =
signature: 'device init'
description: 'initialise a device with balenaOS'
help: '''
Use this command to download the OS image of a certain application and write it to an SD Card.
Notice this command may ask for confirmation interactively.
You can avoid this by passing the `--yes` boolean option.
Examples:
$ balena device init
$ balena device init --application MyApp
'''
options: [
commandOptions.optionalApplication
commandOptions.yes
commandOptions.advancedConfig
_.assign({}, commandOptions.osVersionOrSemver, { signature: 'os-version', parameter: 'os-version' })
commandOptions.drive
{
signature: 'config'
description: 'path to the config JSON file, see `balena os build-config`'
parameter: 'config'
}
]
permission: 'user'
action: (params, options, done) ->
Promise = require('bluebird')
rimraf = Promise.promisify(require('rimraf'))
tmp = require('tmp')
tmpNameAsync = Promise.promisify(tmp.tmpName)
tmp.setGracefulCleanup()
balena = require('balena-sdk').fromSharedOptions()
patterns = require('../utils/patterns')
{ runCommand } = require('../utils/helpers')
Promise.try ->
return options.application if options.application?
return patterns.selectApplication()
.then(balena.models.application.get)
.then (application) ->
download = ->
tmpNameAsync().then (tempPath) ->
osVersion = options['os-version'] or 'default'
runCommand("os download #{application.device_type} --output '#{tempPath}' --version #{osVersion}")
.disposer (tempPath) ->
return rimraf(tempPath)
Promise.using download(), (tempPath) ->
runCommand("device register #{application.app_name}")
.then(balena.models.device.get)
.tap (device) ->
configureCommand = "os configure '#{tempPath}' --device #{device.uuid}"
if options.config
configureCommand += " --config '#{options.config}'"
else if options.advanced
configureCommand += ' --advanced'
runCommand(configureCommand)
.then ->
osInitCommand = "os initialize '#{tempPath}' --type #{application.device_type}"
if options.yes
osInitCommand += ' --yes'
if options.drive
osInitCommand += " --drive #{options.drive}"
runCommand(osInitCommand)
# Make sure the device resource is removed if there is an
# error when configuring or initializing a device image
.catch (error) ->
balena.models.device.remove(device.uuid).finally ->
throw error
.then (device) ->
console.log('Done')
return device.uuid
.nodeify(done)
tsActions = require('./device_ts')
exports.osUpdate = tsActions.osUpdate
exports.supported = tsActions.supported

140
lib/actions/device_ts.ts Normal file
View File

@ -0,0 +1,140 @@
/*
Copyright 2016-2017 Balena
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 { Device } from 'balena-sdk';
import { CommandDefinition } from 'capitano';
import { stripIndent } from 'common-tags';
import { normalizeUuidProp } from '../utils/normalization';
import * as commandOptions from './command-options';
export const supported: CommandDefinition<{}, {}> = {
signature: 'devices supported',
description: 'list all supported devices',
help: stripIndent`
Use this command to get the list of all supported devices.
Examples:
$ balena devices supported
`,
async action(_params, _options) {
const sdk = (await import('balena-sdk')).fromSharedOptions();
const visuals = await import('resin-cli-visuals');
const _ = await import('lodash');
let deviceTypes = await sdk.models.config.getDeviceTypes();
const fields = ['slug', 'name'];
deviceTypes = _.sortBy(deviceTypes, fields).filter(
dt => dt.state !== 'DISCONTINUED',
);
const output = await visuals.table.horizontal(deviceTypes, fields);
console.log(output);
},
};
// tslint:disable-next-line:no-namespace
namespace OsUpdate {
export interface Args {
uuid: string;
}
export type Options = commandOptions.OptionalOsVersionOption &
commandOptions.YesOption;
}
export const osUpdate: CommandDefinition<OsUpdate.Args, OsUpdate.Options> = {
signature: 'device os-update <uuid>',
description: 'Start a Host OS update for a device',
help: stripIndent`
Use this command to trigger a Host OS update for a device.
Notice this command will ask for confirmation interactively.
You can avoid this by passing the \`--yes\` boolean option.
Examples:
$ balena device os-update 23c73a1
$ balena device os-update 23c73a1 --version 2.31.0+rev1.prod
`,
options: [commandOptions.optionalOsVersion, commandOptions.yes],
permission: 'user',
async action(params, options, done) {
normalizeUuidProp(params);
const balena = await import('balena-sdk');
const _ = await import('lodash');
const sdk = balena.fromSharedOptions();
const patterns = await import('../utils/patterns');
const form = await import('resin-cli-form');
return sdk.models.device
.get(params.uuid, {
$select: ['uuid', 'device_type', 'os_version', 'os_variant'],
})
.then(async ({ uuid, device_type, os_version, os_variant }) => {
const currentOsVersion = sdk.models.device.getOsVersion({
os_version,
os_variant,
} as Device);
if (!currentOsVersion) {
patterns.exitWithExpectedError(
'The current os version of the device is not available',
);
// Just to make TS happy
return;
}
return sdk.models.os
.getSupportedOsUpdateVersions(device_type, currentOsVersion)
.then(hupVersionInfo => {
if (hupVersionInfo.versions.length === 0) {
patterns.exitWithExpectedError(
'There are no available Host OS update targets for this device',
);
}
if (options.version != null) {
if (!_.includes(hupVersionInfo.versions, options.version)) {
patterns.exitWithExpectedError(
'The provided version is not in the Host OS update targets for this device',
);
}
return options.version;
}
return form.ask({
message: 'Target OS version',
type: 'list',
choices: hupVersionInfo.versions.map(version => ({
name:
hupVersionInfo.recommended === version
? `${version} (recommended)`
: version,
value: version,
})),
});
})
.then(version =>
patterns
.confirm(
options.yes || false,
'Host OS updates require a device restart when they complete. Are you sure you want to proceed?',
)
.then(() => sdk.models.device.startOsUpdate(uuid, version))
.then(() => patterns.awaitDeviceOsUpdate(uuid, version)),
);
})
.nodeify(done);
},
};

150
lib/actions/help.coffee Normal file
View File

@ -0,0 +1,150 @@
###
Copyright 2016-2017 Balena
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.
###
_ = require('lodash')
capitano = require('capitano')
columnify = require('columnify')
messages = require('../utils/messages')
{ getManualSortCompareFunction } = require('../utils/helpers')
{ exitWithExpectedError } = require('../utils/patterns')
{ getOclifHelpLinePairs } = require('./help_ts')
parse = (object) ->
return _.map object, (item) ->
# Hacky way to determine if an object is
# a function or a command
if item.alias?
signature = item.toString()
else
signature = item.signature.toString()
return [
signature
item.description
]
indent = (text) ->
text = _.map text.split('\n'), (line) ->
return ' ' + line
return text.join('\n')
print = (usageDescriptionPairs) ->
console.log indent columnify _.fromPairs(usageDescriptionPairs),
showHeaders: false
minWidth: 35
manuallySortedPrimaryCommands = [
'help',
'login',
'push',
'logs',
'ssh',
'apps',
'app',
'devices',
'device',
'tunnel',
'preload',
'build',
'deploy',
'join',
'leave',
'local scan',
]
general = (params, options, done) ->
console.log('Usage: balena [COMMAND] [OPTIONS]\n')
console.log(messages.reachingOut)
console.log('\nPrimary commands:\n')
# We do not want the wildcard command
# to be printed in the help screen.
commands = _.reject capitano.state.commands, (command) ->
return command.hidden or command.isWildcard()
groupedCommands = _.groupBy commands, (command) ->
if command.primary
return 'primary'
return 'secondary'
print parse(groupedCommands.primary).sort(getManualSortCompareFunction(
manuallySortedPrimaryCommands,
([signature, description], manualItem) ->
signature == manualItem or signature.startsWith("#{manualItem} ")
))
if options.verbose
console.log('\nAdditional commands:\n')
secondaryCommandPromise = getOclifHelpLinePairs()
.then (oclifHelpLinePairs) ->
print parse(groupedCommands.secondary).concat(oclifHelpLinePairs).sort()
else
console.log('\nRun `balena help --verbose` to list additional commands')
secondaryCommandPromise = Promise.resolve()
secondaryCommandPromise
.then ->
if not _.isEmpty(capitano.state.globalOptions)
console.log('\nGlobal Options:\n')
print parse(capitano.state.globalOptions).sort()
done()
.catch(done)
command = (params, options, done) ->
capitano.state.getMatchCommand params.command, (error, command) ->
return done(error) if error?
if not command? or command.isWildcard()
exitWithExpectedError("Command not found: #{params.command}")
console.log("Usage: #{command.signature}")
if command.help?
console.log("\n#{command.help}")
else if command.description?
console.log("\n#{_.capitalize(command.description)}")
if not _.isEmpty(command.options)
console.log('\nOptions:\n')
print parse(command.options).sort()
return done()
exports.help =
signature: 'help [command...]'
description: 'show help'
help: '''
Get detailed help for an specific command.
Examples:
$ balena help apps
$ balena help os download
'''
primary: true
options: [
signature: 'verbose'
description: 'show additional commands'
boolean: true
alias: 'v'
]
action: (params, options, done) ->
if params.command?
command(params, options, done)
else
general(params, options, done)

53
lib/actions/help_ts.ts Normal file
View File

@ -0,0 +1,53 @@
/**
* @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 { Command } from '@oclif/command';
import * as Bluebird from 'bluebird';
import * as _ from 'lodash';
import * as path from 'path';
import { capitanoizeOclifUsage } from '../utils/oclif-utils';
export async function getOclifHelpLinePairs(): Promise<
Array<[string, string]>
> {
const { convertedCommands } = await import('../preparser');
const cmdClasses: Array<Promise<typeof Command>> = [];
for (const convertedCmd of convertedCommands) {
const [topic, cmd] = convertedCmd.split(':');
const pathComponents = ['..', 'actions-oclif', topic];
if (cmd) {
pathComponents.push(cmd);
}
// note that `import(path)` returns a promise
cmdClasses.push(import(path.join(...pathComponents)));
}
return Bluebird.map(cmdClasses, getCmdUsageDescriptionLinePair);
}
function getCmdUsageDescriptionLinePair(cmdModule: any): [string, string] {
const cmd: typeof Command = cmdModule.default;
const usage = capitanoizeOclifUsage(cmd.usage);
let description = '';
// note: [^] matches any characters (including line breaks), achieving the
// same effect as the 's' regex flag which is only supported by Node 9+
const matches = /\s*([^]+?)\n[^]*/.exec(cmd.description || '');
if (matches && matches.length > 1) {
description = _.lowerFirst(_.trimEnd(matches[1], '.'));
}
return [usage, description];
}

41
lib/actions/index.coffee Normal file
View File

@ -0,0 +1,41 @@
###
Copyright 2016-2017 Balena
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.
###
module.exports =
apiKey: require('./api-key')
app: require('./app')
auth: require('./auth')
device: require('./device')
tags: require('./tags')
keys: require('./keys')
logs: require('./logs')
local: require('./local')
scan: require('./scan')
notes: require('./notes')
help: require('./help')
os: require('./os')
settings: require('./settings')
config: require('./config')
ssh: require('./ssh')
internal: require('./internal')
build: require('./build')
deploy: require('./deploy')
util: require('./util')
preload: require('./preload')
push: require('./push')
join: require('./join')
leave: require('./leave')
tunnel: require('./tunnel')

View File

@ -0,0 +1,56 @@
###
Copyright 2016-2017 Balena
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.
###
# These are internal commands we want to be runnable from the outside
# One use-case for this is spawning the minimal operation with root priviledges
exports.osInit =
signature: 'internal osinit <image> <type> <config>'
description: 'do actual init of the device with the preconfigured os image'
help: '''
Don't use this command directly! Use `balena os initialize <image>` instead.
'''
hidden: true
root: true
action: (params, options, done) ->
Promise = require('bluebird')
init = require('balena-device-init')
helpers = require('../utils/helpers')
configPromise = Promise.try -> JSON.parse(params.config)
manifestPromise = helpers.getManifest(params.image, params.type)
Promise.join configPromise, manifestPromise, (config, manifest) ->
init.initialize(params.image, manifest, config)
.then(helpers.osProgressHandler)
.nodeify(done)
exports.scanDevices =
signature: 'internal scandevices'
description: 'scan for local balena-enabled devices and show a picker to choose one'
help: '''
Don't use this command directly!
'''
hidden: true
root: true
action: (params, options, done) ->
Promise = require('bluebird')
{ forms } = require('balena-sync')
return Promise.try ->
forms.selectLocalBalenaOsDevice()
.then (hostnameOrIp) ->
console.error("==> Selected device: #{hostnameOrIp}")
.nodeify(done)

74
lib/actions/join.ts Normal file
View File

@ -0,0 +1,74 @@
/*
Copyright 2016-2017 Balena
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 { CommandDefinition } from 'capitano';
import { stripIndent } from 'common-tags';
interface Args {
deviceIp?: string;
}
interface Options {
application?: string;
}
export const join: CommandDefinition<Args, Options> = {
signature: 'join [deviceIp]',
description:
'Promote a local device running balenaOS to join an application on a balena server',
help: stripIndent`
Use this command to move a local device to an application on another balena server.
For example, you could provision a device against an openBalena installation
where you perform end-to-end tests and then move it to balenaCloud when it's
ready for production.
Moving a device between applications on the same server is not supported.
If you don't specify a device hostname or IP, this command will automatically
scan the local network for balenaOS devices and prompt you to select one
from an interactive picker. This usually requires root privileges.
Examples:
$ balena join
$ balena join balena.local
$ balena join balena.local --application MyApp
$ balena join 192.168.1.25
$ balena join 192.168.1.25 --application MyApp
`,
options: [
{
signature: 'application',
parameter: 'application',
alias: 'a',
description: 'The name of the application the device should join',
},
],
primary: true,
async action(params, options, done) {
const balena = await import('balena-sdk');
const Logger = await import('../utils/logger');
const promote = await import('../utils/promote');
const sdk = balena.fromSharedOptions();
const logger = Logger.getLogger();
return Bluebird.try(() => {
return promote.join(logger, sdk, params.deviceIp, options.application);
}).nodeify(done);
},
};

123
lib/actions/keys.coffee Normal file
View File

@ -0,0 +1,123 @@
###
Copyright 2016-2017 Balena
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.
###
commandOptions = require('./command-options')
exports.list =
signature: 'keys'
description: 'list all ssh keys'
help: '''
Use this command to list all your SSH keys.
Examples:
$ balena keys
'''
permission: 'user'
action: (params, options, done) ->
balena = require('balena-sdk').fromSharedOptions()
visuals = require('resin-cli-visuals')
balena.models.key.getAll().then (keys) ->
console.log visuals.table.horizontal keys, [
'id'
'title'
]
.nodeify(done)
exports.info =
signature: 'key <id>'
description: 'list a single ssh key'
help: '''
Use this command to show information about a single SSH key.
Examples:
$ balena key 17
'''
permission: 'user'
action: (params, options, done) ->
balena = require('balena-sdk').fromSharedOptions()
visuals = require('resin-cli-visuals')
balena.models.key.get(params.id).then (key) ->
console.log visuals.table.vertical key, [
'id'
'title'
]
# Since the public key string is long, it might
# wrap to lines below, causing the table layout to break.
# See https://github.com/balena-io/balena-cli/issues/151
console.log('\n' + key.public_key)
.nodeify(done)
exports.remove =
signature: 'key rm <id>'
description: 'remove a ssh key'
help: '''
Use this command to remove a SSH key from balena.
Notice this command asks for confirmation interactively.
You can avoid this by passing the `--yes` boolean option.
Examples:
$ balena key rm 17
$ balena key rm 17 --yes
'''
options: [ commandOptions.yes ]
permission: 'user'
action: (params, options, done) ->
balena = require('balena-sdk').fromSharedOptions()
patterns = require('../utils/patterns')
patterns.confirm(options.yes, 'Are you sure you want to delete the key?').then ->
balena.models.key.remove(params.id)
.nodeify(done)
exports.add =
signature: 'key add <name> [path]'
description: 'add a SSH key to balena'
help: '''
Use this command to associate a new SSH key with your account.
If `path` is omitted, the command will attempt
to read the SSH key from stdin.
Examples:
$ balena key add Main ~/.ssh/id_rsa.pub
$ cat ~/.ssh/id_rsa.pub | balena key add Main
'''
permission: 'user'
action: (params, options, done) ->
_ = require('lodash')
Promise = require('bluebird')
readFileAsync = Promise.promisify(require('fs').readFile)
capitano = require('capitano')
balena = require('balena-sdk').fromSharedOptions()
Promise.try ->
return readFileAsync(params.path, encoding: 'utf8') if params.path?
# TODO: should this be promisified for consistency?
Promise.fromNode (callback) ->
capitano.utils.getStdin (data) ->
return callback(null, data)
.then(_.partial(balena.models.key.create, params.name))
.nodeify(done)

59
lib/actions/leave.ts Normal file
View File

@ -0,0 +1,59 @@
/*
Copyright 2016-2017 Balena
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 { CommandDefinition } from 'capitano';
import { stripIndent } from 'common-tags';
interface Args {
deviceIp?: string;
}
export const leave: CommandDefinition<Args, {}> = {
signature: 'leave [deviceIp]',
description: 'Detach a local device from its balena application',
help: stripIndent`
Use this command to make a local device leave the balena server it is
provisioned on. This effectively makes the device "unmanaged".
The device entry on the server is preserved after running this command,
so the device can subsequently re-join the server if needed.
If you don't specify a device hostname or IP, this command will automatically
scan the local network for balenaOS devices and prompt you to select one
from an interactive picker. This usually requires root privileges.
Examples:
$ balena leave
$ balena leave balena.local
$ balena leave 192.168.1.25
`,
options: [],
permission: 'user',
primary: true,
async action(params, _options, done) {
const balena = await import('balena-sdk');
const Logger = await import('../utils/logger');
const promote = await import('../utils/promote');
const sdk = balena.fromSharedOptions();
const logger = Logger.getLogger();
return Bluebird.try(() => {
return promote.leave(logger, sdk, params.deviceIp);
}).nodeify(done);
},
};

View File

@ -0,0 +1,62 @@
Promise = require('bluebird')
_ = require('lodash')
chalk = require('chalk')
dockerUtils = require('../../utils/docker')
{ exitWithExpectedError } = require('../../utils/patterns')
exports.dockerPort = dockerPort = 2375
exports.dockerTimeout = dockerTimeout = 2000
exports.filterOutSupervisorContainer = filterOutSupervisorContainer = (container) ->
for name in container.Names
return false if (name.includes('resin_supervisor') or name.includes('balena_supervisor'))
return true
exports.selectContainerFromDevice = Promise.method (deviceIp, filterSupervisor = false) ->
form = require('resin-cli-form')
docker = dockerUtils.createClient(host: deviceIp, port: dockerPort, timeout: dockerTimeout)
# List all containers, including those not running
docker.listContainersAsync(all: true)
.filter (container) ->
return true if not filterSupervisor
filterOutSupervisorContainer(container)
.then (containers) ->
if _.isEmpty(containers)
exitWithExpectedError("No containers found in #{deviceIp}")
return form.ask
message: 'Select a container'
type: 'list'
choices: _.map containers, (container) ->
containerName = container.Names?[0] or 'Untitled'
shortContainerId = ('' + container.Id).substr(0, 11)
return {
name: "#{containerName} (#{shortContainerId})"
value: container.Id
}
exports.pipeContainerStream = Promise.method ({ deviceIp, name, outStream, follow = false }) ->
docker = dockerUtils.createClient(host: deviceIp, port: dockerPort)
container = docker.getContainer(name)
container.inspectAsync()
.then (containerInfo) ->
return containerInfo?.State?.Running
.then (isRunning) ->
container.attachAsync
logs: not follow or not isRunning
stream: follow and isRunning
stdout: true
stderr: true
.then (containerStream) ->
containerStream.pipe(outStream)
.catch (err) ->
err = '' + err.statusCode
if err is '404'
return console.log(chalk.red.bold("Container '#{name}' not found."))
throw err
exports.getSubShellCommand = require('../../utils/helpers').getSubShellCommand

View File

@ -0,0 +1,237 @@
###
Copyright 2017 Balena
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.
###
BOOT_PARTITION = 1
CONNECTIONS_FOLDER = '/system-connections'
getConfigurationSchema = (connnectionFileName = 'resin-wifi') ->
mapper: [
{
template:
persistentLogging: '{{persistentLogging}}'
domain: [
[ 'config_json', 'persistentLogging' ]
]
}
{
template:
hostname: '{{hostname}}'
domain: [
[ 'config_json', 'hostname' ]
]
}
{
template:
wifi:
ssid: '{{networkSsid}}'
'wifi-security':
psk: '{{networkKey}}'
domain: [
[ 'system_connections', connnectionFileName, 'wifi' ]
[ 'system_connections', connnectionFileName, 'wifi-security' ]
]
}
]
files:
system_connections:
fileset: true
type: 'ini'
location:
path: CONNECTIONS_FOLDER.slice(1)
# Reconfix still uses the older resin-image-fs, so still needs an
# object-based partition definition.
partition: BOOT_PARTITION
config_json:
type: 'json'
location:
path: 'config.json'
partition: BOOT_PARTITION
inquirerOptions = (data) -> [
{
message: 'Network SSID'
type: 'input'
name: 'networkSsid'
default: data.networkSsid
}
{
message: 'Network Key'
type: 'input'
name: 'networkKey'
default: data.networkKey
}
{
message: 'Do you want to set advanced settings?'
type: 'confirm'
name: 'advancedSettings'
default: false
}
{
message: 'Device Hostname'
type: 'input'
name: 'hostname'
default: data.hostname,
when: (answers) ->
answers.advancedSettings
}
{
message: 'Do you want to enable persistent logging?'
type: 'confirm'
name: 'persistentLogging'
default: data.persistentLogging
when: (answers) ->
answers.advancedSettings
}
]
getConfiguration = (data) ->
_ = require('lodash')
inquirer = require('inquirer')
# `persistentLogging` can be `undefined`, so we want
# to make sure that case defaults to `false`
data = _.assign data,
persistentLogging: data.persistentLogging or false
inquirer.prompt(inquirerOptions(data))
.then (answers) ->
return _.merge(data, answers)
# Taken from https://goo.gl/kr1kCt
CONNECTION_FILE = '''
[connection]
id=resin-wifi
type=wifi
[wifi]
hidden=true
mode=infrastructure
ssid=My_Wifi_Ssid
[wifi-security]
auth-alg=open
key-mgmt=wpa-psk
psk=super_secret_wifi_password
[ipv4]
method=auto
[ipv6]
addr-gen-mode=stable-privacy
method=auto
'''
###
* if the `resin-wifi` file exists (previously configured image or downloaded from the UI) it's used and reconfigured
* if the `resin-sample.ignore` exists it's copied to `resin-wifi`
* if the `resin-sample` exists it's reconfigured (legacy mode, will be removed eventually)
* otherwise, the new file is created
###
prepareConnectionFile = (target) ->
_ = require('lodash')
imagefs = require('resin-image-fs')
imagefs.listDirectory
image: target
partition: BOOT_PARTITION
path: CONNECTIONS_FOLDER
.then (files) ->
# The required file already exists
if _.includes(files, 'resin-wifi')
return null
# Fresh image, new mode, accoding to https://github.com/balena-os/meta-balena/pull/770/files
if _.includes(files, 'resin-sample.ignore')
return imagefs.copy
image: target
partition: BOOT_PARTITION
path: "#{CONNECTIONS_FOLDER}/resin-sample.ignore"
,
image: target
partition: BOOT_PARTITION
path: "#{CONNECTIONS_FOLDER}/resin-wifi"
.thenReturn(null)
# Legacy mode, to be removed later
# We return the file name override from this branch
# When it is removed the following cleanup should be done:
# * delete all the null returns from this method
# * turn `getConfigurationSchema` back into the constant, with the connection filename always being `resin-wifi`
# * drop the final `then` from this method
# * adapt the code in the main listener to not receive the config from this method, and use that constant instead
if _.includes(files, 'resin-sample')
return 'resin-sample'
# In case there's no file at all (shouldn't happen normally, but the file might have been removed)
return imagefs.writeFile
image: target
partition: BOOT_PARTITION
path: "#{CONNECTIONS_FOLDER}/resin-wifi"
, CONNECTION_FILE
.thenReturn(null)
.then (connectionFileName) ->
return getConfigurationSchema(connectionFileName)
removeHostname = (schema) ->
_ = require('lodash')
schema.mapper = _.reject schema.mapper, (mapper) ->
_.isEqual(Object.keys(mapper.template), ['hostname'])
module.exports =
signature: 'local configure <target>'
description: '(Re)configure a balenaOS drive or image'
help: '''
Use this command to configure or reconfigure a balenaOS drive or image.
Examples:
$ balena local configure /dev/sdc
$ balena local configure path/to/image.img
'''
root: true
action: (params, options, done) ->
Promise = require('bluebird')
path = require('path')
umount = require('umount')
umountAsync = Promise.promisify(umount.umount)
isMountedAsync = Promise.promisify(umount.isMounted)
reconfix = require('reconfix')
denymount = Promise.promisify(require('denymount'))
prepareConnectionFile(params.target)
.tap ->
isMountedAsync(params.target).then (isMounted) ->
return if not isMounted
umountAsync(params.target)
.then (configurationSchema) ->
dmOpts = {}
if process.pkg
# when running in a standalone pkg install, the 'denymount'
# executable is placed on the same folder as process.execPath
dmOpts.executablePath = path.join(path.dirname(process.execPath), 'denymount')
dmHandler = (cb) ->
reconfix.readConfiguration(configurationSchema, params.target)
.then(getConfiguration)
.then (answers) ->
if not answers.hostname
removeHostname(configurationSchema)
reconfix.writeConfiguration(configurationSchema, answers, params.target)
.asCallback(cb)
denymount params.target, dmHandler, dmOpts
.then ->
console.log('Done!')
.asCallback(done)

120
lib/actions/local/flash.ts Normal file
View File

@ -0,0 +1,120 @@
/*
Copyright 2017 Balena
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 { CommandDefinition } from 'capitano';
import chalk from 'chalk';
import { stripIndent } from 'common-tags';
import * as SDK from 'etcher-sdk';
async function getDrive(options: {
drive?: string;
}): Promise<SDK.sourceDestination.BlockDevice> {
const sdk = await import('etcher-sdk');
const adapter = new sdk.scanner.adapters.BlockDeviceAdapter(() => false);
const scanner = new sdk.scanner.Scanner([adapter]);
await scanner.start();
let drive: SDK.sourceDestination.BlockDevice;
if (options.drive !== undefined) {
const d = scanner.getBy('device', options.drive);
if (d === undefined || !(d instanceof sdk.sourceDestination.BlockDevice)) {
throw new Error(`Drive not found: ${options.drive}`);
}
drive = d;
} else {
const { DriveList } = await import('../../utils/visuals/drive-list');
const driveList = new DriveList(scanner);
drive = await driveList.run();
}
scanner.stop();
return drive;
}
export const flash: CommandDefinition<
{ image: string },
{ drive: string; yes: boolean }
> = {
signature: 'local flash <image>',
description: 'Flash an image to a drive',
help: stripIndent`
Use this command to flash a balenaOS image to a drive.
Examples:
$ balena local flash path/to/balenaos.img[.zip|.gz|.bz2|.xz]
$ balena local flash path/to/balenaos.img --drive /dev/disk2
$ balena local flash path/to/balenaos.img --drive /dev/disk2 --yes
`,
options: [
{
signature: 'yes',
boolean: true,
description: 'confirm non-interactively',
alias: 'y',
},
{
signature: 'drive',
parameter: 'drive',
description: 'drive',
alias: 'd',
},
],
async action(params, options) {
const visuals = await import('resin-cli-visuals');
const form = await import('resin-cli-form');
const { sourceDestination, multiWrite } = await import('etcher-sdk');
const drive = await getDrive(options);
const yes =
options.yes ||
(await form.ask({
message: 'This will erase the selected drive. Are you sure?',
type: 'confirm',
name: 'yes',
default: false,
}));
if (yes !== true) {
console.log(chalk.red.bold('Aborted image flash'));
process.exit(0);
}
const file = new sourceDestination.File(
params.image,
sourceDestination.File.OpenFlags.Read,
);
const source = await file.getInnerSource();
const progressBars: { [key: string]: any } = {
flashing: new visuals.Progress('Flashing'),
verifying: new visuals.Progress('Validating'),
};
await multiWrite.pipeSourceToDestinations(
source,
[drive],
(_, error) => {
// onFail
console.log(chalk.red.bold(error.message));
},
(progress: SDK.multiWrite.MultiDestinationProgress) => {
// onProgress
progressBars[progress.type].update(progress);
},
true, // verify
);
},
};

View File

@ -1,5 +1,5 @@
/*
Copyright 2016-2020 Balena
###
Copyright 2017 Balena
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -12,15 +12,7 @@ distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
###
import type { OptionalNavigationResource } from 'balena-sdk';
export const getExpanded = <T extends object>(
obj: OptionalNavigationResource<T>,
) => (Array.isArray(obj) && obj[0]) || undefined;
export const getExpandedProp = <T extends object, K extends keyof T>(
obj: OptionalNavigationResource<T>,
key: K,
) => (Array.isArray(obj) && obj[0] && obj[0][key]) || undefined;
exports.configure = require('./configure')
exports.flash = require('./flash').flash

View File

@ -0,0 +1,66 @@
###
Copyright 2017 Balena
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.
###
# A function to reliably execute a command
# in all supported operating systems, including
# different Windows environments like `cmd.exe`
# and `Cygwin` should be encapsulated in a
# re-usable package.
#
module.exports =
signature: 'local logs [deviceIp]'
description: 'Get or attach to logs of a running container on a balenaOS device'
help: '''
Examples:
$ balena local logs
$ balena local logs -f
$ balena local logs 192.168.1.10
$ balena local logs 192.168.1.10 -f
$ balena local logs 192.168.1.10 -f --app-name myapp
'''
options: [
signature: 'follow'
boolean: true
description: 'follow log'
alias: 'f'
,
signature: 'app-name'
parameter: 'name'
description: 'name of container to get logs from'
alias: 'a'
]
root: true
action: (params, options, done) ->
Promise = require('bluebird')
{ forms } = require('balena-sync')
{ selectContainerFromDevice, pipeContainerStream } = require('./common')
Promise.try ->
if not params.deviceIp?
return forms.selectLocalBalenaOsDevice()
return params.deviceIp
.then (@deviceIp) =>
if not options['app-name']?
return selectContainerFromDevice(@deviceIp)
return options['app-name']
.then (appName) =>
pipeContainerStream
deviceIp: @deviceIp
name: appName
outStream: process.stdout
follow: options['follow']

View File

@ -0,0 +1,95 @@
###
Copyright 2016-2017 Balena
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.
###
# Loads '.balena-sync.yml' configuration from 'source' directory.
# Returns the configuration object on success
#
_ = require('lodash')
balenaPush = require('balena-sync').capitano('balena-toolbox')
originalAction = balenaPush.action
# TODO: This is a temporary workaround to reuse the existing `rdt push`
# capitano frontend in `balena local push`.
# coffeelint: disable-next-line ("Line ends with trailing whitespace")
deprecationMsg = '''
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Deprecation notice: `balena local push` is deprecated and will be removed in a
future release of the CLI. Please use `balena push <ipAddress>` instead.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
'''
balenaPushHelp = """#{deprecationMsg}
Use this command to push your local changes to a container on a LAN-accessible
balenaOS device on the fly.
This command requires an openssh-compatible 'ssh' client and 'rsync' to be
available in the executable PATH of the shell environment. For more information
(including Windows support) please check the README at:
https://github.com/balena-io/balena-cli
If `Dockerfile` or any file in the 'build-triggers' list is changed,
a new container will be built and run on your device.
If not, changes will simply be synced with `rsync` into the application container.
After every 'balena local push' the updated settings will be saved in
'<source>/.balena-sync.yml' and will be used in later invocations. You can
also change any option by editing '.balena-sync.yml' directly.
Here is an example '.balena-sync.yml' :
$ cat $PWD/.balena-sync.yml
local_balenaos:
app-name: local-app
build-triggers:
- Dockerfile: file-hash-abcdefabcdefabcdefabcdefabcdefabcdef
- package.json: file-hash-abcdefabcdefabcdefabcdefabcdefabcdef
environment:
- MY_VARIABLE=123
Command line options have precedence over the ones saved in '.balena-sync.yml'.
If '.gitignore' is found in the source directory then all explicitly listed files will be
excluded when using rsync to update the container. You can choose to change this default behavior with the
'--skip-gitignore' option.
Examples:
$ balena local push
$ balena local push --app-name test-server --build-triggers package.json,requirements.txt
$ balena local push --force-build
$ balena local push --force-build --skip-logs
$ balena local push --ignore lib/
$ balena local push --verbose false
$ balena local push 192.168.2.10 --source . --destination /usr/src/app
$ balena local push 192.168.2.10 -s /home/user/balenaProject -d /usr/src/app --before 'echo Hello' --after 'echo Done'
"""
module.exports = _.assign balenaPush,
signature: 'local push [deviceIp]'
description: '[deprecated: use "balena push ipAddress"] ' + balenaPush.description
help: balenaPushHelp
primary: false
root: true
action: (params, options, done) ->
console.log deprecationMsg
originalAction(params, options, done)

View File

@ -0,0 +1,114 @@
###
Copyright 2017 Balena
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.
###
{ hostOSAccess } = require('../command-options')
_ = require('lodash')
localHostOSAccessOption = _.cloneDeep(hostOSAccess)
localHostOSAccessOption.description = 'get a shell into the host OS'
module.exports =
signature: 'local ssh [deviceIp]'
description: 'Get a shell into a balenaOS device'
help: '''
Warning: 'balena local ssh' requires an openssh-compatible client to be correctly
installed in your shell environment. For more information (including Windows
support) please check the README here: https://github.com/balena-io/balena-cli
Use this command to get a shell into the running application container of
your device.
The '--host' option will get you a shell into the Host OS of the balenaOS device.
No option will return a list of containers to enter or you can explicitly select
one by passing its name to the --container option
Examples:
$ balena local ssh
$ balena local ssh --host
$ balena local ssh --container chaotic_water
$ balena local ssh --container chaotic_water --port 22222
$ balena local ssh --verbose
'''
options: [
signature: 'verbose'
boolean: true
description: 'increase verbosity'
alias: 'v'
,
localHostOSAccessOption,
signature: 'container'
parameter: 'container'
default: null
description: 'name of container to access'
alias: 'c'
,
signature: 'port'
parameter: 'port'
description: 'ssh port number (default: 22222)'
alias: 'p'
]
root: true
action: (params, options, done) ->
child_process = require('child_process')
Promise = require 'bluebird'
_ = require('lodash')
{ forms } = require('balena-sync')
{ selectContainerFromDevice, getSubShellCommand } = require('./common')
{ exitWithExpectedError } = require('../../utils/patterns')
if (options.host is true and options.container?)
exitWithExpectedError('Please pass either --host or --container option')
if not options.port?
options.port = 22222
verbose = if options.verbose then '-vvv' else ''
Promise.try ->
if not params.deviceIp?
return forms.selectLocalBalenaOsDevice()
return params.deviceIp
.then (deviceIp) ->
_.assign(options, { deviceIp })
return if options.host
if not options.container?
return selectContainerFromDevice(deviceIp)
return options.container
.then (container) ->
command = "ssh \
#{verbose} \
-t \
-p #{options.port} \
-o LogLevel=ERROR \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
root@#{options.deviceIp}"
if not options.host
shellCmd = '''/bin/sh -c $"'if [ -e /bin/bash ]; then exec /bin/bash; else exec /bin/sh; fi'"'''
dockerCmd = "'$(if [ -f /usr/bin/balena ]; then echo \"balena\"; else echo \"docker\"; fi)'"
command += " #{dockerCmd} exec -ti #{container} #{shellCmd}"
subShellCommand = getSubShellCommand(command)
child_process.spawn subShellCommand.program, subShellCommand.args,
stdio: 'inherit'
.nodeify(done)

View File

@ -0,0 +1,79 @@
###
Copyright 2017 Balena
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.
###
# A function to reliably execute a command
# in all supported operating systems, including
# different Windows environments like `cmd.exe`
# and `Cygwin` should be encapsulated in a
# re-usable package.
#
module.exports =
signature: 'local stop [deviceIp]'
description: 'Stop a running container on a balenaOS device'
help: '''
Examples:
$ balena local stop
$ balena local stop --app-name myapp
$ balena local stop --all
$ balena local stop 192.168.1.10
$ balena local stop 192.168.1.10 --app-name myapp
'''
options: [
signature: 'all'
boolean: true
description: 'stop all containers'
,
signature: 'app-name'
parameter: 'name'
description: 'name of container to stop'
alias: 'a'
]
root: true
action: (params, options, done) ->
Promise = require('bluebird')
chalk = require('chalk')
{ forms, config, BalenaLocalDockerUtils } = require('balena-sync')
{ selectContainerFromDevice, filterOutSupervisorContainer } = require('./common')
Promise.try ->
if not params.deviceIp?
return forms.selectLocalBalenaOsDevice()
return params.deviceIp
.then (@deviceIp) =>
@docker = new BalenaLocalDockerUtils(@deviceIp)
if options.all
# Only list running containers
return @docker.docker.listContainersAsync(all: false)
.filter(filterOutSupervisorContainer)
.then (containers) =>
Promise.map containers, ({ Names, Id }) =>
console.log(chalk.yellow.bold("* Stopping container #{Names[0]}"))
@docker.stopContainer(Id)
ymlConfig = config.load()
@appName = options['app-name'] ? ymlConfig['local_balenaos']?['app-name']
@docker.checkForRunningContainer(@appName)
.then (isRunning) =>
if not isRunning
return selectContainerFromDevice(@deviceIp, true)
console.log(chalk.yellow.bold("* Stopping container #{@appName}"))
return @appName
.then (runningContainerName) =>
@docker.stopContainer(runningContainerName)

184
lib/actions/logs.ts Normal file
View File

@ -0,0 +1,184 @@
/*
Copyright 2016-2019 Balena
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 { CommandDefinition } from 'capitano';
import { stripIndent } from 'common-tags';
import { normalizeUuidProp } from '../utils/normalization';
import { validateDotLocalUrl } from '../utils/validation';
type CloudLog =
| {
isSystem: false;
serviceId: number;
timestamp: number;
message: string;
}
| {
isSystem: true;
timestamp: number;
message: string;
};
export const logs: CommandDefinition<
{
uuidOrDevice: string;
},
{
tail?: boolean;
service?: [string] | string;
system?: boolean;
}
> = {
signature: 'logs <uuidOrDevice>',
description: 'show device logs',
help: stripIndent`
Use this command to show logs for a specific device.
By default, the command prints all log messages and exits.
To continuously stream output, and see new logs in real time, use the \`--tail\` option.
If an IP or .local address is passed to this command, logs are displayed from
a local mode device with that address. Note that --tail is implied
when this command is provided a local mode device.
Logs from a single service can be displayed with the --service flag. Just system logs
can be shown with the --system flag. Note that these flags can be used together.
Examples:
$ balena logs 23c73a1
$ balena logs 23c73a1 --tail
$ balena logs 192.168.0.31
$ balena logs 192.168.0.31 --service my-service
$ balena logs 192.168.0.31 --service my-service-1 --service my-service-2
$ balena logs 23c73a1.local --system
$ balena logs 23c73a1.local --system --service my-service`,
options: [
{
signature: 'tail',
description: 'continuously stream output',
boolean: true,
alias: 't',
},
{
signature: 'service',
description: stripIndent`
Reject logs not originating from this service.
This can be used in combination with --system or other --service flags.`,
parameter: 'service',
alias: 's',
},
{
signature: 'system',
alias: 'S',
boolean: true,
description:
'Only show system logs. This can be used in combination with --service.',
},
],
primary: true,
async action(params, options, done) {
normalizeUuidProp(params);
const balena = (await import('balena-sdk')).fromSharedOptions();
const isArray = await import('lodash/isArray');
const { serviceIdToName } = await import('../utils/cloud');
const { displayDeviceLogs, displayLogObject } = await import(
'../utils/device/logs'
);
const { validateIPAddress } = await import('../utils/validation');
const { exitIfNotLoggedIn, exitWithExpectedError } = await import(
'../utils/patterns'
);
const Logger = await import('../utils/logger');
const logger = Logger.getLogger();
const servicesToDisplay =
options.service != null
? isArray(options.service)
? options.service
: [options.service]
: undefined;
const displayCloudLog = async (line: CloudLog) => {
if (!line.isSystem) {
let serviceName = await serviceIdToName(balena, line.serviceId);
if (serviceName == null) {
serviceName = 'Unknown service';
}
displayLogObject(
{ serviceName, ...line },
logger,
options.system || false,
servicesToDisplay,
);
} else {
displayLogObject(
line,
logger,
options.system || false,
servicesToDisplay,
);
}
};
if (
validateIPAddress(params.uuidOrDevice) ||
validateDotLocalUrl(params.uuidOrDevice)
) {
const { DeviceAPI } = await import('../utils/device/api');
const deviceApi = new DeviceAPI(logger, params.uuidOrDevice);
logger.logDebug('Checking we can access device');
try {
await deviceApi.ping();
} catch (e) {
exitWithExpectedError(
new Error(
`Cannot access local mode device at address ${params.uuidOrDevice}`,
),
);
}
const logStream = await deviceApi.getLogStream();
displayDeviceLogs(
logStream,
logger,
options.system || false,
servicesToDisplay,
);
} else {
await exitIfNotLoggedIn();
if (options.tail) {
return balena.logs
.subscribe(params.uuidOrDevice, { count: 100 })
.then(function(logStream) {
logStream.on('line', displayCloudLog);
logStream.on('error', done);
})
.catch(done);
} else {
return balena.logs
.history(params.uuidOrDevice)
.each(displayCloudLog)
.catch(done);
}
}
},
};

55
lib/actions/notes.coffee Normal file
View File

@ -0,0 +1,55 @@
###
Copyright 2016-2017 Balena
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.
###
{ normalizeUuidProp } = require('../utils/normalization')
exports.set =
signature: 'note <|note>'
description: 'set a device note'
help: '''
Use this command to set or update a device note.
If note command isn't passed, the tool attempts to read from `stdin`.
To view the notes, use $ balena device <uuid>.
Examples:
$ balena note "My useful note" --device 7cf02a6
$ cat note.txt | balena note --device 7cf02a6
'''
options: [
signature: 'device'
parameter: 'device'
description: 'device uuid'
alias: [ 'd', 'dev' ]
required: 'You have to specify a device'
]
permission: 'user'
action: (params, options, done) ->
normalizeUuidProp(options, 'device')
Promise = require('bluebird')
_ = require('lodash')
balena = require('balena-sdk').fromSharedOptions()
{ exitWithExpectedError } = require('../utils/patterns')
Promise.try ->
if _.isEmpty(params.note)
exitWithExpectedError('Missing note content')
balena.models.device.note(options.device, params.note)
.nodeify(done)

290
lib/actions/os.coffee Normal file
View File

@ -0,0 +1,290 @@
###
Copyright 2016-2019 Balena
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.
###
commandOptions = require('./command-options')
_ = require('lodash')
formatVersion = (v, isRecommended) ->
result = "v#{v}"
if isRecommended
result += ' (recommended)'
return result
resolveVersion = (deviceType, version) ->
if version isnt 'menu'
if version[0] == 'v'
version = version.slice(1)
return Promise.resolve(version)
form = require('resin-cli-form')
balena = require('balena-sdk').fromSharedOptions()
balena.models.os.getSupportedVersions(deviceType)
.then ({ versions, recommended }) ->
choices = versions.map (v) ->
value: v
name: formatVersion(v, v is recommended)
return form.ask
message: 'Select the OS version:'
type: 'list'
choices: choices
default: recommended
exports.versions =
signature: 'os versions <type>'
description: 'show the available balenaOS versions for the given device type'
help: '''
Use this command to show the available balenaOS versions for a certain device type.
Check available types with `balena devices supported`
Example:
$ balena os versions raspberrypi3
'''
action: (params, options, done) ->
balena = require('balena-sdk').fromSharedOptions()
balena.models.os.getSupportedVersions(params.type)
.then ({ versions, recommended }) ->
versions.forEach (v) ->
console.log(formatVersion(v, v is recommended))
exports.download =
signature: 'os download <type>'
description: 'download an unconfigured os image'
help: '''
Use this command to download an unconfigured os image for a certain device type.
Check available types with `balena devices supported`
If version is not specified the newest stable (non-pre-release) version of OS
is downloaded if available, or the newest version otherwise (if all existing
versions for the given device type are pre-release).
You can pass `--version menu` to pick the OS version from the interactive menu
of all available versions.
Examples:
$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img
$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version 1.24.1
$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version ^1.20.0
$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version latest
$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version default
$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version menu
'''
permission: 'user'
options: [
{
signature: 'output'
description: 'output path'
parameter: 'output'
alias: 'o'
required: 'You have to specify the output location'
}
commandOptions.osVersionOrSemver
]
action: (params, options, done) ->
Promise = require('bluebird')
unzip = require('unzip2')
fs = require('fs')
rindle = require('rindle')
manager = require('balena-image-manager')
visuals = require('resin-cli-visuals')
console.info("Getting device operating system for #{params.type}")
displayVersion = ''
Promise.try ->
if not options.version
console.warn('OS version is not specified, using the default version:
the newest stable (non-pre-release) version if available,
or the newest version otherwise (if all existing
versions for the given device type are pre-release).')
return 'default'
return resolveVersion(params.type, options.version)
.then (version) ->
if version isnt 'default'
displayVersion = " #{version}"
return manager.get(params.type, version)
.then (stream) ->
bar = new visuals.Progress("Downloading Device OS#{displayVersion}")
spinner = new visuals.Spinner("Downloading Device OS#{displayVersion} (size unknown)")
stream.on 'progress', (state) ->
if state?
bar.update(state)
else
spinner.start()
stream.on 'end', ->
spinner.stop()
# We completely rely on the `mime` custom property
# to make this decision.
# The actual stream should be checked instead.
if stream.mime is 'application/zip'
output = unzip.Extract(path: options.output)
else
output = fs.createWriteStream(options.output)
return rindle.wait(stream.pipe(output)).return(options.output)
.tap (output) ->
console.info('The image was downloaded successfully')
.nodeify(done)
buildConfigForDeviceType = (deviceType, advanced = false) ->
form = require('resin-cli-form')
helpers = require('../utils/helpers')
questions = deviceType.options
if not advanced
advancedGroup = _.find questions,
name: 'advanced'
isGroup: true
if advancedGroup?
override = helpers.getGroupDefaults(advancedGroup)
return form.run(questions, { override })
buildConfig = (image, deviceTypeSlug, advanced = false) ->
Promise = require('bluebird')
helpers = require('../utils/helpers')
Promise.resolve(helpers.getManifest(image, deviceTypeSlug))
.then (deviceTypeManifest) ->
buildConfigForDeviceType(deviceTypeManifest, advanced)
exports.buildConfig =
signature: 'os build-config <image> <device-type>'
description: 'build the OS config and save it to the JSON file'
help: '''
Use this command to prebuild the OS config once and skip the interactive part of `balena os configure`.
Example:
$ balena os build-config ../path/rpi3.img raspberrypi3 --output rpi3-config.json
$ balena os configure ../path/rpi3.img --device 7cf02a6 --config rpi3-config.json
'''
permission: 'user'
options: [
commandOptions.advancedConfig
{
signature: 'output'
description: 'the path to the output JSON file'
alias: 'o'
required: 'the output path is required'
parameter: 'output'
}
]
action: (params, options, done) ->
fs = require('fs')
Promise = require('bluebird')
writeFileAsync = Promise.promisify(fs.writeFile)
buildConfig(params.image, params['device-type'], options.advanced)
.then (answers) ->
writeFileAsync(options.output, JSON.stringify(answers, null, 4))
.nodeify(done)
INIT_WARNING_MESSAGE = '''
Note: Initializing the device may ask for administrative permissions
because we need to access the raw devices directly.
'''
exports.initialize =
signature: 'os initialize <image>'
description: 'initialize an os image'
help: """
Use this command to initialize a device with previously configured operating system image.
#{INIT_WARNING_MESSAGE}
Examples:
$ balena os initialize ../path/rpi.img --type 'raspberry-pi'
"""
permission: 'user'
options: [
commandOptions.yes
{
signature: 'type'
description: 'device type (Check available types with `balena devices supported`)'
parameter: 'type'
alias: 't'
required: 'You have to specify a device type'
}
commandOptions.drive
]
action: (params, options, done) ->
Promise = require('bluebird')
umountAsync = Promise.promisify(require('umount').umount)
form = require('resin-cli-form')
patterns = require('../utils/patterns')
helpers = require('../utils/helpers')
console.info("""
Initializing device
#{INIT_WARNING_MESSAGE}
""")
Promise.resolve(helpers.getManifest(params.image, options.type))
.then (manifest) ->
return manifest.initialization?.options
.then (questions) ->
return form.run questions,
override:
drive: options.drive
.tap (answers) ->
return if not answers.drive?
patterns.confirm(
options.yes
"This will erase #{answers.drive}. Are you sure?"
"Going to erase #{answers.drive}."
true
)
.return(answers.drive)
.then(umountAsync)
.tap (answers) ->
return helpers.sudo([
'internal'
'osinit'
params.image
options.type
JSON.stringify(answers)
])
.then (answers) ->
return if not answers.drive?
# TODO: balena local makes use of ejectAsync, see below
# DO we need this / should we do that here?
# getDrive = (drive) ->
# driveListAsync().then (drives) ->
# selectedDrive = _.find(drives, device: drive)
# if not selectedDrive?
# throw new Error("Drive not found: #{drive}")
# return selectedDrive
# if (os.platform() is 'win32') and selectedDrive.mountpoint?
# ejectAsync = Promise.promisify(require('removedrive').eject)
# return ejectAsync(selectedDrive.mountpoint)
umountAsync(answers.drive).tap ->
console.info("You can safely remove #{answers.drive} now")
.nodeify(done)

337
lib/actions/preload.coffee Normal file
View File

@ -0,0 +1,337 @@
###
Copyright 2016-2017 Balena
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.
###
_ = require('lodash')
dockerUtils = require('../utils/docker')
allDeviceTypes = undefined
isCurrent = (commit) ->
return commit == 'latest' or commit == 'current'
getDeviceTypes = ->
Bluebird = require('bluebird')
_ = require('lodash')
if allDeviceTypes != undefined
return Bluebird.resolve(allDeviceTypes)
balena = require('balena-sdk').fromSharedOptions()
balena.models.config.getDeviceTypes()
.then (deviceTypes) ->
_.sortBy(deviceTypes, 'name')
.tap (dt) ->
allDeviceTypes = dt
getDeviceTypesWithSameArch = (deviceTypeSlug) ->
_ = require('lodash')
getDeviceTypes()
.then (deviceTypes) ->
deviceType = _.find(deviceTypes, slug: deviceTypeSlug)
_(deviceTypes).filter(arch: deviceType.arch).map('slug').value()
getApplicationsWithSuccessfulBuilds = (deviceType) ->
preload = require('balena-preload')
balena = require('balena-sdk').fromSharedOptions()
getDeviceTypesWithSameArch(deviceType)
.then (deviceTypes) ->
balena.pine.get
resource: 'my_application'
options:
$filter:
device_type:
$in: deviceTypes
owns__release:
$any:
$alias: 'r'
$expr:
r:
status: 'success'
$expand: preload.applicationExpandOptions
$select: [ 'id', 'app_name', 'device_type', 'commit', 'should_track_latest_release' ]
$orderby: 'app_name asc'
selectApplication = (deviceType) ->
visuals = require('resin-cli-visuals')
form = require('resin-cli-form')
{ exitWithExpectedError } = require('../utils/patterns')
applicationInfoSpinner = new visuals.Spinner('Downloading list of applications and releases.')
applicationInfoSpinner.start()
getApplicationsWithSuccessfulBuilds(deviceType)
.then (applications) ->
applicationInfoSpinner.stop()
if applications.length == 0
exitWithExpectedError("You have no apps with successful releases for a '#{deviceType}' device type.")
form.ask
message: 'Select an application'
type: 'list'
choices: applications.map (app) ->
name: app.app_name
value: app
selectApplicationCommit = (releases) ->
form = require('resin-cli-form')
{ exitWithExpectedError } = require('../utils/patterns')
if releases.length == 0
exitWithExpectedError('This application has no successful releases.')
DEFAULT_CHOICE = { 'name': 'current', 'value': 'current' }
choices = [ DEFAULT_CHOICE ].concat releases.map (release) ->
name: "#{release.end_timestamp} - #{release.commit}"
value: release.commit
return form.ask
message: 'Select a release'
type: 'list'
default: 'current'
choices: choices
offerToDisableAutomaticUpdates = (application, commit, pinDevice) ->
Promise = require('bluebird')
balena = require('balena-sdk').fromSharedOptions()
form = require('resin-cli-form')
if isCurrent(commit) or not application.should_track_latest_release or pinDevice
return Promise.resolve()
message = '''
This application is set to automatically update all devices to the current version.
This might be unexpected behaviour: with this enabled, the preloaded device will still
download and install the current release once it is online.
Do you want to disable automatic updates for this application?
Warning: To re-enable this requires direct api calls,
see https://balena.io/docs/reference/api/resources/device/#set-device-to-release
Alternatively you can pass the --pin-device-to-release flag to pin only this device to the selected release.
'''
form.ask
message: message,
type: 'confirm'
.then (update) ->
if not update
return
balena.pine.patch
resource: 'application'
id: application.id
body:
should_track_latest_release: false
preloadOptions = dockerUtils.appendConnectionOptions [
{
signature: 'app'
parameter: 'appId'
description: 'id of the application to preload'
alias: 'a'
}
{
signature: 'commit'
parameter: 'hash'
description: '''
The commit hash for a specific application release to preload, use "current" to specify the current
release (ignored if no appId is given). The current release is usually also the latest, but can be
manually pinned using https://github.com/balena-io-projects/staged-releases .
'''
alias: 'c'
}
{
signature: 'splash-image'
parameter: 'splashImage.png'
description: 'path to a png image to replace the splash screen'
alias: 's'
}
{
signature: 'dont-check-arch'
boolean: true
description: 'Disables check for matching architecture in image and application'
}
{
signature: 'pin-device-to-release'
boolean: true
description: 'Pin the preloaded device to the preloaded release on provision'
alias: 'p'
}
{
signature: 'add-certificate'
parameter: 'certificate.crt'
description: '''
Add the given certificate (in PEM format) to /etc/ssl/certs in the preloading container.
The file name must end with '.crt' and must not be already contained in the preloader's
/etc/ssl/certs folder.
Can be repeated to add multiple certificates.
'''
}
]
# Remove dockerPort `-p` alias as it conflicts with pin-device-to-release
delete _.find(preloadOptions, signature: 'dockerPort').alias
module.exports =
signature: 'preload <image>'
description: 'preload an app on a disk image (or Edison zip archive)'
help: '''
Preload a balena application release (app images/containers), and optionally
a balenaOS splash screen, in a previously downloaded balenaOS image file (or
Edison zip archive) in the local disk. The balenaOS image file can then be
flashed to a device's SD card. When the device boots, it will not need to
download the application, as it was preloaded.
Warning: "balena preload" requires Docker to be correctly installed in
your shell environment. For more information (including Windows support)
check: https://github.com/balena-io/balena-cli/blob/master/INSTALL.md
Examples:
$ balena preload balena.img --app 1234 --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0 --splash-image image.png
$ balena preload balena.img
'''
permission: 'user'
primary: true
options: preloadOptions
action: (params, options, done) ->
_ = require('lodash')
Promise = require('bluebird')
balena = require('balena-sdk').fromSharedOptions()
preload = require('balena-preload')
visuals = require('resin-cli-visuals')
nodeCleanup = require('node-cleanup')
{ exitWithExpectedError } = require('../utils/patterns')
progressBars = {}
progressHandler = (event) ->
progressBar = progressBars[event.name]
if not progressBar
progressBar = progressBars[event.name] = new visuals.Progress(event.name)
progressBar.update(percentage: event.percentage)
spinners = {}
spinnerHandler = (event) ->
spinner = spinners[event.name]
if not spinner
spinner = spinners[event.name] = new visuals.Spinner(event.name)
if event.action == 'start'
spinner.start()
else
console.log()
spinner.stop()
options.commit = if isCurrent(options.commit) then 'latest' else options.commit
options.image = params.image
options.appId = options.app
delete options.app
options.splashImage = options['splash-image']
delete options['splash-image']
options.dontCheckArch = options['dont-check-arch'] || false
delete options['dont-check-arch']
if options.dontCheckArch and not options.appId
exitWithExpectedError('You need to specify an app id if you disable the architecture check.')
options.pinDevice = options['pin-device-to-release'] || false
delete options['pin-device-to-release']
if _.isArray(options['add-certificate'])
certificates = options['add-certificate']
else if options['add-certificate'] == undefined
certificates = []
else
certificates = [ options['add-certificate'] ]
for certificate in certificates
if not certificate.endsWith('.crt')
exitWithExpectedError('Certificate file name must end with ".crt"')
# Get a configured dockerode instance
dockerUtils.getDocker(options)
.then (docker) ->
preloader = new preload.Preloader(
balena
docker
options.appId
options.commit
options.image
options.splashImage
options.proxy
options.dontCheckArch
options.pinDevice
certificates
)
gotSignal = false
nodeCleanup (exitCode, signal) ->
if signal
gotSignal = true
nodeCleanup.uninstall() # don't call cleanup handler again
preloader.cleanup()
.then ->
# calling process.exit() won't inform parent process of signal
process.kill(process.pid, signal)
return false
if process.env.DEBUG
preloader.stderr.pipe(process.stderr)
preloader.on('progress', progressHandler)
preloader.on('spinner', spinnerHandler)
return new Promise (resolve, reject) ->
preloader.on('error', reject)
preloader.prepare()
.then ->
# If no appId was provided, show a list of matching apps
Promise.try ->
if not preloader.appId
selectApplication(preloader.config.deviceType)
.then (application) ->
preloader.setApplication(application)
.then ->
# Use the commit given as --commit or show an interactive commit selection menu
Promise.try ->
if options.commit
if isCurrent(options.commit) and preloader.application.commit
# handle `--commit current` (and its `--commit latest` synonym)
return 'latest'
release = _.find preloader.application.owns__release, (release) ->
release.commit.startsWith(options.commit)
if not release
exitWithExpectedError('There is no release matching this commit')
return release.commit
selectApplicationCommit(preloader.application.owns__release)
.then (commit) ->
if isCurrent(commit)
preloader.commit = preloader.application.commit
else
preloader.commit = commit
# Propose to disable automatic app updates if the commit is not the current release
offerToDisableAutomaticUpdates(preloader.application, commit, options.pinDevice)
.then ->
# All options are ready: preload the image.
preloader.preload()
.catch(balena.errors.BalenaError, exitWithExpectedError)
.then(resolve)
.catch(reject)
.then(done)
.finally ->
if not gotSignal
preloader.cleanup()

381
lib/actions/push.ts Normal file
View File

@ -0,0 +1,381 @@
/*
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 { BalenaSDK } from 'balena-sdk';
import { CommandDefinition } from 'capitano';
import { stripIndent } from 'common-tags';
import { registrySecretsHelp } from '../utils/messages';
import {
validateApplicationName,
validateDotLocalUrl,
validateIPAddress,
} from '../utils/validation';
enum BuildTarget {
Cloud,
Device,
}
function getBuildTarget(appOrDevice: string): BuildTarget | null {
// First try the application regex from the api
if (validateApplicationName(appOrDevice)) {
return BuildTarget.Cloud;
}
if (validateIPAddress(appOrDevice) || validateDotLocalUrl(appOrDevice)) {
return BuildTarget.Device;
}
return null;
}
async function getAppOwner(sdk: BalenaSDK, appName: string) {
const { exitWithExpectedError, selectFromList } = await import(
'../utils/patterns'
);
const _ = await import('lodash');
const applications = await sdk.models.application.getAll({
$expand: {
user: {
$select: ['username'],
},
},
$filter: {
$eq: [{ $tolower: { $: 'app_name' } }, appName.toLowerCase()],
},
$select: ['id'],
});
if (applications == null || applications.length === 0) {
exitWithExpectedError(
stripIndent`
No applications found with name: ${appName}.
This could mean that the application does not exist, or you do
not have the permissions required to access it.`,
);
}
if (applications.length === 1) {
return _.get(applications, '[0].user[0].username');
}
// If we got more than one application with the same name it means that the
// user has access to a collab app with the same name as a personal app. We
// present a list to the user which shows the fully qualified application
// name (user/appname) and allows them to select
const entries = _.map(applications, app => {
const username = _.get(app, 'user[0].username');
return {
name: `${username}/${appName}`,
extra: username,
};
});
const selected = await selectFromList(
`${
entries.length
} applications found with that name, please select the application you would like to push to`,
entries,
);
return selected.extra;
}
export const push: CommandDefinition<
{
// when Capitano converts a positional parameter (but not an option)
// to a number, the original value is preserved with the _raw suffix
applicationOrDevice: string;
applicationOrDevice_raw: string;
},
{
source?: string;
emulated?: boolean;
dockerfile?: string; // DeviceDeployOptions.dockerfilePath (alternative Dockerfile)
nocache?: boolean;
'registry-secrets'?: string;
nolive?: boolean;
detached?: boolean;
service?: string | string[];
system?: boolean;
env?: string | string[];
}
> = {
signature: 'push <applicationOrDevice>',
primary: true,
description:
'Start a remote build on the balena cloud build servers or a local mode device',
help: stripIndent`
This command can be used to start a build on the remote balena cloud builders,
or a local mode balena device.
When building on the balenaCloud servers, the given source directory will be
sent to the remote server. This can be used as a drop-in replacement for the
"git push" deployment method.
When building on a local mode device, the given source directory will be
built on the device, and the resulting containers will be run on the device.
Logs will be streamed back from the device as part of the same invocation.
The web dashboard can be used to switch a device to local mode:
https://www.balena.io/docs/learn/develop/local-mode/
Note that local mode requires a supervisor version of at least v7.21.0.
The logs from only a single service can be shown with the --service flag, and
showing only the system logs can be achieved with --system. Note that these
flags can be used together.
When pushing to a local device a live session will be started.
The project source folder is watched for filesystem events, and changes
to files and folders are automatically synchronized to the running
containers. The synchronisation is only in one direction, from this machine to
the device, and changes made on the device itself may be overwritten.
This feature requires a device running supervisor version v9.7.0 or greater.
${registrySecretsHelp.split('\n').join('\n\t\t')}
Examples:
$ balena push myApp
$ balena push myApp --source <source directory>
$ balena push myApp -s <source directory>
$ balena push 10.0.0.1
$ balena push 10.0.0.1 --source <source directory>
$ balena push 10.0.0.1 --service my-service
$ balena push 10.0.0.1 --env MY_ENV_VAR=value --env my-service:SERVICE_VAR=value
$ balena push 10.0.0.1 --nolive
$ balena push 23c73a1.local --system
$ balena push 23c73a1.local --system --service my-service
`,
options: [
{
signature: 'source',
alias: 's',
description:
'The source that should be sent to the balena builder to be built (defaults to the current directory)',
parameter: 'source',
},
{
signature: 'emulated',
alias: 'e',
description: 'Force an emulated build to occur on the remote builder',
boolean: true,
},
{
signature: 'dockerfile',
parameter: 'Dockerfile',
description:
'Alternative Dockerfile name/path, relative to the source folder',
},
{
signature: 'nocache',
alias: 'c',
description: "Don't use cache when building this project",
boolean: true,
},
{
signature: 'registry-secrets',
alias: 'R',
parameter: 'secrets.yml|.json',
description: stripIndent`
Path to a local YAML or JSON file containing Docker registry passwords used to pull base images.
Note that if registry-secrets are not provided on the command line, a secrets configuration
file from the balena directory will be used (usually $HOME/.balena/secrets.yml|.json)`,
},
{
signature: 'nolive',
boolean: true,
description: stripIndent`
Don't run a live session on this push. The filesystem will not be monitored, and changes
will not be synchronised to any running containers. Note that both this flag and --detached
and required to cause the process to end once the initial build has completed.`,
},
{
signature: 'detached',
alias: 'd',
description: stripIndent`
When pushing to the cloud, this option will cause the build to start, then return execution
back to the shell, with the status and release ID (if applicable).
When pushing to a local mode device, this option will cause the command to not tail application logs when the build
has completed.`,
boolean: true,
},
{
signature: 'service',
description: stripIndent`
Reject logs not originating from this service.
This can be used in combination with --system and other --service flags.
Only valid when pushing to a local mode device.`,
parameter: 'service',
},
{
signature: 'system',
description: stripIndent`
Only show system logs. This can be used in combination with --service.
Only valid when pushing to a local mode device.`,
boolean: true,
},
{
signature: 'env',
parameter: 'env',
description: stripIndent`
When performing a push to device, run the built containers with environment
variables provided with this argument. Environment variables can be applied
to individual services by adding their service name before the argument,
separated by a colon, e.g:
--env main:MY_ENV=value
Note that if the service name cannot be found in the composition, the entire
left hand side of the = character will be treated as the variable name.
`,
},
],
async action(params, options, done) {
const sdk = (await import('balena-sdk')).fromSharedOptions();
const Bluebird = await import('bluebird');
const isArray = await import('lodash/isArray');
const remote = await import('../utils/remote-build');
const deviceDeploy = await import('../utils/device/deploy');
const { exitIfNotLoggedIn, exitWithExpectedError } = await import(
'../utils/patterns'
);
const { validateSpecifiedDockerfile, getRegistrySecrets } = await import(
'../utils/compose_ts'
);
const { BuildError } = await import('../utils/device/errors');
const appOrDevice: string | null =
params.applicationOrDevice_raw || params.applicationOrDevice;
if (appOrDevice == null) {
exitWithExpectedError('You must specify an application or a device');
}
const source = options.source || '.';
if (process.env.DEBUG) {
console.error(`[debug] Using ${source} as build source`);
}
const dockerfilePath = validateSpecifiedDockerfile(
source,
options.dockerfile,
);
const registrySecrets = await getRegistrySecrets(
sdk,
options['registry-secrets'],
);
const buildTarget = getBuildTarget(appOrDevice);
switch (buildTarget) {
case BuildTarget.Cloud:
// Ensure that the live argument has not been passed to a cloud build
if (options.nolive != null) {
exitWithExpectedError(
'The --nolive flag is only valid when pushing to a local mode device',
);
}
if (options.service) {
exitWithExpectedError(
'The --service flag is only valid when pushing to a local mode device.',
);
}
if (options.system) {
exitWithExpectedError(
'The --system flag is only valid when pushing to a local mode device.',
);
}
if (options.env) {
exitWithExpectedError(
'The --env flag is only valid when pushing to a local mode device.',
);
}
const app = appOrDevice;
await exitIfNotLoggedIn();
await Bluebird.join(
sdk.auth.getToken(),
sdk.settings.get('balenaUrl'),
getAppOwner(sdk, app),
async (token, baseUrl, owner) => {
const opts = {
dockerfilePath,
emulated: options.emulated || false,
nocache: options.nocache || false,
registrySecrets,
headless: options.detached || false,
};
const args = {
app,
owner,
source,
auth: token,
baseUrl,
sdk,
opts,
};
return await remote.startRemoteBuild(args);
},
).nodeify(done);
break;
case BuildTarget.Device:
const device = appOrDevice;
const servicesToDisplay =
options.service != null
? isArray(options.service)
? options.service
: [options.service]
: undefined;
// TODO: Support passing a different port
await Bluebird.resolve(
deviceDeploy.deployToDevice({
source,
deviceHost: device,
dockerfilePath,
registrySecrets,
nocache: options.nocache || false,
nolive: options.nolive || false,
detached: options.detached || false,
services: servicesToDisplay,
system: options.system || false,
env:
typeof options.env === 'string'
? [options.env]
: options.env || [],
}),
)
.catch(BuildError, e => {
exitWithExpectedError(e.toString());
})
.nodeify(done);
break;
default:
exitWithExpectedError(
stripIndent`
Build target not recognised. Please provide either an application name or device address.
The only supported device addresses currently are IP addresses.
If you believe your build target should have been detected, and this is an error, please
create an issue.`,
);
break;
}
},
};

100
lib/actions/scan.coffee Normal file
View File

@ -0,0 +1,100 @@
###
Copyright 2017 Balena
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.
###
dockerInfoProperties = [
'Containers'
'ContainersRunning'
'ContainersPaused'
'ContainersStopped'
'Images'
'Driver'
'SystemTime'
'KernelVersion'
'OperatingSystem'
'Architecture'
]
dockerVersionProperties = [
'Version'
'ApiVersion'
]
module.exports =
signature: 'scan'
description: 'Scan for balenaOS devices in your local network'
help: '''
Examples:
$ balena scan
$ balena scan --timeout 120
$ balena scan --verbose
'''
options: [
signature: 'verbose'
boolean: true
description: 'Display full info'
alias: 'v'
,
signature: 'timeout'
parameter: 'timeout'
description: 'Scan timeout in seconds'
alias: 't'
]
primary: true
root: true
action: (params, options, done) ->
Promise = require('bluebird')
_ = require('lodash')
prettyjson = require('prettyjson')
{ discover } = require('balena-sync')
{ SpinnerPromise } = require('resin-cli-visuals')
{ dockerPort, dockerTimeout } = require('./local/common')
dockerUtils = require('../utils/docker')
{ exitWithExpectedError } = require('../utils/patterns')
if options.timeout?
options.timeout *= 1000
Promise.try ->
new SpinnerPromise
promise: discover.discoverLocalBalenaOsDevices(options.timeout)
startMessage: 'Scanning for local balenaOS devices..'
stopMessage: 'Reporting scan results'
.filter ({ address }) ->
Promise.try ->
docker = dockerUtils.createClient(host: address, port: dockerPort, timeout: dockerTimeout)
docker.pingAsync()
.return(true)
.catchReturn(false)
.tap (devices) ->
if _.isEmpty(devices)
exitWithExpectedError('Could not find any balenaOS devices in the local network')
.map ({ host, address }) ->
docker = dockerUtils.createClient(host: address, port: dockerPort, timeout: dockerTimeout)
Promise.props
dockerInfo: docker.infoAsync().catchReturn('Could not get Docker info')
dockerVersion: docker.versionAsync().catchReturn('Could not get Docker version')
.then ({ dockerInfo, dockerVersion }) ->
if not options.verbose
dockerInfo = _.pick(dockerInfo, dockerInfoProperties) if _.isObject(dockerInfo)
dockerVersion = _.pick(dockerVersion, dockerVersionProperties) if _.isObject(dockerVersion)
return { host, address, dockerInfo, dockerVersion }
.then (devicesInfo) ->
console.log(prettyjson.render(devicesInfo, noColor: true))
.nodeify(done)

39
lib/actions/settings.ts Normal file
View File

@ -0,0 +1,39 @@
/*
Copyright 2016-2017 Balena
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 { CommandDefinition } from 'capitano';
export const list: CommandDefinition = {
signature: 'settings',
description: 'print current settings',
help: `\
Use this command to display detected settings
Examples:
$ balena settings\
`,
async action(_params, _options, done) {
const balena = (await import('balena-sdk')).fromSharedOptions();
const prettyjson = await import('prettyjson');
return balena.settings
.getAll()
.then(prettyjson.render)
.then(console.log)
.nodeify(done);
},
};

372
lib/actions/ssh.ts Normal file
View File

@ -0,0 +1,372 @@
/*
Copyright 2016-2019 Balena
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 BalenaSdk from 'balena-sdk';
import { CommandDefinition } from 'capitano';
import { stripIndent } from 'common-tags';
import { BalenaDeviceNotFound } from 'balena-errors';
import { validateDotLocalUrl, validateIPAddress } from '../utils/validation';
async function getContainerId(
sdk: BalenaSdk.BalenaSDK,
uuid: string,
serviceName: string,
sshOpts: {
port?: number;
proxyCommand?: string;
proxyUrl: string;
username: string;
},
version?: string,
id?: number,
): Promise<string> {
const semver = await import('resin-semver');
if (version == null || id == null) {
const device = await sdk.models.device.get(uuid, {
$select: ['id', 'supervisor_version'],
});
version = device.supervisor_version;
id = device.id;
}
let containerId: string | undefined;
if (semver.gte(version, '8.6.0')) {
const apiUrl = await sdk.settings.get('apiUrl');
// TODO: Move this into the SDKs device model
const request = await sdk.request.send({
method: 'POST',
url: '/supervisor/v2/containerId',
baseUrl: apiUrl,
body: {
method: 'GET',
deviceId: id,
},
});
if (request.status !== 200) {
throw new Error(
`There was an error connecting to device ${uuid}, HTTP response code: ${
request.status
}.`,
);
}
const body = request.body;
if (body.status !== 'success') {
throw new Error(
`There was an error communicating with device ${uuid}.\n\tError: ${
body.message
}`,
);
}
containerId = body.services[serviceName];
} else {
console.log(stripIndent`
Using legacy method to detect container ID. This will be slow.
To speed up this process, please update your device to an OS
which has a supervisor version of at least v8.6.0.
`);
// We need to execute a balena ps command on the device,
// and parse the output, looking for a specific
// container
const { child_process } = await import('mz');
const escapeRegex = await import('lodash/escapeRegExp');
const { getSubShellCommand } = await import('../utils/helpers');
const { deviceContainerEngineBinary } = await import('../utils/device/ssh');
const command = generateVpnSshCommand({
uuid,
verbose: false,
port: sshOpts.port,
command: `host ${uuid} '"${deviceContainerEngineBinary}" ps --format "{{.ID}} {{.Names}}"'`,
proxyCommand: sshOpts.proxyCommand,
proxyUrl: sshOpts.proxyUrl,
username: sshOpts.username,
});
const subShellCommand = getSubShellCommand(command);
const subprocess = child_process.spawn(
subShellCommand.program,
subShellCommand.args,
{
stdio: [null, 'pipe', null],
},
);
const containers = await new Promise<string>((resolve, reject) => {
let output = '';
subprocess.stdout.on('data', chunk => (output += chunk.toString()));
subprocess.on('close', (code: number) => {
if (code !== 0) {
reject(
new Error(
`Non-zero error code when looking for service container: ${code}`,
),
);
} else {
resolve(output);
}
});
});
const lines = containers.split('\n');
const regex = new RegExp(`\\/?${escapeRegex(serviceName)}_\\d+_\\d+`);
for (const container of lines) {
const [cId, name] = container.split(' ');
if (regex.test(name)) {
containerId = cId;
break;
}
}
}
if (containerId == null) {
throw new Error(
`Could not find a service ${serviceName} on device ${uuid}.`,
);
}
return containerId;
}
function generateVpnSshCommand(opts: {
uuid: string;
command: string;
verbose: boolean;
port?: number;
username: string;
proxyUrl: string;
proxyCommand?: string;
}) {
return (
`ssh ${
opts.verbose ? '-vvv' : ''
} -t -o LogLevel=ERROR -o StrictHostKeyChecking=no ` +
`-o UserKnownHostsFile=/dev/null ` +
`${opts.proxyCommand != null ? opts.proxyCommand : ''} ` +
`${opts.port != null ? `-p ${opts.port}` : ''} ` +
`${opts.username}@ssh.${opts.proxyUrl} ${opts.command}`
);
}
export const ssh: CommandDefinition<
{
applicationOrDevice: string;
// when Capitano converts a positional parameter (but not an option)
// to a number, the original value is preserved with the _raw suffix
applicationOrDevice_raw: string;
serviceName?: string;
},
{
port: string;
service: string;
verbose: true | undefined;
noProxy: boolean;
}
> = {
signature: 'ssh <applicationOrDevice> [serviceName]',
description: 'SSH into the host or application container of a device',
primary: true,
help: stripIndent`
This command can be used to start a shell on a local or remote device.
If a service name is not provided, a shell will be opened on the host OS.
If an application name is provided, all online devices in the application
will be presented, and the chosen device will then have a shell opened on
in it's service container or host OS.
For local devices, the IP address and .local domain name are supported.
If the device is referenced by IP or \`.local\` address, the connection
is initiated directly to balenaOS on port \`22222\` via an
openssh-compatible client. Otherwise, any connection initiated remotely
traverses the balenaCloud VPN.
Examples:
balena ssh MyApp
balena ssh f49cefd
balena ssh f49cefd my-service
balena ssh f49cefd --port <port>
balena ssh 192.168.0.1 --verbose
balena ssh f49cefd.local my-service
Warning: \`balena ssh\` requires an openssh-compatible client to be correctly
installed in your shell environment. For more information (including Windows
support) please check:
https://github.com/balena-io/balena-cli/blob/master/INSTALL.md#additional-dependencies`,
options: [
{
signature: 'port',
parameter: 'port',
description: 'SSH gateway port',
alias: 'p',
},
{
signature: 'verbose',
boolean: true,
description: 'Increase verbosity',
alias: 'v',
},
{
signature: 'noproxy',
boolean: true,
description: stripIndent`
Don't use the proxy configuration for this connection. This flag
only make sense if you've configured a proxy globally.`,
},
],
action: async (params, options) => {
const applicationOrDevice =
params.applicationOrDevice_raw || params.applicationOrDevice;
const bash = await import('bash');
// TODO: Make this typed
const hasbin = require('hasbin');
const { getSubShellCommand } = await import('../utils/helpers');
const { child_process } = await import('mz');
const {
exitIfNotLoggedIn,
exitWithExpectedError,
getOnlineTargetUuid,
} = await import('../utils/patterns');
const sdk = BalenaSdk.fromSharedOptions();
const verbose = options.verbose === true;
// ugh TODO: Fix this
const proxyConfig = (global as any).PROXY_CONFIG;
const useProxy = !!proxyConfig && !options.noProxy;
const port = options.port != null ? parseInt(options.port, 10) : undefined;
// if we're doing a direct SSH connection locally...
if (
validateDotLocalUrl(applicationOrDevice) ||
validateIPAddress(applicationOrDevice)
) {
const { performLocalDeviceSSH } = await import('../utils/device/ssh');
return await performLocalDeviceSSH({
address: applicationOrDevice,
port,
verbose,
service: params.serviceName,
});
}
// this will be a tunnelled SSH connection...
await exitIfNotLoggedIn();
const uuid = await getOnlineTargetUuid(sdk, applicationOrDevice);
let version: string | undefined;
let id: number | undefined;
try {
const device = await sdk.models.device.get(uuid, {
$select: ['id', 'supervisor_version', 'is_online'],
});
id = device.id;
version = device.supervisor_version;
} catch (e) {
if (e instanceof BalenaDeviceNotFound) {
exitWithExpectedError(`Could not find device: ${uuid}`);
}
}
const [hasTunnelBin, username, proxyUrl] = await Promise.all([
useProxy ? await hasbin('proxytunnel') : undefined,
sdk.auth.whoami(),
sdk.settings.get('proxyUrl'),
]);
const getSshProxyCommand = () => {
if (!useProxy) {
return '';
}
if (!hasTunnelBin) {
console.warn(stripIndent`
Proxy is enabled but the \`proxytunnel\` binary cannot be found.
Please install it if you want to route the \`balena ssh\` requests through the proxy.
Alternatively you can pass \`--noproxy\` param to the \`balena ssh\` command to ignore the proxy config
for the \`ssh\` requests.
Attempting the unproxied request for now.`);
return '';
}
let tunnelOptions: Dictionary<string> = {
proxy: `${proxyConfig.host}:${proxyConfig.port}`,
dest: '%h:%p',
};
const { proxyAuth } = proxyConfig;
if (proxyAuth) {
const i = proxyAuth.indexOf(':');
tunnelOptions = {
user: proxyAuth.substring(0, i),
pass: proxyAuth.substring(i + 1),
...tunnelOptions,
};
}
const ProxyCommand = `proxytunnel ${bash.args(tunnelOptions, '--', '=')}`;
return `-o ${bash.args({ ProxyCommand }, '', '=')}`;
};
const proxyCommand = getSshProxyCommand();
if (username == null) {
exitWithExpectedError(
`Opening an SSH connection to a remote device requires you to be logged in.`,
);
}
// At this point, we have a long uuid with a device
// that we know exists and is accessible
let containerId: string | undefined;
if (params.serviceName != null) {
containerId = await getContainerId(
sdk,
uuid,
params.serviceName,
{
port,
proxyCommand,
proxyUrl,
username: username!,
},
version,
id,
);
}
let accessCommand: string;
if (containerId != null) {
accessCommand = `enter ${uuid} ${containerId}`;
} else {
accessCommand = `host ${uuid}`;
}
const command = generateVpnSshCommand({
uuid,
command: accessCommand,
verbose,
port,
proxyCommand,
proxyUrl,
username: username!,
});
const subShellCommand = getSubShellCommand(command);
await child_process.spawn(subShellCommand.program, subShellCommand.args, {
stdio: 'inherit',
});
},
};

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