jumbo-toast-cli 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/LICENSE +21 -0
- package/README.md +75 -2
- package/bin/jumbo.mjs +9 -0
- package/package.json +41 -4
- package/src/capabilities.mjs +62 -0
- package/src/core.mjs +576 -0
- package/src/credentials.mjs +185 -0
- package/src/errors.mjs +29 -0
- package/src/http.mjs +181 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jumbo Toast contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,76 @@
|
|
|
1
|
-
|
|
1
|
+
<!-- INPUT: npm package jumbo-toast-cli, Node.js 22+, protected Jumbo Toast API and operator credentials. -->
|
|
2
|
+
<!-- OUTPUT: Standalone installation, login, query and controlled-update instructions without requiring repository files. -->
|
|
3
|
+
<!-- POS: Public npm package README; downloading the client does not grant backend permissions. -->
|
|
4
|
+
<!-- PROTOCOL: Sync package.json and docs/operations/JUMBO_CLI_FEISHU.md when install, auth or safety boundaries change. -->
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
# Jumbo Toast CLI
|
|
7
|
+
|
|
8
|
+
`jumbo` 是 Jumbo Toast 后台的命令行客户端,供授权运营人员或 AI 查询数据、预览并执行受控更新。需要 Node.js 22 或更高版本。
|
|
9
|
+
|
|
10
|
+
## 安装与更新
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install -g jumbo-toast-cli@latest
|
|
14
|
+
jumbo --version
|
|
15
|
+
jumbo help
|
|
16
|
+
jumbo capabilities
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
更新使用同一条安装命令;本机保存的 profile 不在安装目录内,升级时会保留。
|
|
20
|
+
|
|
21
|
+
## 登录
|
|
22
|
+
|
|
23
|
+
把地址换成实际后台 origin。远程地址必须 HTTPS;本机开发允许 localhost/127.0.0.1 HTTP。
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
jumbo --profile production --base-url https://your-backend.example \
|
|
27
|
+
auth login --email operator@example.com
|
|
28
|
+
|
|
29
|
+
jumbo --profile production doctor
|
|
30
|
+
jumbo --profile production auth whoami
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
交互终端会隐藏密码输入。自动化可使用 `--password-stdin`、`--password-env ENV_NAME` 或仓库外 `0600` 权限的 `--password-file`。禁止把密码或 token 写到命令参数中。
|
|
34
|
+
|
|
35
|
+
profile 默认保存在 `~/.config/jumbo-toast/credentials.json`,目录权限 `0700`、文件权限 `0600`;token 只发给其绑定的后台地址。`auth logout` 删除本机 profile,不撤销其他已签发的服务端 token。
|
|
36
|
+
|
|
37
|
+
## 查询
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
jumbo --profile production products list --status on --page-size 20
|
|
41
|
+
jumbo --profile production orders list --status active --limit 20
|
|
42
|
+
jumbo --profile production stores list
|
|
43
|
+
jumbo --profile production inventory alerts --threshold 10
|
|
44
|
+
jumbo --profile production business daily --days 7
|
|
45
|
+
jumbo --profile production reminders status
|
|
46
|
+
jumbo --profile production reminders history --limit 20
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
输出为 JSON,权限由服务器校验,金额单位为整数分。用 `jumbo <command> --help` 查看参数。
|
|
50
|
+
|
|
51
|
+
## 受控更新
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
jumbo --profile production control export \
|
|
55
|
+
--kind product --id YOUR_PRODUCT_ID --out ./product.json
|
|
56
|
+
|
|
57
|
+
# 编辑 product.json 的 patch 字段,再预览
|
|
58
|
+
jumbo --profile production control preview --file ./product.json
|
|
59
|
+
|
|
60
|
+
jumbo --profile production control apply --file ./product.json \
|
|
61
|
+
--reason "更新商品说明" --idempotency-key "product-copy-20261009-0001" --apply
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
可写种类:`product`、`store-availability`、`store-hours`、`home-bulletins`、`banner`、`feishu-rules`。商品写入限内容、上下架和排序;没有价格、库存数量、付款、退款、储值、角色权限或任意 URL 写入口。
|
|
65
|
+
|
|
66
|
+
服务器校验 revision、权限、原因和幂等键;CLI 在成功回执后必须回读核对。409 时重新导出并检查差异;`APPLY_RESULT_UNKNOWN` 时使用同一文件、原因和幂等键重试;`READBACK_FAILED` 表示不能确认最终状态。
|
|
67
|
+
|
|
68
|
+
## 飞书与后端兼容性
|
|
69
|
+
|
|
70
|
+
安装 CLI 不会部署后台或启动自动提醒。目标后台必须部署兼容的 control-plane/reminder API;旧后台可能返回 404。飞书 Webhook/签名密钥仅配置在服务器,CLI 只查询状态或修改业务提醒规则,不持有机器人密钥,也没有本地常驻转发命令。
|
|
71
|
+
|
|
72
|
+
默认关闭的后台通知任务负责新订单、低库存与每日摘要。真实群消息送达需在服务器配置后单独验证;客户端安装成功不等于飞书已绑定。
|
|
73
|
+
|
|
74
|
+
## 许可
|
|
75
|
+
|
|
76
|
+
客户端代码采用 MIT,见包内 LICENSE。包不包含门店品牌图片、数据库、`.env`、profile 或后台源码。
|
package/bin/jumbo.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// INPUT: Process argv/env/stdin/stdout and the Jumbo Toast CLI core.
|
|
3
|
+
// OUTPUT: One structured JSON command result and a stable process exit code.
|
|
4
|
+
// POS: Executable `jumbo` bin; all parsing, networking and credential policy stays in src/core.mjs.
|
|
5
|
+
// PROTOCOL: Sync ../package.json and ../src/core.mjs when executable invocation changes.
|
|
6
|
+
|
|
7
|
+
import { runCli } from '../src/core.mjs';
|
|
8
|
+
|
|
9
|
+
process.exitCode = await runCli();
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jumbo-toast-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"jumbo": "./bin/jumbo.mjs"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"check": "node --check bin/jumbo.mjs && node --check src/errors.mjs && node --check src/credentials.mjs && node --check src/http.mjs && node --check src/capabilities.mjs && node --check src/core.mjs",
|
|
11
|
+
"test": "node --test test/*.test.mjs",
|
|
12
|
+
"prepublishOnly": "npm run check && npm test"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22"
|
|
16
|
+
},
|
|
17
|
+
"description": "Jumbo Toast 后台的受控 HTTP 运维 CLI",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/wlct517/Jumbotoast.git",
|
|
22
|
+
"directory": "apps/cli"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"jumbotoast",
|
|
26
|
+
"admin",
|
|
27
|
+
"cli"
|
|
28
|
+
],
|
|
29
|
+
"files": [
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE",
|
|
32
|
+
"bin/jumbo.mjs",
|
|
33
|
+
"src/errors.mjs",
|
|
34
|
+
"src/credentials.mjs",
|
|
35
|
+
"src/http.mjs",
|
|
36
|
+
"src/capabilities.mjs",
|
|
37
|
+
"src/core.mjs"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"registry": "https://registry.npmjs.org/"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// INPUT: Authorized CLI scope, real API filters/RBAC and narrow control-plane resource kinds.
|
|
2
|
+
// OUTPUT: Immutable command metadata used by help/capabilities and checked alongside dispatch options.
|
|
3
|
+
// POS: Human/machine discovery contract; listing a capability never grants its server permission.
|
|
4
|
+
// PROTOCOL: Sync core.mjs, apps/api/src/routes/control-plane.ts and docs/operations/JUMBO_CLI_FEISHU.md when commands, filters or permissions change.
|
|
5
|
+
|
|
6
|
+
export const CLI_VERSION = '0.1.0';
|
|
7
|
+
|
|
8
|
+
export const CONTROL_KINDS = Object.freeze([
|
|
9
|
+
'product',
|
|
10
|
+
'store-availability',
|
|
11
|
+
'store-hours',
|
|
12
|
+
'home-bulletins',
|
|
13
|
+
'banner',
|
|
14
|
+
'feishu-rules',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export const CLI_CAPABILITIES = Object.freeze([
|
|
18
|
+
{ command: 'version', mode: 'local', permission: null, options: [] },
|
|
19
|
+
{ command: 'auth login', mode: 'auth', permission: null, options: ['email', 'password-stdin', 'password-file', 'password-env'] },
|
|
20
|
+
{ command: 'auth whoami', mode: 'read', permission: 'active staff', options: [] },
|
|
21
|
+
{ command: 'auth logout', mode: 'local', permission: null, options: [] },
|
|
22
|
+
{ command: 'doctor', mode: 'read', permission: null, options: [] },
|
|
23
|
+
{ command: 'capabilities', mode: 'local', permission: null, options: [] },
|
|
24
|
+
{ command: 'catalog show', mode: 'read', permission: 'orders.read', options: [] },
|
|
25
|
+
{ command: 'products list', mode: 'read', permission: 'orders.read', options: ['store-id', 'status', 'q', 'page', 'page-size'] },
|
|
26
|
+
{ command: 'products get', mode: 'read', permission: 'orders.read', options: ['<id>'] },
|
|
27
|
+
{ command: 'orders list', mode: 'read', permission: 'orders.read', options: ['status', 'store-id', 'q', 'limit', 'cursor'] },
|
|
28
|
+
{ command: 'orders get', mode: 'read', permission: 'orders.read', options: ['<id>'] },
|
|
29
|
+
{ command: 'stores list', mode: 'read', permission: 'stores.read', options: [] },
|
|
30
|
+
{ command: 'store status', mode: 'read', permission: 'stores.read', options: ['<store-id>'] },
|
|
31
|
+
{ command: 'inventory alerts', mode: 'read', permission: 'dashboard.read', options: ['store-id', 'threshold'] },
|
|
32
|
+
{ command: 'business summary', mode: 'read', permission: 'dashboard.read', options: [] },
|
|
33
|
+
{ command: 'business daily', mode: 'read', permission: 'analytics.read', options: ['days'] },
|
|
34
|
+
{ command: 'reminders status', mode: 'read', permission: 'settings.read', options: [] },
|
|
35
|
+
{ command: 'reminders history', mode: 'read', permission: 'settings.read', options: ['limit'] },
|
|
36
|
+
{ command: 'control export', mode: 'local-file/read', permission: 'per-kind write permission', options: ['kind', 'id', 'out'] },
|
|
37
|
+
{ command: 'control preview', mode: 'dry-run', permission: 'per-kind write permission', options: ['file'] },
|
|
38
|
+
{ command: 'control apply', mode: 'controlled-write', permission: 'per-kind write permission', options: ['file', 'reason', 'idempotency-key', 'apply'] },
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
export const HELP = Object.freeze({
|
|
42
|
+
name: 'jumbo',
|
|
43
|
+
version: CLI_VERSION,
|
|
44
|
+
globalOptions: {
|
|
45
|
+
profile: 'Local profile name (default: default)',
|
|
46
|
+
'base-url': 'Loopback HTTP or remote HTTPS origin; stored tokens remain origin-bound',
|
|
47
|
+
'timeout-ms': '500-30000, default 15000',
|
|
48
|
+
pretty: 'Pretty-print the JSON envelope',
|
|
49
|
+
},
|
|
50
|
+
authSafety: {
|
|
51
|
+
forbiddenArgv: ['password', 'token'],
|
|
52
|
+
passwordSources: ['--password-stdin', '--password-file <0600 file>', '--password-env <name>', 'hidden TTY prompt'],
|
|
53
|
+
},
|
|
54
|
+
controlKinds: CONTROL_KINDS,
|
|
55
|
+
examples: [
|
|
56
|
+
'jumbo --base-url http://127.0.0.1:8787 auth login --email admin@jumbotoast.local --password-stdin',
|
|
57
|
+
'jumbo orders list --status active --limit 20',
|
|
58
|
+
'jumbo control export --kind product --id p_club --out ./product.json',
|
|
59
|
+
'jumbo control preview --file ./product.json',
|
|
60
|
+
'jumbo control apply --file ./product.json --reason "更新商品文案" --idempotency-key product-copy-20261009-0001 --apply',
|
|
61
|
+
],
|
|
62
|
+
});
|
package/src/core.mjs
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
// INPUT: Process argv/env/stdin (including pnpm's leading -- separator), fixed capability metadata, secure profile store and bounded JSON HTTP client.
|
|
2
|
+
// OUTPUT: runCli/executeCommand with stable JSON envelopes for secret-safe auth, diagnostics, real filtered reads, and file preview/apply/read-back verification.
|
|
3
|
+
// POS: `jumbo` command dispatcher; it cannot address arbitrary API paths, databases, payment writes, deployments or Feishu test sends.
|
|
4
|
+
// PROTOCOL: Sync capabilities.mjs, apps/api/src/routes/control-plane.ts, tests and docs/operations/JUMBO_CLI_FEISHU.md when commands/options/output change.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { createCredentialStore, readSecretFile, validateProfileName } from './credentials.mjs';
|
|
9
|
+
import { CLI_CAPABILITIES, CLI_VERSION, CONTROL_KINDS, HELP } from './capabilities.mjs';
|
|
10
|
+
import { asCliError, CliError, EXIT } from './errors.mjs';
|
|
11
|
+
import { normalizeBaseUrl, requestJson, resolveProfileTarget } from './http.mjs';
|
|
12
|
+
|
|
13
|
+
const BOOLEAN_OPTIONS = new Set(['pretty', 'apply', 'password-stdin', 'help', 'version']);
|
|
14
|
+
const GLOBAL_OPTIONS = new Set(['profile', 'base-url', 'timeout-ms', 'pretty', 'help', 'version']);
|
|
15
|
+
const COMMAND_OPTIONS = Object.freeze({
|
|
16
|
+
help: [],
|
|
17
|
+
version: [],
|
|
18
|
+
capabilities: [],
|
|
19
|
+
doctor: [],
|
|
20
|
+
'auth login': ['email', 'password-stdin', 'password-file', 'password-env'],
|
|
21
|
+
'auth whoami': [],
|
|
22
|
+
'auth logout': [],
|
|
23
|
+
'catalog show': [],
|
|
24
|
+
'products list': ['store-id', 'status', 'q', 'page', 'page-size'],
|
|
25
|
+
'products get': [],
|
|
26
|
+
'orders list': ['status', 'store-id', 'q', 'limit', 'cursor'],
|
|
27
|
+
'orders get': [],
|
|
28
|
+
'stores list': [],
|
|
29
|
+
'store status': [],
|
|
30
|
+
'inventory alerts': ['store-id', 'threshold'],
|
|
31
|
+
'business summary': [],
|
|
32
|
+
'business daily': ['days'],
|
|
33
|
+
'reminders status': [],
|
|
34
|
+
'reminders history': ['limit'],
|
|
35
|
+
'control export': ['kind', 'id', 'out'],
|
|
36
|
+
'control preview': ['file'],
|
|
37
|
+
'control apply': ['file', 'reason', 'idempotency-key', 'apply'],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const ORDER_STATUSES = new Set([
|
|
41
|
+
'active', 'making', 'history', 'refunding', 'refunded',
|
|
42
|
+
'created', 'awaiting_payment', 'paid', 'accepted', 'preparing', 'ready',
|
|
43
|
+
'completed', 'cancelled',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
function parseArgv(argv) {
|
|
47
|
+
if (argv[0] === '--') argv = argv.slice(1);
|
|
48
|
+
for (const token of argv) {
|
|
49
|
+
if (token === '--password' || token.startsWith('--password=') || token === '--token' || token.startsWith('--token=')) {
|
|
50
|
+
throw new CliError(
|
|
51
|
+
'SECRET_IN_ARGV',
|
|
52
|
+
'Passwords and tokens are forbidden in argv; use stdin/file/prompt/env',
|
|
53
|
+
EXIT.USAGE
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const options = {};
|
|
58
|
+
const positionals = [];
|
|
59
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
60
|
+
const token = argv[index];
|
|
61
|
+
if (!token.startsWith('--')) {
|
|
62
|
+
positionals.push(token);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const eq = token.indexOf('=');
|
|
66
|
+
const key = token.slice(2, eq === -1 ? undefined : eq);
|
|
67
|
+
if (!key) throw new CliError('INVALID_ARGUMENT', 'Empty option name', EXIT.USAGE);
|
|
68
|
+
if (Object.hasOwn(options, key)) throw new CliError('DUPLICATE_OPTION', `Duplicate --${key}`, EXIT.USAGE);
|
|
69
|
+
if (BOOLEAN_OPTIONS.has(key)) {
|
|
70
|
+
if (eq !== -1) throw new CliError('INVALID_ARGUMENT', `--${key} does not accept a value`, EXIT.USAGE);
|
|
71
|
+
options[key] = true;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
let value = eq === -1 ? argv[++index] : token.slice(eq + 1);
|
|
75
|
+
if (value === undefined || value.startsWith('--')) {
|
|
76
|
+
throw new CliError('MISSING_OPTION_VALUE', `--${key} requires a value`, EXIT.USAGE);
|
|
77
|
+
}
|
|
78
|
+
options[key] = value;
|
|
79
|
+
}
|
|
80
|
+
return { options, positionals };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function resolveCommand(positionals) {
|
|
84
|
+
if (!positionals.length) return { command: 'help', args: [] };
|
|
85
|
+
const one = positionals[0];
|
|
86
|
+
if (['help', 'version', 'doctor', 'capabilities'].includes(one)) {
|
|
87
|
+
return { command: one, args: positionals.slice(1) };
|
|
88
|
+
}
|
|
89
|
+
const two = positionals.slice(0, 2).join(' ');
|
|
90
|
+
if (Object.hasOwn(COMMAND_OPTIONS, two)) return { command: two, args: positionals.slice(2) };
|
|
91
|
+
throw new CliError('UNKNOWN_COMMAND', `Unknown command: ${positionals.slice(0, 2).join(' ')}`, EXIT.USAGE);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validateOptions(command, options) {
|
|
95
|
+
const allowed = new Set([...GLOBAL_OPTIONS, ...(COMMAND_OPTIONS[command] || [])]);
|
|
96
|
+
for (const key of Object.keys(options)) {
|
|
97
|
+
if (!allowed.has(key)) throw new CliError('UNKNOWN_OPTION', `Unknown option for ${command}: --${key}`, EXIT.USAGE);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function requireNoArgs(command, args) {
|
|
102
|
+
if (args.length) throw new CliError('UNEXPECTED_ARGUMENT', `${command} accepts no positional arguments`, EXIT.USAGE);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function requireOneArg(command, args, label) {
|
|
106
|
+
if (args.length !== 1 || !args[0]) {
|
|
107
|
+
throw new CliError('INVALID_ARGUMENT', `${command} requires exactly one ${label}`, EXIT.USAGE);
|
|
108
|
+
}
|
|
109
|
+
return args[0];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function integerOption(options, key, { min, max } = {}) {
|
|
113
|
+
if (options[key] === undefined) return undefined;
|
|
114
|
+
const value = Number(options[key]);
|
|
115
|
+
if (!Number.isInteger(value) || (min !== undefined && value < min) || (max !== undefined && value > max)) {
|
|
116
|
+
const range = min !== undefined || max !== undefined ? ` (${min ?? '-∞'}..${max ?? '∞'})` : '';
|
|
117
|
+
throw new CliError('INVALID_ARGUMENT', `--${key} must be an integer${range}`, EXIT.USAGE);
|
|
118
|
+
}
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function queryPath(base, pairs) {
|
|
123
|
+
const query = new URLSearchParams();
|
|
124
|
+
for (const [key, value] of pairs) {
|
|
125
|
+
if (value !== undefined && value !== null && value !== '') query.set(key, String(value));
|
|
126
|
+
}
|
|
127
|
+
const suffix = query.toString();
|
|
128
|
+
return suffix ? `${base}?${suffix}` : base;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function readStdinSecret(stream) {
|
|
132
|
+
let value = '';
|
|
133
|
+
for await (const chunk of stream) {
|
|
134
|
+
value += String(chunk);
|
|
135
|
+
if (Buffer.byteLength(value, 'utf8') > 64 * 1024) {
|
|
136
|
+
throw new CliError('SECRET_TOO_LARGE', 'Password input is too large', EXIT.AUTH);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
value = value.replace(/[\r\n]+$/, '');
|
|
140
|
+
if (!value) throw new CliError('SECRET_REQUIRED', 'Password is empty', EXIT.AUTH);
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function promptHidden(input, output) {
|
|
145
|
+
if (!input?.isTTY || typeof input.setRawMode !== 'function') {
|
|
146
|
+
throw new CliError(
|
|
147
|
+
'SECRET_SOURCE_REQUIRED',
|
|
148
|
+
'Use --password-stdin, --password-file, --password-env or an interactive TTY',
|
|
149
|
+
EXIT.AUTH
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
output.write('Password: ');
|
|
153
|
+
input.setRawMode(true);
|
|
154
|
+
input.resume();
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
let value = '';
|
|
157
|
+
const finish = (error) => {
|
|
158
|
+
input.off('data', onData);
|
|
159
|
+
input.setRawMode(false);
|
|
160
|
+
output.write('\n');
|
|
161
|
+
if (error) reject(error);
|
|
162
|
+
else if (!value) reject(new CliError('SECRET_REQUIRED', 'Password is empty', EXIT.AUTH));
|
|
163
|
+
else resolve(value);
|
|
164
|
+
};
|
|
165
|
+
const onData = (chunk) => {
|
|
166
|
+
const text = String(chunk);
|
|
167
|
+
for (const character of text) {
|
|
168
|
+
if (character === '\u0003') return finish(new CliError('CANCELLED', 'Login cancelled', EXIT.AUTH));
|
|
169
|
+
if (character === '\r' || character === '\n') return finish();
|
|
170
|
+
if (character === '\u007f' || character === '\b') value = value.slice(0, -1);
|
|
171
|
+
else if (character >= ' ') value += character;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
input.on('data', onData);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function resolvePassword(options, io) {
|
|
179
|
+
const explicit = ['password-stdin', 'password-file', 'password-env'].filter((key) => options[key] !== undefined);
|
|
180
|
+
if (explicit.length > 1) {
|
|
181
|
+
throw new CliError('SECRET_SOURCE_CONFLICT', 'Choose exactly one password source', EXIT.USAGE);
|
|
182
|
+
}
|
|
183
|
+
if (options['password-stdin']) return readStdinSecret(io.stdin);
|
|
184
|
+
if (options['password-file']) return readSecretFile(options['password-file']);
|
|
185
|
+
if (options['password-env']) {
|
|
186
|
+
const name = String(options['password-env']);
|
|
187
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
188
|
+
throw new CliError('INVALID_ENV_NAME', 'password-env must name an environment variable', EXIT.USAGE);
|
|
189
|
+
}
|
|
190
|
+
const value = io.env[name];
|
|
191
|
+
if (!value) throw new CliError('SECRET_REQUIRED', 'Password environment variable is empty', EXIT.AUTH);
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
if (io.env.JUMBO_PASSWORD) return io.env.JUMBO_PASSWORD;
|
|
195
|
+
if (typeof io.promptSecret === 'function') return io.promptSecret();
|
|
196
|
+
return promptHidden(io.stdin, io.stderr);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function readControlFile(fileRaw) {
|
|
200
|
+
const file = path.resolve(String(fileRaw || ''));
|
|
201
|
+
let stat;
|
|
202
|
+
try {
|
|
203
|
+
stat = fs.lstatSync(file);
|
|
204
|
+
} catch {
|
|
205
|
+
throw new CliError('CONTROL_FILE', 'Control file could not be read', EXIT.USAGE);
|
|
206
|
+
}
|
|
207
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > 1024 * 1024) {
|
|
208
|
+
throw new CliError('CONTROL_FILE', 'Control file must be a regular JSON file under 1 MiB', EXIT.USAGE);
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
212
|
+
} catch {
|
|
213
|
+
throw new CliError('CONTROL_FILE', 'Control file is invalid JSON', EXIT.USAGE);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function writeControlFile(fileRaw, document) {
|
|
218
|
+
const file = path.resolve(String(fileRaw || ''));
|
|
219
|
+
try {
|
|
220
|
+
fs.writeFileSync(file, `${JSON.stringify(document, null, 2)}\n`, {
|
|
221
|
+
encoding: 'utf8',
|
|
222
|
+
mode: 0o600,
|
|
223
|
+
flag: 'wx',
|
|
224
|
+
});
|
|
225
|
+
fs.chmodSync(file, 0o600);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (error?.code === 'EEXIST') {
|
|
228
|
+
throw new CliError('OUTPUT_EXISTS', 'Refusing to overwrite the output file', EXIT.USAGE);
|
|
229
|
+
}
|
|
230
|
+
throw new CliError('OUTPUT_WRITE', 'Could not write the output file', EXIT.USAGE);
|
|
231
|
+
}
|
|
232
|
+
return file;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function outputEnvelope(stream, value, pretty) {
|
|
236
|
+
stream.write(`${JSON.stringify(value, null, pretty ? 2 : 0)}\n`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function commandHelp(command) {
|
|
240
|
+
const capability = CLI_CAPABILITIES.find((item) => item.command === command);
|
|
241
|
+
return capability ? { ...HELP, command: capability } : HELP;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function authContext(options, io, store) {
|
|
245
|
+
const profile = validateProfileName(options.profile || io.env.JUMBO_PROFILE || 'default');
|
|
246
|
+
const storedProfile = store.get(profile);
|
|
247
|
+
const target = resolveProfileTarget({
|
|
248
|
+
optionBaseUrl: options['base-url'],
|
|
249
|
+
envBaseUrl: io.env.JUMBO_BASE_URL,
|
|
250
|
+
envToken: io.env.JUMBO_TOKEN,
|
|
251
|
+
storedProfile,
|
|
252
|
+
});
|
|
253
|
+
const timeoutMs = integerOption(options, 'timeout-ms', { min: 500, max: 30_000 }) ?? 15_000;
|
|
254
|
+
return { profile, storedProfile, ...target, timeoutMs };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function authenticatedRequest(context, io, input) {
|
|
258
|
+
if (!context.token) {
|
|
259
|
+
throw new CliError('AUTH_REQUIRED', 'Login first or provide JUMBO_TOKEN', EXIT.AUTH);
|
|
260
|
+
}
|
|
261
|
+
return requestJson({
|
|
262
|
+
...input,
|
|
263
|
+
baseUrl: context.baseUrl,
|
|
264
|
+
token: context.token,
|
|
265
|
+
timeoutMs: context.timeoutMs,
|
|
266
|
+
fetchImpl: io.fetch,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function executeCommand({ command, args, options }, io) {
|
|
271
|
+
if (options.version || command === 'version') {
|
|
272
|
+
requireNoArgs(command, args);
|
|
273
|
+
return { version: CLI_VERSION };
|
|
274
|
+
}
|
|
275
|
+
if (options.help) return commandHelp(command);
|
|
276
|
+
if (command === 'help') {
|
|
277
|
+
requireNoArgs(command, args);
|
|
278
|
+
return HELP;
|
|
279
|
+
}
|
|
280
|
+
if (command === 'capabilities') {
|
|
281
|
+
requireNoArgs(command, args);
|
|
282
|
+
return { version: CLI_VERSION, capabilities: CLI_CAPABILITIES, controlKinds: CONTROL_KINDS };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const store = createCredentialStore({ env: io.env });
|
|
286
|
+
const profile = validateProfileName(options.profile || io.env.JUMBO_PROFILE || 'default');
|
|
287
|
+
|
|
288
|
+
if (command === 'auth logout') {
|
|
289
|
+
requireNoArgs(command, args);
|
|
290
|
+
return { profile, removed: store.remove(profile) };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (command === 'auth login') {
|
|
294
|
+
requireNoArgs(command, args);
|
|
295
|
+
const email = String(options.email || io.env.JUMBO_EMAIL || '').trim();
|
|
296
|
+
if (!email || email.length > 200) {
|
|
297
|
+
throw new CliError('EMAIL_REQUIRED', 'auth login requires --email or JUMBO_EMAIL', EXIT.USAGE);
|
|
298
|
+
}
|
|
299
|
+
const stored = store.get(profile);
|
|
300
|
+
const baseUrl = normalizeBaseUrl(
|
|
301
|
+
options['base-url'] || io.env.JUMBO_BASE_URL || stored?.baseUrl || 'http://127.0.0.1:8787'
|
|
302
|
+
);
|
|
303
|
+
const timeoutMs = integerOption(options, 'timeout-ms', { min: 500, max: 30_000 }) ?? 15_000;
|
|
304
|
+
let password = await resolvePassword(options, io);
|
|
305
|
+
try {
|
|
306
|
+
const data = await requestJson({
|
|
307
|
+
baseUrl,
|
|
308
|
+
path: '/api/auth/staff/login',
|
|
309
|
+
method: 'POST',
|
|
310
|
+
body: { email, password },
|
|
311
|
+
timeoutMs,
|
|
312
|
+
fetchImpl: io.fetch,
|
|
313
|
+
});
|
|
314
|
+
if (!data || typeof data.token !== 'string' || !data.token || !data.staff) {
|
|
315
|
+
throw new CliError('INVALID_RESPONSE', 'Login response did not contain a staff session', EXIT.NETWORK);
|
|
316
|
+
}
|
|
317
|
+
store.set(profile, { baseUrl, token: data.token, staff: data.staff });
|
|
318
|
+
return { profile, baseUrl, staff: data.staff, permissions: data.permissions || [] };
|
|
319
|
+
} finally {
|
|
320
|
+
password = '';
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const context = authContext(options, io, store);
|
|
325
|
+
|
|
326
|
+
if (command === 'doctor') {
|
|
327
|
+
requireNoArgs(command, args);
|
|
328
|
+
const health = await requestJson({
|
|
329
|
+
baseUrl: context.baseUrl,
|
|
330
|
+
path: '/api/health',
|
|
331
|
+
timeoutMs: context.timeoutMs,
|
|
332
|
+
fetchImpl: io.fetch,
|
|
333
|
+
allowBare: true,
|
|
334
|
+
});
|
|
335
|
+
let identity = null;
|
|
336
|
+
if (context.token) {
|
|
337
|
+
identity = await authenticatedRequest(context, io, { path: '/api/admin/me' });
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
profile: context.profile,
|
|
341
|
+
baseUrl: context.baseUrl,
|
|
342
|
+
health,
|
|
343
|
+
authenticated: Boolean(identity),
|
|
344
|
+
identity,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (command === 'auth whoami') {
|
|
349
|
+
requireNoArgs(command, args);
|
|
350
|
+
return authenticatedRequest(context, io, { path: '/api/admin/me' });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (command === 'catalog show') {
|
|
354
|
+
requireNoArgs(command, args);
|
|
355
|
+
return authenticatedRequest(context, io, { path: '/api/admin/catalog' });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (command === 'products list') {
|
|
359
|
+
requireNoArgs(command, args);
|
|
360
|
+
const status = options.status;
|
|
361
|
+
if (status && !['all', 'on', 'off'].includes(status)) {
|
|
362
|
+
throw new CliError('INVALID_ARGUMENT', '--status must be all, on or off', EXIT.USAGE);
|
|
363
|
+
}
|
|
364
|
+
return authenticatedRequest(context, io, {
|
|
365
|
+
path: queryPath('/api/admin/control-plane/products', [
|
|
366
|
+
['storeId', options['store-id']], ['status', status], ['q', options.q],
|
|
367
|
+
['page', integerOption(options, 'page', { min: 1 })],
|
|
368
|
+
['pageSize', integerOption(options, 'page-size', { min: 1, max: 100 })],
|
|
369
|
+
]),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (command === 'products get') {
|
|
374
|
+
const id = requireOneArg(command, args, 'product id');
|
|
375
|
+
return authenticatedRequest(context, io, {
|
|
376
|
+
path: `/api/admin/control-plane/products/${encodeURIComponent(id)}`,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (command === 'orders list') {
|
|
381
|
+
requireNoArgs(command, args);
|
|
382
|
+
if (options.status && !ORDER_STATUSES.has(options.status)) {
|
|
383
|
+
throw new CliError('INVALID_ARGUMENT', 'Unsupported order status filter', EXIT.USAGE);
|
|
384
|
+
}
|
|
385
|
+
return authenticatedRequest(context, io, {
|
|
386
|
+
path: queryPath('/api/admin/orders', [
|
|
387
|
+
['status', options.status], ['storeId', options['store-id']], ['q', options.q],
|
|
388
|
+
['limit', integerOption(options, 'limit', { min: 1, max: 100 })], ['cursor', options.cursor],
|
|
389
|
+
]),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (command === 'orders get') {
|
|
394
|
+
const id = requireOneArg(command, args, 'order id');
|
|
395
|
+
return authenticatedRequest(context, io, { path: `/api/admin/orders/${encodeURIComponent(id)}` });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (command === 'stores list') {
|
|
399
|
+
requireNoArgs(command, args);
|
|
400
|
+
return authenticatedRequest(context, io, { path: '/api/admin/stores' });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (command === 'store status') {
|
|
404
|
+
const id = requireOneArg(command, args, 'store id');
|
|
405
|
+
return authenticatedRequest(context, io, {
|
|
406
|
+
path: `/api/admin/control-plane/store-status/${encodeURIComponent(id)}`,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (command === 'inventory alerts') {
|
|
411
|
+
requireNoArgs(command, args);
|
|
412
|
+
return authenticatedRequest(context, io, {
|
|
413
|
+
path: queryPath('/api/admin/control-plane/inventory-alerts', [
|
|
414
|
+
['storeId', options['store-id']],
|
|
415
|
+
['threshold', integerOption(options, 'threshold', { min: 0, max: 1_000_000 })],
|
|
416
|
+
]),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (command === 'business summary') {
|
|
421
|
+
requireNoArgs(command, args);
|
|
422
|
+
return authenticatedRequest(context, io, { path: '/api/admin/dashboard' });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (command === 'business daily') {
|
|
426
|
+
requireNoArgs(command, args);
|
|
427
|
+
return authenticatedRequest(context, io, {
|
|
428
|
+
path: queryPath('/api/admin/analytics/daily', [
|
|
429
|
+
['days', integerOption(options, 'days', { min: 1, max: 90 })],
|
|
430
|
+
]),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (command === 'reminders status') {
|
|
435
|
+
requireNoArgs(command, args);
|
|
436
|
+
return authenticatedRequest(context, io, { path: '/api/admin/reminders/status' });
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (command === 'reminders history') {
|
|
440
|
+
requireNoArgs(command, args);
|
|
441
|
+
return authenticatedRequest(context, io, {
|
|
442
|
+
path: queryPath('/api/admin/reminders/history', [
|
|
443
|
+
['limit', integerOption(options, 'limit', { min: 1, max: 100 })],
|
|
444
|
+
]),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (command === 'control export') {
|
|
449
|
+
requireNoArgs(command, args);
|
|
450
|
+
const kind = String(options.kind || '');
|
|
451
|
+
if (!CONTROL_KINDS.includes(kind)) {
|
|
452
|
+
throw new CliError('INVALID_ARGUMENT', `--kind must be one of: ${CONTROL_KINDS.join(', ')}`, EXIT.USAGE);
|
|
453
|
+
}
|
|
454
|
+
if (!options.id || !options.out) {
|
|
455
|
+
throw new CliError('INVALID_ARGUMENT', 'control export requires --kind, --id and --out', EXIT.USAGE);
|
|
456
|
+
}
|
|
457
|
+
const document = await authenticatedRequest(context, io, {
|
|
458
|
+
path: `/api/admin/control-plane/resources/${encodeURIComponent(kind)}/${encodeURIComponent(options.id)}`,
|
|
459
|
+
});
|
|
460
|
+
const file = writeControlFile(options.out, document);
|
|
461
|
+
return { file, document };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (command === 'control preview') {
|
|
465
|
+
requireNoArgs(command, args);
|
|
466
|
+
if (!options.file) throw new CliError('INVALID_ARGUMENT', 'control preview requires --file', EXIT.USAGE);
|
|
467
|
+
const document = readControlFile(options.file);
|
|
468
|
+
return authenticatedRequest(context, io, {
|
|
469
|
+
path: '/api/admin/control-plane/preview',
|
|
470
|
+
method: 'POST',
|
|
471
|
+
body: document,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (command === 'control apply') {
|
|
476
|
+
requireNoArgs(command, args);
|
|
477
|
+
if (!options.apply) {
|
|
478
|
+
throw new CliError('APPLY_REQUIRED', 'control apply requires explicit --apply', EXIT.USAGE);
|
|
479
|
+
}
|
|
480
|
+
if (!options.file || !options.reason || !options['idempotency-key']) {
|
|
481
|
+
throw new CliError(
|
|
482
|
+
'INVALID_ARGUMENT',
|
|
483
|
+
'control apply requires --file, --reason, --idempotency-key and --apply',
|
|
484
|
+
EXIT.USAGE
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
const document = readControlFile(options.file);
|
|
488
|
+
let result;
|
|
489
|
+
try {
|
|
490
|
+
result = await authenticatedRequest(context, io, {
|
|
491
|
+
path: '/api/admin/control-plane/apply',
|
|
492
|
+
method: 'POST',
|
|
493
|
+
body: {
|
|
494
|
+
document,
|
|
495
|
+
reason: options.reason,
|
|
496
|
+
idempotencyKey: options['idempotency-key'],
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
} catch (error) {
|
|
500
|
+
if (error instanceof CliError && error.exitCode === EXIT.NETWORK) {
|
|
501
|
+
throw new CliError(
|
|
502
|
+
'APPLY_RESULT_UNKNOWN',
|
|
503
|
+
'Apply result is unknown; retry the same file and idempotency key',
|
|
504
|
+
EXIT.CONFLICT
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
throw error;
|
|
508
|
+
}
|
|
509
|
+
const resource = result?.resource;
|
|
510
|
+
if (!resource?.kind || !resource?.targetId || !resource?.expectedRevision) {
|
|
511
|
+
throw new CliError('READBACK_FAILED', 'Apply receipt is missing its resource revision', EXIT.CONFLICT);
|
|
512
|
+
}
|
|
513
|
+
let readBack;
|
|
514
|
+
try {
|
|
515
|
+
readBack = await authenticatedRequest(context, io, {
|
|
516
|
+
path: `/api/admin/control-plane/resources/${encodeURIComponent(resource.kind)}/${encodeURIComponent(resource.targetId)}`,
|
|
517
|
+
});
|
|
518
|
+
} catch {
|
|
519
|
+
throw new CliError(
|
|
520
|
+
'READBACK_FAILED',
|
|
521
|
+
'Apply returned a receipt but the mandatory read-back could not be completed',
|
|
522
|
+
EXIT.CONFLICT
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
if (readBack?.expectedRevision !== resource.expectedRevision) {
|
|
526
|
+
throw new CliError(
|
|
527
|
+
'READBACK_FAILED',
|
|
528
|
+
'Server read-back revision does not match the apply receipt',
|
|
529
|
+
EXIT.CONFLICT,
|
|
530
|
+
{ expectedRevision: resource.expectedRevision, actualRevision: readBack?.expectedRevision ?? null }
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
return { ...result, verifiedRevision: readBack.expectedRevision };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
throw new CliError('UNKNOWN_COMMAND', `Unknown command: ${command}`, EXIT.USAGE);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export async function runCli(
|
|
540
|
+
argv = process.argv.slice(2),
|
|
541
|
+
overrides = {}
|
|
542
|
+
) {
|
|
543
|
+
const io = {
|
|
544
|
+
env: process.env,
|
|
545
|
+
stdin: process.stdin,
|
|
546
|
+
stdout: process.stdout,
|
|
547
|
+
stderr: process.stderr,
|
|
548
|
+
fetch: globalThis.fetch,
|
|
549
|
+
promptSecret: undefined,
|
|
550
|
+
...overrides,
|
|
551
|
+
};
|
|
552
|
+
let pretty = false;
|
|
553
|
+
let command = 'unknown';
|
|
554
|
+
try {
|
|
555
|
+
const parsed = parseArgv(argv);
|
|
556
|
+
const resolved = resolveCommand(parsed.positionals);
|
|
557
|
+
command = parsed.options.version ? 'version' : resolved.command;
|
|
558
|
+
pretty = Boolean(parsed.options.pretty);
|
|
559
|
+
validateOptions(command, parsed.options);
|
|
560
|
+
const data = await executeCommand({ ...resolved, options: parsed.options }, io);
|
|
561
|
+
outputEnvelope(io.stdout, { ok: true, command, data }, pretty);
|
|
562
|
+
return EXIT.OK;
|
|
563
|
+
} catch (unknownError) {
|
|
564
|
+
const error = asCliError(unknownError);
|
|
565
|
+
outputEnvelope(io.stderr, {
|
|
566
|
+
ok: false,
|
|
567
|
+
command,
|
|
568
|
+
error: {
|
|
569
|
+
code: error.code,
|
|
570
|
+
message: error.message,
|
|
571
|
+
...(error.details === undefined ? {} : { details: error.details }),
|
|
572
|
+
},
|
|
573
|
+
}, pretty);
|
|
574
|
+
return error.exitCode;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// INPUT: OS home/XDG or JUMBO_CONFIG_DIR, profile names, staff tokens returned by the API, and optional password files.
|
|
2
|
+
// OUTPUT: Symlink-resistant 0700 directory/0600 atomic credential storage plus permission-checked secret-file reads.
|
|
3
|
+
// POS: Local trust boundary for `jumbo`; passwords are never persisted and Feishu secrets never enter this store.
|
|
4
|
+
// PROTOCOL: Sync core.mjs, tests and docs/operations/JUMBO_CLI_FEISHU.md when path, format or permission rules change.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { randomBytes } from 'node:crypto';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { CliError, EXIT } from './errors.mjs';
|
|
12
|
+
|
|
13
|
+
const STORE_VERSION = 1;
|
|
14
|
+
const PROFILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
15
|
+
const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
16
|
+
const REPOSITORY_ROOT_REAL = fs.realpathSync(REPOSITORY_ROOT);
|
|
17
|
+
|
|
18
|
+
export function validateProfileName(raw) {
|
|
19
|
+
const value = String(raw || '').trim();
|
|
20
|
+
if (!PROFILE_RE.test(value)) {
|
|
21
|
+
throw new CliError('INVALID_PROFILE', 'Profile must use 1-64 safe characters', EXIT.USAGE);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveConfigDir(env = process.env) {
|
|
27
|
+
if (env.JUMBO_CONFIG_DIR) return path.resolve(env.JUMBO_CONFIG_DIR);
|
|
28
|
+
if (env.XDG_CONFIG_HOME) return path.resolve(env.XDG_CONFIG_HOME, 'jumbo-toast');
|
|
29
|
+
return path.join(os.homedir(), '.config', 'jumbo-toast');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function assertOwned(stat, label) {
|
|
33
|
+
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
34
|
+
throw new CliError('CREDENTIAL_OWNER', `${label} must be owned by the current user`, EXIT.AUTH);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ensureDirectory(directory) {
|
|
39
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
40
|
+
const stat = fs.lstatSync(directory);
|
|
41
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
42
|
+
throw new CliError('CREDENTIAL_PATH', 'Credential directory must be a real directory', EXIT.AUTH);
|
|
43
|
+
}
|
|
44
|
+
assertOwned(stat, 'Credential directory');
|
|
45
|
+
fs.chmodSync(directory, 0o700);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function assertOutsideRepository(directory) {
|
|
49
|
+
const real = fs.realpathSync(directory);
|
|
50
|
+
if (real === REPOSITORY_ROOT_REAL || real.startsWith(`${REPOSITORY_ROOT_REAL}${path.sep}`)) {
|
|
51
|
+
throw new CliError(
|
|
52
|
+
'CREDENTIAL_LOCATION',
|
|
53
|
+
'Credential storage must stay outside the repository',
|
|
54
|
+
EXIT.AUTH
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function safeReadJson(file) {
|
|
60
|
+
if (!fs.existsSync(file)) return { version: STORE_VERSION, profiles: {} };
|
|
61
|
+
const stat = fs.lstatSync(file);
|
|
62
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
63
|
+
throw new CliError('CREDENTIAL_PATH', 'Credential file must be a regular file', EXIT.AUTH);
|
|
64
|
+
}
|
|
65
|
+
assertOwned(stat, 'Credential file');
|
|
66
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
67
|
+
throw new CliError(
|
|
68
|
+
'CREDENTIAL_PERMISSIONS',
|
|
69
|
+
'Credential file permissions must be 0600',
|
|
70
|
+
EXIT.AUTH
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (stat.size > 1024 * 1024) {
|
|
74
|
+
throw new CliError('CREDENTIAL_FORMAT', 'Credential file is unexpectedly large', EXIT.AUTH);
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
78
|
+
if (parsed?.version !== STORE_VERSION || !parsed.profiles || typeof parsed.profiles !== 'object') {
|
|
79
|
+
throw new Error('shape');
|
|
80
|
+
}
|
|
81
|
+
return parsed;
|
|
82
|
+
} catch {
|
|
83
|
+
throw new CliError('CREDENTIAL_FORMAT', 'Credential file is invalid', EXIT.AUTH);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function atomicWrite(file, value) {
|
|
88
|
+
const directory = path.dirname(file);
|
|
89
|
+
ensureDirectory(directory);
|
|
90
|
+
const temp = path.join(directory, `.credentials.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
|
91
|
+
try {
|
|
92
|
+
fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, {
|
|
93
|
+
encoding: 'utf8',
|
|
94
|
+
mode: 0o600,
|
|
95
|
+
flag: 'wx',
|
|
96
|
+
});
|
|
97
|
+
fs.chmodSync(temp, 0o600);
|
|
98
|
+
fs.renameSync(temp, file);
|
|
99
|
+
fs.chmodSync(file, 0o600);
|
|
100
|
+
} finally {
|
|
101
|
+
if (fs.existsSync(temp)) fs.unlinkSync(temp);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createCredentialStore({ env = process.env } = {}) {
|
|
106
|
+
const directory = resolveConfigDir(env);
|
|
107
|
+
if (directory === REPOSITORY_ROOT || directory.startsWith(`${REPOSITORY_ROOT}${path.sep}`)) {
|
|
108
|
+
throw new CliError(
|
|
109
|
+
'CREDENTIAL_LOCATION',
|
|
110
|
+
'Credential storage must stay outside the repository',
|
|
111
|
+
EXIT.AUTH
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const file = path.join(directory, 'credentials.json');
|
|
115
|
+
|
|
116
|
+
function readStore() {
|
|
117
|
+
ensureDirectory(directory);
|
|
118
|
+
// Resolve parent symlinks after creation; a lexically external path must not land in the repo.
|
|
119
|
+
assertOutsideRepository(directory);
|
|
120
|
+
return safeReadJson(file);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return Object.freeze({
|
|
124
|
+
directory,
|
|
125
|
+
file,
|
|
126
|
+
get(profileRaw) {
|
|
127
|
+
const profile = validateProfileName(profileRaw);
|
|
128
|
+
const value = readStore().profiles[profile];
|
|
129
|
+
if (!value || typeof value !== 'object') return null;
|
|
130
|
+
if (typeof value.baseUrl !== 'string' || typeof value.token !== 'string') {
|
|
131
|
+
throw new CliError('CREDENTIAL_FORMAT', 'Stored profile is invalid', EXIT.AUTH);
|
|
132
|
+
}
|
|
133
|
+
return { ...value };
|
|
134
|
+
},
|
|
135
|
+
set(profileRaw, value) {
|
|
136
|
+
const profile = validateProfileName(profileRaw);
|
|
137
|
+
if (!value || typeof value.baseUrl !== 'string' || typeof value.token !== 'string' || !value.token) {
|
|
138
|
+
throw new CliError('CREDENTIAL_FORMAT', 'Refusing to store an invalid profile', EXIT.AUTH);
|
|
139
|
+
}
|
|
140
|
+
const store = readStore();
|
|
141
|
+
store.profiles[profile] = {
|
|
142
|
+
baseUrl: value.baseUrl,
|
|
143
|
+
token: value.token,
|
|
144
|
+
staff: value.staff ?? null,
|
|
145
|
+
updatedAt: new Date().toISOString(),
|
|
146
|
+
};
|
|
147
|
+
atomicWrite(file, store);
|
|
148
|
+
},
|
|
149
|
+
remove(profileRaw) {
|
|
150
|
+
const profile = validateProfileName(profileRaw);
|
|
151
|
+
const store = readStore();
|
|
152
|
+
if (!Object.hasOwn(store.profiles, profile)) return false;
|
|
153
|
+
delete store.profiles[profile];
|
|
154
|
+
atomicWrite(file, store);
|
|
155
|
+
return true;
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function readSecretFile(fileRaw) {
|
|
161
|
+
const file = path.resolve(String(fileRaw || ''));
|
|
162
|
+
let stat;
|
|
163
|
+
try {
|
|
164
|
+
stat = fs.lstatSync(file);
|
|
165
|
+
} catch {
|
|
166
|
+
throw new CliError('SECRET_FILE', 'Password file could not be read', EXIT.AUTH);
|
|
167
|
+
}
|
|
168
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
169
|
+
throw new CliError('SECRET_FILE', 'Password file must be a regular file', EXIT.AUTH);
|
|
170
|
+
}
|
|
171
|
+
const real = fs.realpathSync(file);
|
|
172
|
+
if (real === REPOSITORY_ROOT_REAL || real.startsWith(`${REPOSITORY_ROOT_REAL}${path.sep}`)) {
|
|
173
|
+
throw new CliError('SECRET_FILE_LOCATION', 'Password file must stay outside the repository', EXIT.AUTH);
|
|
174
|
+
}
|
|
175
|
+
assertOwned(stat, 'Password file');
|
|
176
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
177
|
+
throw new CliError('SECRET_FILE_PERMISSIONS', 'Password file permissions must be 0600', EXIT.AUTH);
|
|
178
|
+
}
|
|
179
|
+
if (stat.size < 1 || stat.size > 64 * 1024) {
|
|
180
|
+
throw new CliError('SECRET_FILE', 'Password file size is invalid', EXIT.AUTH);
|
|
181
|
+
}
|
|
182
|
+
const value = fs.readFileSync(file, 'utf8').replace(/[\r\n]+$/, '');
|
|
183
|
+
if (!value) throw new CliError('SECRET_REQUIRED', 'Password is empty', EXIT.AUTH);
|
|
184
|
+
return value;
|
|
185
|
+
}
|
package/src/errors.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// INPUT: Usage, credential, HTTP, RBAC, conflict and verification failures.
|
|
2
|
+
// OUTPUT: CliError carrying a stable machine code, safe message, exit code and optional non-secret details.
|
|
3
|
+
// POS: Shared error taxonomy for every `jumbo` command and JSON envelope.
|
|
4
|
+
// PROTOCOL: Sync capabilities.mjs, core.mjs tests and docs/operations/JUMBO_CLI_FEISHU.md when exit meanings change.
|
|
5
|
+
|
|
6
|
+
export const EXIT = Object.freeze({
|
|
7
|
+
OK: 0,
|
|
8
|
+
USAGE: 2,
|
|
9
|
+
AUTH: 3,
|
|
10
|
+
FORBIDDEN: 4,
|
|
11
|
+
NETWORK: 5,
|
|
12
|
+
CONFLICT: 6,
|
|
13
|
+
INTERNAL: 70,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export class CliError extends Error {
|
|
17
|
+
constructor(code, message, exitCode = EXIT.INTERNAL, details = undefined) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'CliError';
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.exitCode = exitCode;
|
|
22
|
+
this.details = details;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function asCliError(error) {
|
|
27
|
+
if (error instanceof CliError) return error;
|
|
28
|
+
return new CliError('INTERNAL_ERROR', 'Unexpected CLI failure', EXIT.INTERNAL);
|
|
29
|
+
}
|
package/src/http.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// INPUT: Fixed relative API paths, validated profile origin, optional staff token, JSON body, timeout and injected/global fetch.
|
|
2
|
+
// OUTPUT: Parsed API data with loopback-HTTP/remote-HTTPS enforcement, manual redirect rejection and safe status-to-exit mapping.
|
|
3
|
+
// POS: Network trust boundary; credentials are scoped to one normalized origin and are never forwarded to redirects or caller-selected endpoints.
|
|
4
|
+
// PROTOCOL: Sync core.mjs, capabilities.mjs, security tests and docs/operations/JUMBO_CLI_FEISHU.md when URL or error behavior changes.
|
|
5
|
+
|
|
6
|
+
import { CliError, EXIT } from './errors.mjs';
|
|
7
|
+
|
|
8
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
9
|
+
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
export function normalizeBaseUrl(raw) {
|
|
12
|
+
let url;
|
|
13
|
+
try {
|
|
14
|
+
url = new URL(String(raw || ''));
|
|
15
|
+
} catch {
|
|
16
|
+
throw new CliError('INVALID_BASE_URL', 'Base URL is invalid', EXIT.USAGE);
|
|
17
|
+
}
|
|
18
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
19
|
+
throw new CliError('INVALID_BASE_URL', 'Base URL cannot contain credentials, query or fragment', EXIT.USAGE);
|
|
20
|
+
}
|
|
21
|
+
if (url.pathname !== '/' && url.pathname !== '') {
|
|
22
|
+
throw new CliError('INVALID_BASE_URL', 'Base URL must be an origin without a path', EXIT.USAGE);
|
|
23
|
+
}
|
|
24
|
+
const loopback = LOOPBACK_HOSTS.has(url.hostname.toLowerCase());
|
|
25
|
+
if (url.protocol === 'http:' && !loopback) {
|
|
26
|
+
throw new CliError('HTTPS_REQUIRED', 'Remote targets require HTTPS; HTTP is loopback-only', EXIT.USAGE);
|
|
27
|
+
}
|
|
28
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
29
|
+
throw new CliError('PROTOCOL_FORBIDDEN', 'Only HTTPS and loopback HTTP are allowed', EXIT.USAGE);
|
|
30
|
+
}
|
|
31
|
+
return url.origin;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveEndpoint(baseUrl, relativePath) {
|
|
35
|
+
const origin = normalizeBaseUrl(baseUrl);
|
|
36
|
+
if (typeof relativePath !== 'string' || !relativePath.startsWith('/api/') || relativePath.includes('://')) {
|
|
37
|
+
throw new CliError('ENDPOINT_FORBIDDEN', 'CLI endpoint is not allowlisted', EXIT.INTERNAL);
|
|
38
|
+
}
|
|
39
|
+
const pathOnly = relativePath.split('?', 1)[0];
|
|
40
|
+
if (pathOnly.split('/').includes('..')) {
|
|
41
|
+
throw new CliError('ENDPOINT_FORBIDDEN', 'CLI endpoint is not allowlisted', EXIT.INTERNAL);
|
|
42
|
+
}
|
|
43
|
+
const endpoint = new URL(relativePath, `${origin}/`);
|
|
44
|
+
if (endpoint.origin !== origin || !endpoint.pathname.startsWith('/api/')) {
|
|
45
|
+
throw new CliError('ENDPOINT_FORBIDDEN', 'CLI endpoint is not allowlisted', EXIT.INTERNAL);
|
|
46
|
+
}
|
|
47
|
+
return endpoint.toString();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readBoundedText(response) {
|
|
51
|
+
if (!response.body || typeof response.body.getReader !== 'function') {
|
|
52
|
+
const value = await response.text();
|
|
53
|
+
if (Buffer.byteLength(value, 'utf8') > MAX_RESPONSE_BYTES) {
|
|
54
|
+
throw new CliError('RESPONSE_TOO_LARGE', 'API response is too large', EXIT.NETWORK);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
const reader = response.body.getReader();
|
|
59
|
+
const decoder = new TextDecoder();
|
|
60
|
+
let bytes = 0;
|
|
61
|
+
let value = '';
|
|
62
|
+
try {
|
|
63
|
+
while (true) {
|
|
64
|
+
const chunk = await reader.read();
|
|
65
|
+
if (chunk.done) break;
|
|
66
|
+
bytes += chunk.value.byteLength;
|
|
67
|
+
if (bytes > MAX_RESPONSE_BYTES) {
|
|
68
|
+
await reader.cancel().catch(() => undefined);
|
|
69
|
+
throw new CliError('RESPONSE_TOO_LARGE', 'API response is too large', EXIT.NETWORK);
|
|
70
|
+
}
|
|
71
|
+
value += decoder.decode(chunk.value, { stream: true });
|
|
72
|
+
}
|
|
73
|
+
value += decoder.decode();
|
|
74
|
+
return value;
|
|
75
|
+
} finally {
|
|
76
|
+
reader.releaseLock();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function apiFailure(status, payload) {
|
|
81
|
+
const serverCode = typeof payload?.error === 'string' ? payload.error : `HTTP_${status}`;
|
|
82
|
+
const message = typeof payload?.message === 'string' ? payload.message : 'API request failed';
|
|
83
|
+
if (status === 401) return new CliError(serverCode, message, EXIT.AUTH);
|
|
84
|
+
if (status === 403) return new CliError(serverCode, message, EXIT.FORBIDDEN);
|
|
85
|
+
if (status === 409) return new CliError(serverCode, message, EXIT.CONFLICT);
|
|
86
|
+
if (status >= 400 && status < 500) {
|
|
87
|
+
return new CliError(serverCode, message, EXIT.USAGE, payload?.details);
|
|
88
|
+
}
|
|
89
|
+
return new CliError('SERVER_ERROR', 'Server request failed', EXIT.NETWORK, { status });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function requestJson({
|
|
93
|
+
baseUrl,
|
|
94
|
+
path,
|
|
95
|
+
method = 'GET',
|
|
96
|
+
token = '',
|
|
97
|
+
body,
|
|
98
|
+
timeoutMs = 15_000,
|
|
99
|
+
fetchImpl = globalThis.fetch,
|
|
100
|
+
allowBare = false,
|
|
101
|
+
}) {
|
|
102
|
+
const endpoint = resolveEndpoint(baseUrl, path);
|
|
103
|
+
if (typeof fetchImpl !== 'function') {
|
|
104
|
+
throw new CliError('FETCH_UNAVAILABLE', 'Node fetch is unavailable', EXIT.INTERNAL);
|
|
105
|
+
}
|
|
106
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 500 || timeoutMs > 30_000) {
|
|
107
|
+
throw new CliError('INVALID_TIMEOUT', 'timeout-ms must be between 500 and 30000', EXIT.USAGE);
|
|
108
|
+
}
|
|
109
|
+
const controller = new AbortController();
|
|
110
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
111
|
+
try {
|
|
112
|
+
const headers = { accept: 'application/json' };
|
|
113
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
114
|
+
if (body !== undefined) headers['content-type'] = 'application/json';
|
|
115
|
+
let response;
|
|
116
|
+
try {
|
|
117
|
+
response = await fetchImpl(endpoint, {
|
|
118
|
+
method,
|
|
119
|
+
headers,
|
|
120
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
121
|
+
signal: controller.signal,
|
|
122
|
+
redirect: 'manual',
|
|
123
|
+
});
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error?.name === 'AbortError') {
|
|
126
|
+
throw new CliError('TIMEOUT', 'API request timed out', EXIT.NETWORK);
|
|
127
|
+
}
|
|
128
|
+
throw new CliError('NETWORK_ERROR', 'API request failed before a response', EXIT.NETWORK);
|
|
129
|
+
}
|
|
130
|
+
if (response.status >= 300 && response.status < 400) {
|
|
131
|
+
throw new CliError(
|
|
132
|
+
'REDIRECT_FORBIDDEN',
|
|
133
|
+
'API redirects are refused so credentials cannot cross origins',
|
|
134
|
+
EXIT.NETWORK
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const contentLength = Number(response.headers?.get?.('content-length') || 0);
|
|
138
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) {
|
|
139
|
+
if (response.body && typeof response.body.cancel === 'function') {
|
|
140
|
+
await response.body.cancel().catch(() => undefined);
|
|
141
|
+
}
|
|
142
|
+
throw new CliError('RESPONSE_TOO_LARGE', 'API response is too large', EXIT.NETWORK);
|
|
143
|
+
}
|
|
144
|
+
let text;
|
|
145
|
+
try {
|
|
146
|
+
text = await readBoundedText(response);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error instanceof CliError) throw error;
|
|
149
|
+
if (controller.signal.aborted || error?.name === 'AbortError') {
|
|
150
|
+
throw new CliError('TIMEOUT', 'API request timed out', EXIT.NETWORK);
|
|
151
|
+
}
|
|
152
|
+
throw new CliError('NETWORK_ERROR', 'API response body could not be read', EXIT.NETWORK);
|
|
153
|
+
}
|
|
154
|
+
let payload = null;
|
|
155
|
+
if (text) {
|
|
156
|
+
try {
|
|
157
|
+
payload = JSON.parse(text);
|
|
158
|
+
} catch {
|
|
159
|
+
throw new CliError('INVALID_RESPONSE', 'API returned invalid JSON', EXIT.NETWORK);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!response.ok || (!allowBare && payload?.code !== 0)) throw apiFailure(response.status, payload);
|
|
163
|
+
return allowBare ? payload : payload?.data;
|
|
164
|
+
} finally {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function resolveProfileTarget({ optionBaseUrl, envBaseUrl, envToken, storedProfile }) {
|
|
170
|
+
const selectedBase = optionBaseUrl || envBaseUrl || storedProfile?.baseUrl || 'http://127.0.0.1:8787';
|
|
171
|
+
const baseUrl = normalizeBaseUrl(selectedBase);
|
|
172
|
+
const token = envToken || storedProfile?.token || '';
|
|
173
|
+
if (!envToken && storedProfile?.token && normalizeBaseUrl(storedProfile.baseUrl) !== baseUrl) {
|
|
174
|
+
throw new CliError(
|
|
175
|
+
'CREDENTIAL_SCOPE_MISMATCH',
|
|
176
|
+
'Stored credentials are bound to another origin; login to the selected target first',
|
|
177
|
+
EXIT.AUTH
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return { baseUrl, token };
|
|
181
|
+
}
|