Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 228 additions & 0 deletions env/larkenv
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
#
# larkenv —— 一个脚本搞定 lark-cli 的 boe / pre / online 环境切换。
#
# ./env/larkenv setup 编译 + 安装 + 自动配 PATH(只需跑这一次)
# larkenv init boe <app-id> 一条龙:配 app(不弹选择)+ 固定 user 身份 + 登录
# larkenv init boe 同上,但走交互式选 app
# larkenv login boe 只补登录(app 已配好时用),自动出二维码
# larkenv qr <登录URL> 手动把某个 URL 转成终端二维码
# larkenv boe <任何 lark-cli 命令>
# larkenv pre <任何 lark-cli 命令>
# larkenv online <任何 lark-cli 命令>
#
# 可选:
# LARK_LANE=<泳道> larkenv boe ... 指定泳道(注入 X-TT-ENV 头)
# LARK_CLI_SHOW_LOGID=1 larkenv ... 打印所有请求的 x-tt-logid(默认只在出错时打)
#
# 三个环境的配置和登录态完全隔离在 ~/.lark-cli-env/config/{boe,pre,online},互不影响。
set -euo pipefail

BIN_DIR="${LARK_CLI_ENV_BIN:-$HOME/.local/bin}"
CONFIG_ROOT="$HOME/.lark-cli-env"
BIN="$BIN_DIR/lark-cli-env"

usage() {
# Print the header comment block (from the title line to the first
# non-comment line), stripped of its leading "# ".
awk 'NR<5{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0"
exit "${1:-0}"
}

# ---------- setup:编译 + 安装 + 配 PATH ----------
do_setup() {
local repo_root
repo_root="$(cd "$(dirname "$0")/.." && pwd)"

echo "==> 编译 lark-cli ..."
(cd "$repo_root" && ./build.sh)

echo "==> 安装到 $BIN_DIR ..."
mkdir -p "$BIN_DIR" "$CONFIG_ROOT/config"
cp "$repo_root/lark-cli" "$BIN"
cp "$repo_root/env/larkenv" "$BIN_DIR/larkenv"
chmod +x "$BIN" "$BIN_DIR/larkenv"

# 幂等地把 BIN_DIR 加进 shell rc
local rc marker='# added by lark-cli env/larkenv'
case "${SHELL##*/}" in
zsh) rc="$HOME/.zshrc" ;;
bash) rc="$HOME/.bashrc" ;;
*) rc="" ;;
esac
if [ -n "$rc" ] && ! grep -qF "$marker" "$rc" 2>/dev/null; then
printf '\n%s\nexport PATH="%s:$PATH"\n' "$marker" "$BIN_DIR" >>"$rc"
echo "==> 已把 $BIN_DIR 写入 $rc"
fi

# NOTE: brace the expansions below — the surrounding full-width punctuation
# would otherwise be swallowed into the variable name by bash.
# Plain `[ … ] && x=y` would return non-zero on the else path and trip set -e.
local reload="新开一个终端"
if [ -n "$rc" ]; then
reload="新开一个终端(或 source ${rc})"
fi

cat <<EOF

安装完成。${reload},然后:

larkenv init boe # 首次:配 app + 固定 user 身份 + 扫码登录
larkenv init pre <上面拿到的 app-id> # 其他环境复用同一个 app,不用重配
larkenv login boe # 登录态失效时,只补登录

larkenv boe base +table-list --base-token <t>
larkenv pre wiki +node-get --token <url>
larkenv online drive +search --query xxx

EOF
}

# ---------- 环境变量注入 ----------
apply_env() {
export LARKSUITE_CLI_CONFIG_DIR="$CONFIG_ROOT/config/$1"
case "$1" in
boe)
export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-boe.cn"
# boe 是内网域名,必须绕开公司外部 relay 代理
export LARK_CLI_NO_PROXY=1
;;
pre)
export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn"
;;
online)
# 不覆盖 endpoint,走开源默认 feishu.cn / larksuite.com;仅隔离配置目录
;;
esac
# Append rather than overwrite, so a caller-supplied LARKSUITE_CLI_EXTRA_HEADERS
# (e.g. "x-use-boe: 1") survives alongside LARK_LANE.
if [ -n "${LARK_LANE:-}" ]; then
if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then
export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; X-TT-ENV: $LARK_LANE"
else
export LARKSUITE_CLI_EXTRA_HEADERS="X-TT-ENV: $LARK_LANE"
fi
fi
mkdir -p "$LARKSUITE_CLI_CONFIG_DIR"
}
Comment on lines +84 to +109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject unsupported environment names.

