@cityholdings/minicube-dev 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -2
- package/bin/minicube-dev.js +11 -0
- package/package.json +27 -4
- package/scripts/install.ps1 +13 -0
- package/scripts/install.sh +9 -0
- package/src/act.js +150 -0
- package/src/api.js +138 -0
- package/src/args.js +53 -0
- package/src/build.js +216 -0
- package/src/cli.js +432 -0
- package/src/config.js +126 -0
- package/src/device.js +73 -0
- package/src/kube.js +54 -0
- package/src/log.js +56 -0
- package/src/proc.js +91 -0
- package/src/workflow.js +113 -0
- package/src/zip.js +110 -0
package/README.md
CHANGED
|
@@ -1,3 +1,157 @@
|
|
|
1
|
-
#
|
|
1
|
+
# minicube-dev
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One command-line workflow for mPaaS **mini programs**, **H5 offline packages** and **Cube cards** —
|
|
4
|
+
from code to build, device preview and release, with no IDE or console UI.
|
|
5
|
+
|
|
6
|
+
| Step | Mini program / H5 offline package | Card |
|
|
7
|
+
| --- | --- | --- |
|
|
8
|
+
| Build | `minicube-dev build` | `minicube-dev build` (`cube-pack build` if installed, else `act build`) |
|
|
9
|
+
| Debug | `minicube-dev debug --device android` — uploads a debug package visible only to a whitelist and opens it on the device | `minicube-dev debug --device ios-sim` — pushed over the local ACT server, nothing is uploaded |
|
|
10
|
+
| Preview | `minicube-dev preview --device ios-sim` (latest debug package) or `minicube-dev preview '<citysuper://…>'` | same as debug |
|
|
11
|
+
| Release | `minicube-dev release --publish full` | `minicube-dev release --publish full` |
|
|
12
|
+
|
|
13
|
+
The package contains **no** server addresses, environments or credentials. They all live in one
|
|
14
|
+
configuration file on each developer's machine.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
Requires Node.js 18.17+ (macOS or Windows).
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g @cityholdings/minicube-dev
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Upgrade with the same command. Offline: build a tarball with `npm run pack` (written to `dist/`)
|
|
25
|
+
and run `scripts/install.sh` (macOS) / `scripts\install.ps1` (Windows).
|
|
26
|
+
|
|
27
|
+
### Publishing a new version (maintainers)
|
|
28
|
+
|
|
29
|
+
Copy `.npmrc.example` to `.npmrc` (git-ignored, never packed) and either set `NPM_TOKEN` or put
|
|
30
|
+
the token of the npm user `cityholdings` in place of `${NPM_TOKEN}`. Then:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm version patch
|
|
34
|
+
npm publish
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Optional tools, used only by the commands that need them: `adb` (Android preview), Xcode
|
|
38
|
+
command-line tools (`xcrun`, iOS simulator/device preview), `kubectl` (token administration or
|
|
39
|
+
`transport: "kube"`), `@antdigital/cube` in card projects, `cube-pack` (inline card images), and the
|
|
40
|
+
mini program compiler `mini-pkg-builder`.
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
`~/.minicube-dev/config.json` (Windows: `%USERPROFILE%\.minicube-dev\config.json`), or the file named
|
|
45
|
+
by `MINICUBE_DEV_CONFIG` / `--config`. Create a skeleton with `minicube-dev config init`:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"defaultEnv": "tst",
|
|
50
|
+
"environments": {
|
|
51
|
+
"tst": {
|
|
52
|
+
"baseUrl": "https://<tst gateway host>/core",
|
|
53
|
+
"token": "mcd_…",
|
|
54
|
+
"previewScheme": "citysuper",
|
|
55
|
+
"kube": { "context": "<kubectl context>", "namespace": "<namespace>", "service": "<mpaas-core service>", "port": 8301 }
|
|
56
|
+
},
|
|
57
|
+
"uat": { "baseUrl": "https://<uat gateway host>/core", "tokenEnv": "MINICUBE_TOKEN_UAT" }
|
|
58
|
+
},
|
|
59
|
+
"devices": {
|
|
60
|
+
"android": { "package": "<android application id>", "cubeConnectActivity": "<debug activity accepting cubeActHost/cubeActPort>" },
|
|
61
|
+
"ios": { "bundleId": "<bundle id, physical devices only>" }
|
|
62
|
+
},
|
|
63
|
+
"builders": { "mini": "/path/to/mini-pkg-builder" }
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
* `token` or `tokenEnv` (name of an environment variable holding it) per environment.
|
|
68
|
+
* Without `baseUrl` (or with `"transport": "kube"`) requests go through `kubectl port-forward`
|
|
69
|
+
using your local kubeconfig.
|
|
70
|
+
* `builders.mini` is the mini-pkg-builder executable (`mini-pkg-builder-macos-arm64`,
|
|
71
|
+
`mini-pkg-builder-win-x64.exe`, …) or its runtime `index.js`; `MINICUBE_MINI_BUILDER` overrides it.
|
|
72
|
+
|
|
73
|
+
`minicube-dev doctor` checks the toolchain and every environment's token.
|
|
74
|
+
|
|
75
|
+
## Tokens
|
|
76
|
+
|
|
77
|
+
The CLI does not use the IDE username/password. Each environment issues tokens bound to one mPaaS
|
|
78
|
+
app + workspace (e.g. `<appId>/default`); a token can reach every mini program, H5
|
|
79
|
+
offline package and card of that app and nothing else.
|
|
80
|
+
|
|
81
|
+
* 256-bit random value (`mcd_` + 43 characters), only its SHA-256 is stored server side.
|
|
82
|
+
* Valid 365 days by default (max 730); `minicube-dev whoami` warns 30 days before expiry.
|
|
83
|
+
* Rotation issues a new token and keeps the old one valid for a grace period (default 7 days).
|
|
84
|
+
|
|
85
|
+
Administration needs kubectl access to the environment — the endpoints only answer requests
|
|
86
|
+
arriving on the pod's loopback interface, never through the public gateway:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
minicube-dev admin token create --env tst --name "ci" --app <appId> [--days 365] [--save]
|
|
90
|
+
minicube-dev admin token list --env tst
|
|
91
|
+
minicube-dev admin token rotate --env tst <id> [--grace 7] [--save]
|
|
92
|
+
minicube-dev admin token revoke --env tst <id>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`--save` writes the new token into your config; otherwise hand it to the developer, who stores it
|
|
96
|
+
with `minicube-dev config set-token tst <token>`.
|
|
97
|
+
|
|
98
|
+
## Project file
|
|
99
|
+
|
|
100
|
+
`minicube.json` at the project root (`minicube-dev init` writes one):
|
|
101
|
+
|
|
102
|
+
```jsonc
|
|
103
|
+
// mini program (directory with app.json)
|
|
104
|
+
{ "type": "mini", "resourceId": "2025081500000001", "debug": { "whitelist": "my-team" } }
|
|
105
|
+
|
|
106
|
+
// H5 offline package: build command and output directory become <id>/www
|
|
107
|
+
{ "type": "h5", "resourceId": "70000000", "h5": { "buildCommand": "npm run build", "outputDir": "dist" },
|
|
108
|
+
"mainUrl": "/www/index.html", "debug": { "whitelist": "my-team" } }
|
|
109
|
+
|
|
110
|
+
// card (ACT v4 project)
|
|
111
|
+
{ "type": "card", "resourceId": "register-info-consent", "card": { "dir": "./card" } }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Optional: `version` (otherwise the next free version after the highest on the server; cards
|
|
115
|
+
default to `manifest.json`'s version), `mainUrl`, `clientScopes`
|
|
116
|
+
(`[{ "clientType": "iOS", "minVersion": "3.2.0" }]`, default iOS + Android without limits),
|
|
117
|
+
`enableTabBar`, `enableOptionMenu`, `expand`, `env`.
|
|
118
|
+
|
|
119
|
+
## Whitelists
|
|
120
|
+
|
|
121
|
+
Debug packages are delivered only to members (client user ids) of their whitelists, and only when
|
|
122
|
+
opened through a preview link.
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
minicube-dev whitelist list
|
|
126
|
+
minicube-dev whitelist create my-team --items 10001,10002
|
|
127
|
+
minicube-dev whitelist add <id> --items 10003
|
|
128
|
+
minicube-dev debug --whitelist my-team # or an id; repeatable
|
|
129
|
+
minicube-dev debug --whitelist-create qa --items 10001 # new whitelist for this upload
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Devices
|
|
133
|
+
|
|
134
|
+
`--device android | android:<serial> | ios-sim | ios-sim:<udid> | ios:<udid>`
|
|
135
|
+
|
|
136
|
+
* Mini program / H5 previews open `citysuper://platformapi/startapp?…` links (the mPaaS IDE
|
|
137
|
+
preview layout with the app scheme instead of `mpaas`). Only non-production builds accept them.
|
|
138
|
+
* Card previews attach the app to `act server` without scanning: Android starts the debug-only
|
|
139
|
+
connect activity (emulators get an `adb reverse` tunnel); the iOS simulator opens
|
|
140
|
+
`citysuper://cubedebug/connect?host=127.0.0.1&port=9001` (UAT target, which links CubeDev).
|
|
141
|
+
Physical phones scan the ACT QR code.
|
|
142
|
+
|
|
143
|
+
## Release
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
minicube-dev release --env tst # upload a release package (not yet visible)
|
|
147
|
+
minicube-dev publish <packageId> --env tst --mode full
|
|
148
|
+
minicube-dev release --env tst --publish full --yes # both in one go
|
|
149
|
+
minicube-dev publish <packageId> --mode whitelist --whitelist qa
|
|
150
|
+
minicube-dev publish <packageId> --mode timewindow --end 2026-11-30T00:00:00Z --count 500
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Full publishing asks for confirmation unless `--yes` is given.
|
|
154
|
+
|
|
155
|
+
## Commands
|
|
156
|
+
|
|
157
|
+
Run `minicube-dev help`. `--json` prints machine-readable output; `MINICUBE_DEBUG=1` shows stack traces.
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cityholdings/minicube-dev",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line workflow for mPaaS mini programs, H5 offline packages and Cube cards: build, debug upload, device preview and release",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"minicube-dev": "bin/minicube-dev.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"scripts/install.sh",
|
|
13
|
+
"scripts/install.ps1",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.17"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test",
|
|
21
|
+
"pack": "npm pack --pack-destination dist",
|
|
22
|
+
"prepublishOnly": "node --test"
|
|
23
|
+
},
|
|
24
|
+
"license": "UNLICENSED",
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public",
|
|
27
|
+
"registry": "https://registry.npmjs.org/"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Install minicube-dev globally from the tarball next to this script (or the one given).
|
|
2
|
+
param([string]$Tarball)
|
|
3
|
+
$ErrorActionPreference = 'Stop'
|
|
4
|
+
if (-not $Tarball) {
|
|
5
|
+
$Tarball = Get-ChildItem (Join-Path $PSScriptRoot '..\dist\*minicube-dev-*.tgz') -ErrorAction SilentlyContinue | Sort-Object Name | Select-Object -Last 1 -ExpandProperty FullName
|
|
6
|
+
}
|
|
7
|
+
if (-not $Tarball -or -not (Test-Path $Tarball)) { throw "minicube-dev tarball not found; run 'npm run pack' first or pass -Tarball" }
|
|
8
|
+
$major = [int]((node -p "process.versions.node").Split('.')[0])
|
|
9
|
+
if ($major -lt 18) { throw 'Node.js 18.17+ is required' }
|
|
10
|
+
npm install -g $Tarball
|
|
11
|
+
minicube-dev --version
|
|
12
|
+
$config = if ($env:MINICUBE_DEV_CONFIG) { $env:MINICUBE_DEV_CONFIG } else { Join-Path $env:USERPROFILE '.minicube-dev\config.json' }
|
|
13
|
+
if (-not (Test-Path $config)) { Write-Host 'Next: minicube-dev config init' }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Install minicube-dev globally from the tarball next to this script (or the one given).
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
tarball="${1:-$(ls "$(dirname "$0")"/../dist/*minicube-dev-*.tgz 2>/dev/null | tail -1)}"
|
|
5
|
+
[ -f "$tarball" ] || { echo "minicube-dev tarball not found; run 'npm run pack' first or pass its path"; exit 1; }
|
|
6
|
+
node -e 'process.exit(+process.versions.node.split(".")[0] >= 18 ? 0 : 1)' || { echo "Node.js 18.17+ is required"; exit 1; }
|
|
7
|
+
npm install -g "$tarball"
|
|
8
|
+
minicube-dev --version
|
|
9
|
+
[ -f "${MINICUBE_DEV_CONFIG:-$HOME/.minicube-dev/config.json}" ] || echo "Next: minicube-dev config init"
|
package/src/act.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { mkdirSync, openSync } from 'node:fs';
|
|
2
|
+
import { createConnection } from 'node:net';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { cardDir, resolveAct } from './build.js';
|
|
6
|
+
import { adb, androidSerial } from './device.js';
|
|
7
|
+
import { CliError, log } from './log.js';
|
|
8
|
+
import { delay, run, runSync, spawnDetached } from './proc.js';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_PORT = 9001;
|
|
11
|
+
const CONNECT_TIMEOUT_MS = 45_000;
|
|
12
|
+
const SETTLE_MS = 2_000;
|
|
13
|
+
|
|
14
|
+
function isListening(port) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const socket = createConnection({ host: '127.0.0.1', port });
|
|
17
|
+
const done = (ok) => {
|
|
18
|
+
socket.destroy();
|
|
19
|
+
resolve(ok);
|
|
20
|
+
};
|
|
21
|
+
socket.setTimeout(1000);
|
|
22
|
+
socket.once('connect', () => done(true));
|
|
23
|
+
socket.once('error', () => done(false));
|
|
24
|
+
socket.once('timeout', () => done(false));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function getJson(port, route) {
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch(`http://127.0.0.1:${port}${route}`, { signal: AbortSignal.timeout(3000) });
|
|
31
|
+
return response.ok ? await response.json() : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function attachedDevices(port) {
|
|
38
|
+
const body = await getJson(port, '/api/device/list');
|
|
39
|
+
return Array.isArray(body?.data) ? body.data : [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The address a device should use for the ACT server, which also ends up in every template file
|
|
44
|
+
* URL the server hands out. Tunnelled targets (Android over adb reverse, the iOS simulator) use
|
|
45
|
+
* loopback: it works behind on-device VPNs, which capture an app's LAN traffic.
|
|
46
|
+
*/
|
|
47
|
+
export function advertisedHost(target) {
|
|
48
|
+
if (target?.kind === 'android' || target?.kind === 'ios-sim') return '127.0.0.1';
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* ACT v4 `act server` is left running so the device connection survives between previews. `host`
|
|
54
|
+
* is the address it advertises (HOST env of the ACT server); a running server advertising another
|
|
55
|
+
* address is restarted.
|
|
56
|
+
*/
|
|
57
|
+
export async function ensureActServer(project, port = DEFAULT_PORT, host = null) {
|
|
58
|
+
const act = resolveAct(project.root);
|
|
59
|
+
if (!act) throw new CliError('ACT not found', 'Install it in the card project: npm i -D @antdigital/cube');
|
|
60
|
+
if (await isListening(port)) {
|
|
61
|
+
const current = (await serverAddress(port)).host;
|
|
62
|
+
if (!host || current === host) {
|
|
63
|
+
log.detail(`Reusing the ACT server on port ${port} (${current})`);
|
|
64
|
+
return act;
|
|
65
|
+
}
|
|
66
|
+
log.detail(`Restarting the ACT server: it advertises ${current}, this device needs ${host}`);
|
|
67
|
+
await run(act, ['prepare'], { cwd: project.root, capture: true });
|
|
68
|
+
const freed = Date.now() + 10_000;
|
|
69
|
+
while ((await isListening(port)) && Date.now() < freed) await delay(300);
|
|
70
|
+
}
|
|
71
|
+
mkdirSync(path.join(project.root, '.minicube'), { recursive: true });
|
|
72
|
+
const logFile = path.join(project.root, '.minicube', 'act-server.log');
|
|
73
|
+
spawnDetached(act, ['server'], { cwd: project.root, logFd: openSync(logFile, 'a'), env: host ? { HOST: host } : undefined });
|
|
74
|
+
const deadline = Date.now() + 20_000;
|
|
75
|
+
while (Date.now() < deadline) {
|
|
76
|
+
await delay(500);
|
|
77
|
+
if (await isListening(port)) {
|
|
78
|
+
log.detail(`ACT server started on port ${port} (log: ${path.relative(project.root, logFile)})`);
|
|
79
|
+
return act;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
throw new CliError(`ACT server did not start on port ${port}`, `See ${logFile}; free the port with \`act prepare\`.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function lanAddress() {
|
|
86
|
+
for (const addresses of Object.values(os.networkInterfaces())) {
|
|
87
|
+
for (const a of addresses ?? []) if (a.family === 'IPv4' && !a.internal) return a.address;
|
|
88
|
+
}
|
|
89
|
+
return '127.0.0.1';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** host/port the server advertises in its QR code, e.g. alipays://...?ip=192.168.1.5&port=9001 */
|
|
93
|
+
async function serverAddress(port) {
|
|
94
|
+
const body = await getJson(port, '/api/server/qrvalue');
|
|
95
|
+
const value = typeof body?.data === 'string' ? body.data : '';
|
|
96
|
+
const match = value.match(/[?&](?:ip|host)=([^&\s]+)[\s\S]*?[?&]port=(\d{2,5})/);
|
|
97
|
+
return match ? { host: decodeURIComponent(match[1]), port: match[2] } : { host: lanAddress(), port: String(port) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Attach the device to the ACT server without scanning:
|
|
102
|
+
* - Android: start the debug-only connect activity with cubeActHost/cubeActPort extras, over an
|
|
103
|
+
* adb reverse tunnel to 127.0.0.1 (USB devices and emulators alike)
|
|
104
|
+
* - iOS simulator: open <scheme>://cubedebug/connect?host=&port= (UAT builds with CubeDev)
|
|
105
|
+
*/
|
|
106
|
+
export async function attachDevice(target, env, devices, port = DEFAULT_PORT) {
|
|
107
|
+
const before = (await attachedDevices(port)).length;
|
|
108
|
+
const address = await serverAddress(port);
|
|
109
|
+
if (target.kind === 'android') {
|
|
110
|
+
const serial = androidSerial(target);
|
|
111
|
+
const pkg = devices.android?.package;
|
|
112
|
+
const activity = devices.android?.cubeConnectActivity;
|
|
113
|
+
if (!pkg || !activity || pkg.includes('<') || activity.includes('<')) {
|
|
114
|
+
throw new CliError('devices.android.package and devices.android.cubeConnectActivity must be configured for card preview');
|
|
115
|
+
}
|
|
116
|
+
adb(serial, ['reverse', `tcp:${address.port}`, `tcp:${address.port}`]);
|
|
117
|
+
const host = '127.0.0.1';
|
|
118
|
+
adb(serial, ['shell', 'am', 'force-stop', pkg]);
|
|
119
|
+
const component = activity.startsWith('.') ? `${pkg}/${pkg}${activity}` : `${pkg}/${activity}`;
|
|
120
|
+
adb(serial, ['shell', 'am', 'start', '-n', component, '--es', 'cubeActHost', host, '--es', 'cubeActPort', address.port]);
|
|
121
|
+
log.detail(`Android ${serial} -> ${host}:${address.port}`);
|
|
122
|
+
} else if (target.kind === 'ios-sim') {
|
|
123
|
+
const scheme = env?.previewScheme ?? 'citysuper';
|
|
124
|
+
// The simulator shares the Mac's network stack, so loopback reaches the ACT server.
|
|
125
|
+
const url = `${scheme}://cubedebug/connect?host=127.0.0.1&port=${address.port}`;
|
|
126
|
+
const result = runSync('xcrun', ['simctl', 'openurl', target.id, url]);
|
|
127
|
+
if (result.code !== 0) throw new CliError(`simctl openurl failed: ${result.stderr.trim()}`);
|
|
128
|
+
log.detail(`iOS simulator -> 127.0.0.1:${address.port}`);
|
|
129
|
+
} else {
|
|
130
|
+
log.info(`Scan the ACT QR code from the app, or connect the phone to ${address.host}:${address.port}`);
|
|
131
|
+
}
|
|
132
|
+
const deadline = Date.now() + CONNECT_TIMEOUT_MS;
|
|
133
|
+
while (Date.now() < deadline) {
|
|
134
|
+
if ((await attachedDevices(port)).length > before) {
|
|
135
|
+
// The websocket reports attached before the host's preview bridge is ready on a cold start.
|
|
136
|
+
await delay(SETTLE_MS);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
await delay(1000);
|
|
140
|
+
}
|
|
141
|
+
if ((await attachedDevices(port)).length === 0) {
|
|
142
|
+
throw new CliError('The app did not attach to the ACT server', 'The host app must be a debug/UAT build with Cube debugging enabled, and the device must reach this machine.');
|
|
143
|
+
}
|
|
144
|
+
log.warn('No new attachment observed; delivering to the devices already attached');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function deliverCard(project, act) {
|
|
148
|
+
const result = await run(act, ['preview', cardDir(project)], { cwd: project.root });
|
|
149
|
+
if (result.code !== 0) throw new CliError(`act preview failed (exit ${result.code})`);
|
|
150
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CliError } from './log.js';
|
|
4
|
+
import { openPortForward } from './kube.js';
|
|
5
|
+
|
|
6
|
+
export const TOKEN_HEADER = 'X-Minicube-Token';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Client for the mpaas-core `/cli/v1` API. The environment reaches the server either through its
|
|
10
|
+
* public `baseUrl` or, with `transport: "kube"` (or no baseUrl), through a kubectl port-forward.
|
|
11
|
+
*/
|
|
12
|
+
export async function connect(env) {
|
|
13
|
+
if (!env.token) {
|
|
14
|
+
throw new CliError(`No token configured for environment "${env.name}"`, 'Ask an administrator for a token (minicube-dev admin token create) and put it in the config as "token" or "tokenEnv".');
|
|
15
|
+
}
|
|
16
|
+
let baseUrl = env.baseUrl && !String(env.baseUrl).includes('<') ? String(env.baseUrl).replace(/\/+$/, '') : null;
|
|
17
|
+
let tunnel = null;
|
|
18
|
+
if (env.transport === 'kube' || !baseUrl) {
|
|
19
|
+
tunnel = await openPortForward(env.kube);
|
|
20
|
+
baseUrl = tunnel.baseUrl;
|
|
21
|
+
}
|
|
22
|
+
return new ApiClient(`${baseUrl}/cli/v1`, env.token, () => tunnel?.close());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ApiClient {
|
|
26
|
+
constructor(root, token, onClose) {
|
|
27
|
+
this.root = root;
|
|
28
|
+
this.token = token;
|
|
29
|
+
this.onClose = onClose;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
close() {
|
|
33
|
+
this.onClose?.();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async request(method, route, { json, form, query } = {}) {
|
|
37
|
+
const url = new URL(this.root + route);
|
|
38
|
+
for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
|
|
39
|
+
const headers = { [TOKEN_HEADER]: this.token, Accept: 'application/json' };
|
|
40
|
+
let body;
|
|
41
|
+
if (json !== undefined) {
|
|
42
|
+
headers['Content-Type'] = 'application/json';
|
|
43
|
+
body = JSON.stringify(json);
|
|
44
|
+
} else if (form) {
|
|
45
|
+
body = form;
|
|
46
|
+
}
|
|
47
|
+
let response;
|
|
48
|
+
try {
|
|
49
|
+
response = await fetch(url, { method, headers, body, signal: AbortSignal.timeout(10 * 60_000) });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw new CliError(`Request to ${url.origin} failed: ${error.cause?.message ?? error.message}`);
|
|
52
|
+
}
|
|
53
|
+
const text = await response.text();
|
|
54
|
+
let payload;
|
|
55
|
+
try {
|
|
56
|
+
payload = JSON.parse(text);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new CliError(`Unexpected response from server (HTTP ${response.status}): ${text.slice(0, 300)}`);
|
|
59
|
+
}
|
|
60
|
+
if (response.status === 401 || payload.code === 4010) {
|
|
61
|
+
throw new CliError('The server rejected the CLI token (invalid, expired or revoked)', 'Check `minicube-dev token status`, or ask an administrator to rotate it.');
|
|
62
|
+
}
|
|
63
|
+
if (payload.code !== 1000) {
|
|
64
|
+
throw new CliError(`Server error ${payload.code ?? response.status}: ${payload.msg ?? payload.error ?? text.slice(0, 300)}`);
|
|
65
|
+
}
|
|
66
|
+
return payload.data;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
whoami() {
|
|
70
|
+
return this.request('GET', '/whoami');
|
|
71
|
+
}
|
|
72
|
+
resources(type) {
|
|
73
|
+
return this.request('GET', '/resources', { query: { type } });
|
|
74
|
+
}
|
|
75
|
+
resource(id) {
|
|
76
|
+
return this.request('GET', `/resources/${encodeURIComponent(id)}`);
|
|
77
|
+
}
|
|
78
|
+
packages(resourceId, limit = 20) {
|
|
79
|
+
return this.request('GET', `/resources/${encodeURIComponent(resourceId)}/packages`, { query: { limit } });
|
|
80
|
+
}
|
|
81
|
+
pkg(id) {
|
|
82
|
+
return this.request('GET', `/packages/${id}`);
|
|
83
|
+
}
|
|
84
|
+
upload(meta, file) {
|
|
85
|
+
const form = new FormData();
|
|
86
|
+
form.set('meta', JSON.stringify(meta));
|
|
87
|
+
form.set('file', new Blob([readFileSync(file)], { type: 'application/zip' }), path.basename(file));
|
|
88
|
+
return this.request('POST', '/packages', { form });
|
|
89
|
+
}
|
|
90
|
+
publish(id, body) {
|
|
91
|
+
return this.request('POST', `/packages/${id}/publish`, { json: body });
|
|
92
|
+
}
|
|
93
|
+
whitelists() {
|
|
94
|
+
return this.request('GET', '/whitelists');
|
|
95
|
+
}
|
|
96
|
+
whitelist(id) {
|
|
97
|
+
return this.request('GET', `/whitelists/${id}`);
|
|
98
|
+
}
|
|
99
|
+
createWhitelist(name, items) {
|
|
100
|
+
return this.request('POST', '/whitelists', { json: { name, items } });
|
|
101
|
+
}
|
|
102
|
+
addWhitelistItems(id, items) {
|
|
103
|
+
return this.request('POST', `/whitelists/${id}/items`, { json: { items } });
|
|
104
|
+
}
|
|
105
|
+
removeWhitelistItems(id, items) {
|
|
106
|
+
return this.request('POST', `/whitelists/${id}/items/remove`, { json: { items } });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Admin client: token management through a kubectl port-forward only. */
|
|
111
|
+
export async function connectAdmin(env) {
|
|
112
|
+
const tunnel = await openPortForward(env.kube);
|
|
113
|
+
const root = `${tunnel.baseUrl}/cli/admin/tokens`;
|
|
114
|
+
const call = async (method, route = '', json) => {
|
|
115
|
+
const response = await fetch(root + route, {
|
|
116
|
+
method,
|
|
117
|
+
headers: json ? { 'Content-Type': 'application/json' } : {},
|
|
118
|
+
body: json ? JSON.stringify(json) : undefined,
|
|
119
|
+
signal: AbortSignal.timeout(60_000),
|
|
120
|
+
});
|
|
121
|
+
const text = await response.text();
|
|
122
|
+
let payload;
|
|
123
|
+
try {
|
|
124
|
+
payload = JSON.parse(text);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new CliError(`Unexpected admin response (HTTP ${response.status}): ${text.slice(0, 300)}`, 'Is the minicube-dev backend deployed to this environment?');
|
|
127
|
+
}
|
|
128
|
+
if (payload.code !== 1000) throw new CliError(`Server error ${payload.code}: ${payload.msg}`);
|
|
129
|
+
return payload.data;
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
list: (appId) => call('GET', appId ? `?appId=${encodeURIComponent(appId)}` : ''),
|
|
133
|
+
issue: (body) => call('POST', '', body),
|
|
134
|
+
rotate: (id, body) => call('POST', `/${id}/rotate`, body),
|
|
135
|
+
revoke: (id, body) => call('POST', `/${id}/revoke`, body),
|
|
136
|
+
close: () => tunnel.close(),
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/args.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal argv parser: positionals, `--flag`, `--key value`, `--key=value`, `-k value`, `--no-flag`.
|
|
3
|
+
* Repeated keys collect into arrays.
|
|
4
|
+
*/
|
|
5
|
+
const ALIASES = { e: 'env', c: 'config', d: 'device', v: 'version', w: 'whitelist', y: 'yes', h: 'help', t: 'type', p: 'project' };
|
|
6
|
+
const BOOLEAN = new Set(['help', 'yes', 'json', 'quiet', 'no-build', 'open', 'remote', 'save', 'force', 'skip-build', 'dry-run']);
|
|
7
|
+
|
|
8
|
+
export function parseArgs(argv) {
|
|
9
|
+
const positionals = [];
|
|
10
|
+
const flags = {};
|
|
11
|
+
const set = (key, value) => {
|
|
12
|
+
if (key in flags) flags[key] = [].concat(flags[key], value);
|
|
13
|
+
else flags[key] = value;
|
|
14
|
+
};
|
|
15
|
+
for (let i = 0; i < argv.length; i++) {
|
|
16
|
+
const arg = argv[i];
|
|
17
|
+
if (arg === '--') {
|
|
18
|
+
positionals.push(...argv.slice(i + 1));
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
if (arg.startsWith('--')) {
|
|
22
|
+
const eq = arg.indexOf('=');
|
|
23
|
+
let key = eq > 0 ? arg.slice(2, eq) : arg.slice(2);
|
|
24
|
+
if (eq > 0) {
|
|
25
|
+
set(key, arg.slice(eq + 1));
|
|
26
|
+
} else if (key.startsWith('no-') && !BOOLEAN.has(key)) {
|
|
27
|
+
set(key.slice(3), false);
|
|
28
|
+
} else if (BOOLEAN.has(key) || i + 1 >= argv.length || argv[i + 1].startsWith('-')) {
|
|
29
|
+
set(key, true);
|
|
30
|
+
} else {
|
|
31
|
+
set(key, argv[++i]);
|
|
32
|
+
}
|
|
33
|
+
} else if (arg.startsWith('-') && arg.length === 2) {
|
|
34
|
+
const key = ALIASES[arg[1]] ?? arg[1];
|
|
35
|
+
if (BOOLEAN.has(key) || i + 1 >= argv.length || argv[i + 1].startsWith('-')) set(key, true);
|
|
36
|
+
else set(key, argv[++i]);
|
|
37
|
+
} else {
|
|
38
|
+
positionals.push(arg);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { positionals, flags };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Last value of a flag that may have been repeated. */
|
|
45
|
+
export function one(value) {
|
|
46
|
+
return Array.isArray(value) ? value[value.length - 1] : value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Comma separated and/or repeated flag values as a flat list. */
|
|
50
|
+
export function list(value) {
|
|
51
|
+
if (value === undefined || value === true || value === false) return [];
|
|
52
|
+
return [].concat(value).flatMap((v) => String(v).split(',')).map((v) => v.trim()).filter(Boolean);
|
|
53
|
+
}
|