init and login accept arbitrary values here. For example, init ../../tmp escapes CONFIG_ROOT, while init staging silently uses online endpoints. Restrict this function to boe, pre, and online, returning a failure for anything else.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@env/larkenv` around lines 84 - 109, The apply_env function currently accepts
unsupported environment names, enabling config-directory traversal and
unintended endpoint defaults. Add validation at the start of apply_env so only
boe, pre, and online are accepted; for any other value, emit a failure return
before exporting LARKSUITE_CLI_CONFIG_DIR or creating directories, while
preserving the existing handling for supported environments.


# json_field extracts a top-level string field from a JSON object on stdin.
json_field() {
if command -v jq >/dev/null 2>&1; then
jq -r --arg k "$1" '.[$k] // empty'
else
python3 -c 'import json,sys; print(json.load(sys.stdin).get(sys.argv[1],""))' "$1"
fi
}

# do_login runs the device flow but renders the verification URL as a terminal
# QR code before polling. Scanning with your own Feishu app avoids authorizing
# as whichever account the default browser happens to be signed in as — the
# usual cause of "用户登录态无效 (20033)" on a shared machine.
do_login() {
local json url code
json="$("$BIN" auth login --recommend --no-wait --json)"
url="$(printf '%s' "$json" | json_field verification_url)"
code="$(printf '%s' "$json" | json_field device_code)"

if [ -z "$url" ] || [ -z "$code" ]; then
echo "无法解析登录信息,原始输出:" >&2
printf '%s\n' "$json" >&2
return 1
fi

echo "" >&2
echo "用你自己的手机飞书扫下面的码(别用浏览器,容易串成别人的账号):" >&2
echo "" >&2
"$BIN" auth qrcode --ascii "$url"
echo "" >&2
echo "扫不了的话再用链接:$url" >&2
echo "" >&2

"$BIN" auth login --device-code "$code"
}

require_bin() {
[ -x "$BIN" ] || {
echo "还没安装,先在仓库根目录跑一次:./env/larkenv setup" >&2
exit 1
}
}

# ---------- 入口 ----------
[ $# -ge 1 ] || usage 1

case "$1" in
setup)
do_setup
;;
qr)
# Renders the device-flow URL as a terminal QR code. The CLI never opens a
# browser itself, so scanning with your own Feishu app is the way to avoid
# authorizing as whichever account the default browser happens to hold.
[ $# -eq 2 ] || {
echo "用法: larkenv qr <登录URL>" >&2
exit 1
}
require_bin
exec "$BIN" auth qrcode --ascii "$2"
;;
init)
# With an app id, config init runs fully non-interactively — no app-selection
# prompt at all. boe/pre can reuse the online app id, so there is usually no
# reason to register a separate app per environment.
case $# in
2 | 3) ;;
*)
echo "用法: larkenv init <boe|pre|online> [app-id]" >&2
exit 1
;;
esac
require_bin
apply_env "$2"

if [ $# -eq 3 ]; then
printf 'App Secret (输入不回显): ' >&2
stty -echo 2>/dev/null || true
read -r secret
stty echo 2>/dev/null || true
printf '\n' >&2
# --app-secret-stdin keeps the secret out of the process list.
printf '%s' "$secret" | "$BIN" config init --app-id "$3" --app-secret-stdin
unset secret
Comment on lines +187 to +194

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve the secret and always restore terminal echo.

read -r trims leading/trailing IFS whitespace, and an interrupt after stty -echo exits with echo disabled. Use IFS= read -r secret and a cleanup trap that restores echo on exit/signals.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@env/larkenv` around lines 187 - 194, Update the secret-input flow in the
app-secret configuration block to use IFS= read -r so leading and trailing
whitespace is preserved. Add cleanup trap handling around stty -echo that
restores terminal echo on normal completion and signals, while retaining the
existing stdin-based secret submission and cleanup.

else
echo "提示:已有 app 时可直接 'larkenv init $2 <app-id>' 跳过选择流程。" >&2
"$BIN" config init
fi

# 固定成 user 身份,之后所有命令都不用再带 --as user。
"$BIN" config default-as user
do_login
;;
login)
# 单独补登录:app 配好了、只是授权失败或过期时用这个,不用重跑 init。
[ $# -eq 2 ] || {
echo "用法: larkenv login <boe|pre|online>" >&2
exit 1
}
require_bin
apply_env "$2"
do_login
;;
boe | pre | online)
require_bin
env_name="$1"
shift
apply_env "$env_name"
exec "$BIN" "$@"
;;
-h | --help | help)
usage 0
;;
*)
echo "未知命令 '$1'(可用: boe / pre / online / setup / init / login / qr)" >&2
exit 1
;;
esac
81 changes: 81 additions & 0 deletions internal/cmdutil/debug_header_transport.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package cmdutil

import (
"fmt"
"io"
"net/http"
"os"
"sort"
"strings"

"github.com/larksuite/cli/internal/transport"
)

// DebugHeadersEnv, when set to a truthy value, makes DebugHeaderTransport dump
// the outbound request line and headers to stderr. It sits at the innermost
// position of the transport chain, so what it prints is what actually goes on
// the wire — after every other layer has contributed its headers.
const DebugHeadersEnv = "LARK_CLI_DEBUG_HEADERS"

// sensitiveHeaders are redacted in the dump; their presence is still reported.
var sensitiveHeaders = map[string]bool{
"authorization": true,
"x-lark-mcp-uat": true,
"x-lark-mcp-tat": true,
"cookie": true,
"x-larksuite-key": true,
}
Comment on lines +23 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Redact Proxy-Authorization too.

A request carrying that standard credential header is printed verbatim when debug logging is enabled, contradicting the redaction guarantee. Add it to sensitiveHeaders and cover it with a regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cmdutil/debug_header_transport.go` around lines 23 - 30, Add
"proxy-authorization" to the sensitiveHeaders map used by the debug transport so
this credential is redacted while its presence remains reported. Add a
regression test covering a request with Proxy-Authorization and verify the debug
output does not expose its value.

Source: Coding guidelines


// DebugHeaderTransport dumps outbound request headers when DebugHeadersEnv is set.
type DebugHeaderTransport struct {
Base http.RoundTripper
Out io.Writer // defaults to os.Stderr
}

func (t *DebugHeaderTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return transport.Fallback()
}

func (t *DebugHeaderTransport) out() io.Writer {
if t.Out != nil {
return t.Out
}
return os.Stderr
}

func debugHeadersEnabled() bool {
v := strings.TrimSpace(os.Getenv(DebugHeadersEnv))
return v != "" && v != "0"
}

// RoundTrip implements http.RoundTripper.
func (t *DebugHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if !debugHeadersEnabled() {
return t.base().RoundTrip(req)
}

var b strings.Builder
fmt.Fprintf(&b, "[lark-cli] --> %s %s\n", req.Method, req.URL.String())

keys := make([]string, 0, len(req.Header))
for k := range req.Header {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
val := strings.Join(req.Header[k], ", ")
if sensitiveHeaders[strings.ToLower(k)] {
val = fmt.Sprintf("<redacted, %d chars>", len(val))
}
fmt.Fprintf(&b, "[lark-cli] %s: %s\n", k, val)
}
fmt.Fprint(t.out(), b.String())

return t.base().RoundTrip(req)
}
62 changes: 62 additions & 0 deletions internal/cmdutil/env_header_transport.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package cmdutil

import (
"net/http"
"os"
"strings"

"github.com/larksuite/cli/internal/transport"
)

// ExtraHeadersEnv is the environment variable carrying extra request headers,
// formatted as "Key: Value" pairs separated by ";"
// (e.g. "X-TT-ENV: boe_lane; X-Other: 1"). Primarily used to select a
// boe/pre swimlane via X-TT-ENV.
const ExtraHeadersEnv = "LARKSUITE_CLI_EXTRA_HEADERS"

// parseExtraHeaders parses an ExtraHeadersEnv value into a header map.
// Entries without a colon are skipped.
func parseExtraHeaders(raw string) map[string]string {
out := map[string]string{}
for _, part := range strings.Split(raw, ";") {
i := strings.Index(part, ":")
if i < 0 {
continue
}
k := strings.TrimSpace(part[:i])
v := strings.TrimSpace(part[i+1:])
if k != "" {
out[k] = v
}
}
return out
Comment on lines +22 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject malformed extra-header configuration instead of silently changing it.

Malformed segments and blank keys are currently discarded, so a misspelled lane header can send requests without the requested routing. Return a typed validation error and test that failure.

  • internal/cmdutil/env_header_transport.go#L22-L35: make malformed segments/header names fail instead of skipping them.
  • internal/cmdutil/env_header_transport_test.go#L26-L31: replace silent-skip expectations with assertions for the validation error.
📍 Affects 2 files
  • internal/cmdutil/env_header_transport.go#L22-L35 (this comment)
  • internal/cmdutil/env_header_transport_test.go#L26-L31
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cmdutil/env_header_transport.go` around lines 22 - 35, Update
parseExtraHeaders in internal/cmdutil/env_header_transport.go to reject
malformed segments and blank header names by returning the established typed
validation error instead of silently skipping them; adjust
internal/cmdutil/env_header_transport_test.go lines 26-31 to assert that
malformed input returns this validation error rather than being omitted.

Source: Coding guidelines

}

// EnvHeaderTransport is an http.RoundTripper that injects headers declared in
// ExtraHeadersEnv into every request. It is a no-op when the variable is unset.
type EnvHeaderTransport struct {
Base http.RoundTripper
}

func (t *EnvHeaderTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return transport.Fallback()
}

// RoundTrip implements http.RoundTripper.
func (t *EnvHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
headers := parseExtraHeaders(os.Getenv(ExtraHeadersEnv))
if len(headers) == 0 {
return t.base().RoundTrip(req)
}
req = req.Clone(req.Context())
for k, v := range headers {
req.Header.Set(k, v)
}
return t.base().RoundTrip(req)
}
Loading
Loading