mirror of
https://github.com/zgs225/cliproxy-plugin-commandcode.git
synced 2026-09-26 11:42:48 +08:00
Compare commits
3
Commits
v0.4.3
...
d7733552ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7733552ee | ||
|
|
c2b8d89ba6 | ||
|
|
9d1884cf20 |
@@ -40,7 +40,7 @@
|
|||||||
- 支持在 `config.yaml` 配置或在配额页面上直接输入。
|
- 支持在 `config.yaml` 配置或在配额页面上直接输入。
|
||||||
- 支持纯 token 或完整 Cookie 字符串(自动提取 `__Secure-commandcode_prod_.session_token`)。
|
- 支持纯 token 或完整 Cookie 字符串(自动提取 `__Secure-commandcode_prod_.session_token`)。
|
||||||
4. **精确用量与双滑动窗口限额解析**:
|
4. **精确用量与双滑动窗口限额解析**:
|
||||||
- 上游接口:`GET https://api.commandcode.ai/internal/billing/credits`。
|
- 上游接口(v0.5.0+):配置 `commandcode_api_key`(Provider API key,长期凭据)时走 `GET https://api.commandcode.ai/alpha/billing/credits` 与 `/alpha/usage/summary`(Bearer 认证);否则回退 session cookie 查 `/internal/billing/credits`。
|
||||||
- 请求优先走宿主提供的 `host.http.do` 回调(复用宿主代理、日志与鉴权管道),离线或未注入宿主时自动无缝降级至 Go 标准 `net/http`。
|
- 请求优先走宿主提供的 `host.http.do` 回调(复用宿主代理、日志与鉴权管道),离线或未注入宿主时自动无缝降级至 Go 标准 `net/http`。
|
||||||
- 全面解析 `credits`(月度基础额度、开源奖励额度、总可用额度)与 `windowLimits`(5小时短期滑动窗口、周度窗口限额,计算已用量、上限、剩余量、使用百分比及重置时间)。
|
- 全面解析 `credits`(月度基础额度、开源奖励额度、总可用额度)与 `windowLimits`(5小时短期滑动窗口、周度窗口限额,计算已用量、上限、剩余量、使用百分比及重置时间)。
|
||||||
5. **嵌入式纯单文件 QuotaCard 资源页**:
|
5. **嵌入式纯单文件 QuotaCard 资源页**:
|
||||||
@@ -132,7 +132,8 @@ plugins:
|
|||||||
commandcode:
|
commandcode:
|
||||||
enabled: true
|
enabled: true
|
||||||
priority: 1
|
priority: 1
|
||||||
session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # 支持纯 token 或完整 Cookie 字符串
|
session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # v0.4.5 前唯一凭据;v0.5.0 起为可选回退
|
||||||
|
commandcode_api_key: "user_YOUR_COMMANDCODE_PROVIDER_KEY" # v0.5.0+ 推荐:非空则用量查询走 /alpha 端点(Bearer),无需 session cookie
|
||||||
api_base: "https://api.commandcode.ai" # 可选,默认为官方接口
|
api_base: "https://api.commandcode.ai" # 可选,默认为官方接口
|
||||||
opencode_api_key: "sk-YOUR_OPENCODE_GO_API_KEY" # 可选(单 key 兑底,v0.3.0+)
|
opencode_api_key: "sk-YOUR_OPENCODE_GO_API_KEY" # 可选(单 key 兑底,v0.3.0+)
|
||||||
# v0.4.0+ 多 key:list 优先于单 key 字段,每 key 独立账号独立配额窗口
|
# v0.4.0+ 多 key:list 优先于单 key 字段,每 key 独立账号独立配额窗口
|
||||||
|
|||||||
+59
-12
@@ -157,7 +157,15 @@ func handleGetUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfi
|
|||||||
apiBase = cfg.GetAPIBase()
|
apiBase = cfg.GetAPIBase()
|
||||||
}
|
}
|
||||||
|
|
||||||
return executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
|
// commandcode_api_key is NOT overridable via query parameters (same
|
||||||
|
// secrets-out-of-URLs policy as the OpenCode handler): the plugin
|
||||||
|
// config is the only credential source on GET.
|
||||||
|
apiKey := ""
|
||||||
|
if cfg != nil {
|
||||||
|
apiKey = cfg.GetCommandCodeAPIKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
return executeUsageQuery(ctx, apiBase, apiKey, sessionToken, req.HostCallbackID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
|
func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
|
||||||
@@ -165,6 +173,7 @@ func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConf
|
|||||||
SessionToken string `json:"session_token"`
|
SessionToken string `json:"session_token"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
APIBase string `json:"api_base"`
|
APIBase string `json:"api_base"`
|
||||||
|
CommandCodeAPIKey string `json:"commandcode_api_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(req.Body) > 0 {
|
if len(req.Body) > 0 {
|
||||||
@@ -176,6 +185,7 @@ func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConf
|
|||||||
sessionToken = body.Token
|
sessionToken = body.Token
|
||||||
}
|
}
|
||||||
apiBase := body.APIBase
|
apiBase := body.APIBase
|
||||||
|
apiKey := body.CommandCodeAPIKey
|
||||||
|
|
||||||
// Fallback to plugin config if body didn't specify
|
// Fallback to plugin config if body didn't specify
|
||||||
if sessionToken == "" && cfg != nil {
|
if sessionToken == "" && cfg != nil {
|
||||||
@@ -184,12 +194,22 @@ func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConf
|
|||||||
if apiBase == "" && cfg != nil {
|
if apiBase == "" && cfg != nil {
|
||||||
apiBase = cfg.GetAPIBase()
|
apiBase = cfg.GetAPIBase()
|
||||||
}
|
}
|
||||||
|
if apiKey == "" && cfg != nil {
|
||||||
return executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
|
apiKey = cfg.GetCommandCodeAPIKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackID string) (ManagementResponse, error) {
|
return executeUsageQuery(ctx, apiBase, apiKey, sessionToken, req.HostCallbackID)
|
||||||
if strings.TrimSpace(sessionToken) == "" {
|
}
|
||||||
|
|
||||||
|
func executeUsageQuery(ctx context.Context, apiBase, apiKey, sessionToken, hostCallbackID string) (ManagementResponse, error) {
|
||||||
|
apiKey = strings.TrimSpace(apiKey)
|
||||||
|
|
||||||
|
// Credential priority: commandcode_api_key non-empty → /alpha + Bearer
|
||||||
|
// (Provider API key, no cookie); otherwise session_token → /internal
|
||||||
|
// + Cookie (legacy fallback). Neither present → 400 with the
|
||||||
|
// "session_token is required" prefix (isLocalCredentialError in the
|
||||||
|
// /all aggregate depends on this message).
|
||||||
|
if strings.TrimSpace(sessionToken) == "" && apiKey == "" {
|
||||||
resBytes, _ := json.Marshal(map[string]any{
|
resBytes, _ := json.Marshal(map[string]any{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
"error": "session_token is required. Configure session_token in plugin config, provide a credential file, or pass session_token in request",
|
"error": "session_token is required. Configure session_token in plugin config, provide a credential file, or pass session_token in request",
|
||||||
@@ -203,7 +223,14 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, statusCode, errFetch := FetchCreditsRaw(ctx, apiBase, sessionToken, hostCallbackID)
|
var raw []byte
|
||||||
|
var statusCode int
|
||||||
|
var errFetch error
|
||||||
|
if apiKey != "" {
|
||||||
|
raw, statusCode, errFetch = FetchCommandCodeCreditsAlphaRaw(ctx, apiBase, apiKey, hostCallbackID)
|
||||||
|
} else {
|
||||||
|
raw, statusCode, errFetch = FetchCreditsRaw(ctx, apiBase, sessionToken, hostCallbackID)
|
||||||
|
}
|
||||||
if errFetch != nil {
|
if errFetch != nil {
|
||||||
resBytes, _ := json.Marshal(map[string]any{
|
resBytes, _ := json.Marshal(map[string]any{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
@@ -223,12 +250,19 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
|
|||||||
}
|
}
|
||||||
|
|
||||||
if statusCode != http.StatusOK {
|
if statusCode != http.StatusOK {
|
||||||
resBytes, _ := json.Marshal(map[string]any{
|
payload := map[string]any{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
"status_code": statusCode,
|
"status_code": statusCode,
|
||||||
"error": "upstream returned non-200 status",
|
}
|
||||||
"body": string(raw),
|
if apiKey != "" {
|
||||||
})
|
// Alpha path: do NOT echo the upstream body; point at the
|
||||||
|
// configured Provider API key instead.
|
||||||
|
payload["error"] = fmt.Sprintf("commandcode upstream returned %d: check commandcode_api_key", statusCode)
|
||||||
|
} else {
|
||||||
|
payload["error"] = "upstream returned non-200 status"
|
||||||
|
payload["body"] = string(raw)
|
||||||
|
}
|
||||||
|
resBytes, _ := json.Marshal(payload)
|
||||||
return ManagementResponse{
|
return ManagementResponse{
|
||||||
StatusCode: statusCode,
|
StatusCode: statusCode,
|
||||||
Headers: map[string][]string{
|
Headers: map[string][]string{
|
||||||
@@ -240,7 +274,14 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
|
|||||||
|
|
||||||
// Fetch billing-period (monthly) usage totals; non-fatal if unavailable.
|
// Fetch billing-period (monthly) usage totals; non-fatal if unavailable.
|
||||||
var summary *UpstreamUsageSummaryResponse
|
var summary *UpstreamUsageSummaryResponse
|
||||||
if sumRaw, sumStatus, sumErr := FetchUsageSummaryRaw(ctx, apiBase, sessionToken, hostCallbackID); sumErr == nil && sumStatus == http.StatusOK {
|
if apiKey != "" {
|
||||||
|
if sumRaw, sumStatus, sumErr := FetchCommandCodeUsageSummaryAlphaRaw(ctx, apiBase, apiKey, hostCallbackID); sumErr == nil && sumStatus == http.StatusOK {
|
||||||
|
var parsed UpstreamUsageSummaryResponse
|
||||||
|
if errSum := json.Unmarshal(sumRaw, &parsed); errSum == nil && parsed.TotalMonthlyCredits > 0 {
|
||||||
|
summary = &parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if sumRaw, sumStatus, sumErr := FetchUsageSummaryRaw(ctx, apiBase, sessionToken, hostCallbackID); sumErr == nil && sumStatus == http.StatusOK {
|
||||||
var parsed UpstreamUsageSummaryResponse
|
var parsed UpstreamUsageSummaryResponse
|
||||||
if errSum := json.Unmarshal(sumRaw, &parsed); errSum == nil && parsed.TotalMonthlyCredits > 0 {
|
if errSum := json.Unmarshal(sumRaw, &parsed); errSum == nil && parsed.TotalMonthlyCredits > 0 {
|
||||||
summary = &parsed
|
summary = &parsed
|
||||||
@@ -369,16 +410,19 @@ func handleOpenCodeUsage(ctx context.Context, req ManagementRequest, cfg *Plugin
|
|||||||
// all failed due to upstream errors → 502.
|
// all failed due to upstream errors → 502.
|
||||||
func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
|
func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
|
||||||
sessionToken := ""
|
sessionToken := ""
|
||||||
|
commandcodeAPIKey := ""
|
||||||
opencodeKeys := []string{}
|
opencodeKeys := []string{}
|
||||||
|
|
||||||
if req.Method == http.MethodPost && len(req.Body) > 0 {
|
if req.Method == http.MethodPost && len(req.Body) > 0 {
|
||||||
var body struct {
|
var body struct {
|
||||||
SessionToken string `json:"session_token"`
|
SessionToken string `json:"session_token"`
|
||||||
|
CommandCodeAPIKey string `json:"commandcode_api_key"`
|
||||||
OpencodeAPIKeys []string `json:"opencode_api_keys"`
|
OpencodeAPIKeys []string `json:"opencode_api_keys"`
|
||||||
OpencodeAPIKey string `json:"opencode_api_key"`
|
OpencodeAPIKey string `json:"opencode_api_key"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(req.Body, &body)
|
_ = json.Unmarshal(req.Body, &body)
|
||||||
sessionToken = body.SessionToken
|
sessionToken = body.SessionToken
|
||||||
|
commandcodeAPIKey = body.CommandCodeAPIKey
|
||||||
opencodeKeys = normalizeOpenCodeKeys(body.OpencodeAPIKeys)
|
opencodeKeys = normalizeOpenCodeKeys(body.OpencodeAPIKeys)
|
||||||
if len(opencodeKeys) == 0 {
|
if len(opencodeKeys) == 0 {
|
||||||
if single := strings.TrimSpace(body.OpencodeAPIKey); single != "" {
|
if single := strings.TrimSpace(body.OpencodeAPIKey); single != "" {
|
||||||
@@ -391,6 +435,9 @@ func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfi
|
|||||||
if sessionToken == "" && cfg != nil {
|
if sessionToken == "" && cfg != nil {
|
||||||
sessionToken = cfg.GetSessionToken()
|
sessionToken = cfg.GetSessionToken()
|
||||||
}
|
}
|
||||||
|
if commandcodeAPIKey == "" && cfg != nil {
|
||||||
|
commandcodeAPIKey = cfg.GetCommandCodeAPIKey()
|
||||||
|
}
|
||||||
if len(opencodeKeys) == 0 && cfg != nil {
|
if len(opencodeKeys) == 0 && cfg != nil {
|
||||||
opencodeKeys = cfg.GetOpenCodeAPIKeys()
|
opencodeKeys = cfg.GetOpenCodeAPIKeys()
|
||||||
}
|
}
|
||||||
@@ -411,7 +458,7 @@ func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfi
|
|||||||
succeeded := 0
|
succeeded := 0
|
||||||
|
|
||||||
// Provider 1: Command Code (reuses executeUsageQuery).
|
// Provider 1: Command Code (reuses executeUsageQuery).
|
||||||
ccResp, _ := executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
|
ccResp, _ := executeUsageQuery(ctx, apiBase, commandcodeAPIKey, sessionToken, req.HostCallbackID)
|
||||||
if ccResp.StatusCode == http.StatusOK {
|
if ccResp.StatusCode == http.StatusOK {
|
||||||
resp.CommandCode = ccResp.Body
|
resp.CommandCode = ccResp.Body
|
||||||
succeeded++
|
succeeded++
|
||||||
|
|||||||
+366
-3
@@ -3,6 +3,7 @@ package plugin
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -74,8 +75,8 @@ func TestHandleManagement_QuotaResource(t *testing.T) {
|
|||||||
if !strings.Contains(bodyStr, "用量配额") {
|
if !strings.Contains(bodyStr, "用量配额") {
|
||||||
t.Errorf("Body does not contain expected menu text 用量配额")
|
t.Errorf("Body does not contain expected menu text 用量配额")
|
||||||
}
|
}
|
||||||
if !strings.Contains(bodyStr, "v0.4.3") {
|
if !strings.Contains(bodyStr, "v0.5.0") {
|
||||||
t.Errorf("Body does not contain version badge v0.4.3")
|
t.Errorf("Body does not contain version badge v0.5.0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,6 +176,24 @@ const mockOpencodeUsageJSON = `{"usage":{
|
|||||||
"monthly": {"status":"ok","percent":23,"resetsAt":"2026-10-14T09:13:49.000Z"}
|
"monthly": {"status":"ok","percent":23,"resetsAt":"2026-10-14T09:13:49.000Z"}
|
||||||
}}`
|
}}`
|
||||||
|
|
||||||
|
// mockOpencodeUsageJSONFuture returns the same usage envelope but with reset
|
||||||
|
// timestamps relative to now. The hardcoded dates in mockOpencodeUsageJSON
|
||||||
|
// eventually fall into the past (weekly 2026-09-21 did), which makes
|
||||||
|
// weekly.ResetInSeconds = 0 and breaks the `want > 0` assertion in
|
||||||
|
// TestHandleManagement_OpencodeUsageRoute. Use this fixture for tests that
|
||||||
|
// assert positive reset_in_seconds.
|
||||||
|
func mockOpencodeUsageJSONFuture() string {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
ts := func(d time.Duration) string {
|
||||||
|
return now.Add(d).Format("2006-01-02T15:04:05.000Z")
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`{"usage":{
|
||||||
|
"rolling": {"status":"ok","percent":4, "resetsAt":"%s"},
|
||||||
|
"weekly": {"status":"ok","percent":46,"resetsAt":"%s"},
|
||||||
|
"monthly": {"status":"ok","percent":23,"resetsAt":"%s"}
|
||||||
|
}}`, ts(2*time.Hour), ts(72*time.Hour), ts(30*24*time.Hour))
|
||||||
|
}
|
||||||
|
|
||||||
// Verifies that /plugins/commandcode/opencode/usage is matched by the dedicated
|
// Verifies that /plugins/commandcode/opencode/usage is matched by the dedicated
|
||||||
// OpenCode handler and NOT swallowed by the generic "/usage" suffix match
|
// OpenCode handler and NOT swallowed by the generic "/usage" suffix match
|
||||||
// (which would route it to the Command Code handler).
|
// (which would route it to the Command Code handler).
|
||||||
@@ -194,7 +213,7 @@ func TestHandleManagement_OpencodeUsageRoute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
sawAuthHeader = true
|
sawAuthHeader = true
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(mockOpencodeUsageJSON))
|
_, _ = w.Write([]byte(mockOpencodeUsageJSONFuture()))
|
||||||
}))
|
}))
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
@@ -801,3 +820,347 @@ func TestHandleManagement_UnknownPath(t *testing.T) {
|
|||||||
t.Fatalf("StatusCode = %d, want 404", resp.StatusCode)
|
t.Fatalf("StatusCode = %d, want 404", resp.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- v0.5.0: commandcode_api_key → /alpha + Bearer path ----
|
||||||
|
|
||||||
|
// mockAlphaCreditsJSON omits opensourceMonthlyCredits, matching the real
|
||||||
|
// /alpha/billing/credits payload shape (field difference vs /internal).
|
||||||
|
const mockAlphaCreditsJSON = `{"credits":{"monthlyCredits":555},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}`
|
||||||
|
|
||||||
|
// newAlphaTestServer: /alpha/billing/credits and /alpha/usage/summary both
|
||||||
|
// assert Bearer auth and the absence of a Cookie header; every other path
|
||||||
|
// fails the test (regression guard against falling back to /internal).
|
||||||
|
func newAlphaTestServer(t *testing.T, wantKey string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer "+wantKey {
|
||||||
|
t.Errorf("upstream Authorization = %q, want Bearer %s", got, wantKey)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Cookie"); got != "" {
|
||||||
|
t.Errorf("upstream Cookie = %q, want none on the /alpha path", got)
|
||||||
|
}
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/alpha/billing/credits":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(mockAlphaCreditsJSON))
|
||||||
|
case "/alpha/usage/summary":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 40}`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected upstream request: %s %s (internal path must not be hit when commandcode_api_key is set)", r.Method, r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /usage with commandcode_api_key configured → both upstream calls hit
|
||||||
|
// /alpha/* with Bearer auth and no Cookie; response parses with the alpha
|
||||||
|
// payload (total = monthly when opensource field is absent) and the monthly
|
||||||
|
// window is derived from the /alpha summary.
|
||||||
|
func TestHandleManagement_GetUsage_AlphaKey(t *testing.T) {
|
||||||
|
ts := newAlphaTestServer(t, "user_cfg-key")
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(ts.Client())
|
||||||
|
defer func() {
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}()
|
||||||
|
|
||||||
|
cfg := &PluginConfig{
|
||||||
|
CommandCodeAPIKey: "user_cfg-key",
|
||||||
|
APIBase: ts.URL,
|
||||||
|
}
|
||||||
|
req := ManagementRequest{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/v0/management/plugins/commandcode/usage",
|
||||||
|
}
|
||||||
|
resp, err := HandleManagement(context.Background(), req, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleManagement error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var usage FormattedUsageResponse
|
||||||
|
if err := json.Unmarshal(resp.Body, &usage); err != nil {
|
||||||
|
t.Fatalf("unmarshal body error: %v", err)
|
||||||
|
}
|
||||||
|
if !usage.OK {
|
||||||
|
t.Fatal("expected OK=true")
|
||||||
|
}
|
||||||
|
if usage.Credits.MonthlyCredits != 555 || usage.Credits.TotalCredits != 555 {
|
||||||
|
t.Errorf("credits = %+v, want monthly=555 total=555 (opensource absent in alpha payload)", usage.Credits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /usage: body commandcode_api_key overrides the configured key (the
|
||||||
|
// config key gets a 401 from the mock, so a 200 proves the body key won).
|
||||||
|
func TestHandleManagement_PostUsage_AlphaKeyBodyOverridesConfig(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Header.Get("Authorization") {
|
||||||
|
case "Bearer user_body-key":
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/alpha/billing/credits":
|
||||||
|
_, _ = w.Write([]byte(mockAlphaCreditsJSON))
|
||||||
|
case "/alpha/usage/summary":
|
||||||
|
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 40}`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Errorf("upstream got Authorization %q — config key must lose to the POST body key", r.Header.Get("Authorization"))
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(ts.Client())
|
||||||
|
defer func() {
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}()
|
||||||
|
|
||||||
|
reqBody, _ := json.Marshal(map[string]string{
|
||||||
|
"commandcode_api_key": "user_body-key",
|
||||||
|
"api_base": ts.URL,
|
||||||
|
})
|
||||||
|
req := ManagementRequest{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/plugins/commandcode/usage",
|
||||||
|
Body: reqBody,
|
||||||
|
}
|
||||||
|
cfg := &PluginConfig{CommandCodeAPIKey: "user_cfg-must-lose", APIBase: ts.URL}
|
||||||
|
resp, err := HandleManagement(context.Background(), req, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleManagement error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("StatusCode = %d, want 200 (body key overrides config), body=%s", resp.StatusCode, string(resp.Body))
|
||||||
|
}
|
||||||
|
var usage FormattedUsageResponse
|
||||||
|
if err := json.Unmarshal(resp.Body, &usage); err != nil || !usage.OK {
|
||||||
|
t.Errorf("unexpected response: err=%v usage=%+v", err, usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: without commandcode_api_key the legacy /internal + Cookie path
|
||||||
|
// is preserved; the /alpha endpoints must never be requested.
|
||||||
|
func TestHandleManagement_Usage_FallbackToInternalCookie(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/alpha/") {
|
||||||
|
t.Errorf("unexpected /alpha request %s — must stay on /internal without commandcode_api_key", r.URL.Path)
|
||||||
|
}
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/internal/billing/credits":
|
||||||
|
if !strings.Contains(r.Header.Get("Cookie"), "legacy-cookie-token") {
|
||||||
|
t.Errorf("Cookie = %q, want the session token cookie", r.Header.Get("Cookie"))
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits": 321},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}`))
|
||||||
|
case "/internal/usage/summary":
|
||||||
|
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 11}`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(ts.Client())
|
||||||
|
defer func() {
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}()
|
||||||
|
|
||||||
|
cfg := &PluginConfig{SessionToken: "legacy-cookie-token", APIBase: ts.URL}
|
||||||
|
|
||||||
|
t.Run("GET falls back to internal", func(t *testing.T) {
|
||||||
|
req := ManagementRequest{Method: http.MethodGet, Path: "/plugins/commandcode/usage"}
|
||||||
|
resp, err := HandleManagement(context.Background(), req, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleManagement error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("POST falls back to internal", func(t *testing.T) {
|
||||||
|
reqBody, _ := json.Marshal(map[string]string{"session_token": "legacy-cookie-token", "api_base": ts.URL})
|
||||||
|
req := ManagementRequest{Method: http.MethodPost, Path: "/plugins/commandcode/usage", Body: reqBody}
|
||||||
|
resp, err := HandleManagement(context.Background(), req, &PluginConfig{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleManagement error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET must NOT honor a commandcode_api_key query parameter (same
|
||||||
|
// secrets-out-of-URLs policy as the OpenCode handler): the config's
|
||||||
|
// session_token path is used and the query-provided key is ignored.
|
||||||
|
func TestHandleManagement_GetUsage_NoQueryKeyOverride(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/alpha/billing/credits" || r.URL.Path == "/alpha/usage/summary" {
|
||||||
|
t.Errorf("unexpected /alpha request %s — query override of commandcode_api_key is not supported", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/internal/billing/credits" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Header.Get("Cookie") == "" {
|
||||||
|
t.Errorf("Cookie = %q, want the configured session token cookie", r.Header.Get("Cookie"))
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits": 77},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(ts.Client())
|
||||||
|
defer func() {
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}()
|
||||||
|
|
||||||
|
cfg := &PluginConfig{SessionToken: "configured-cookie-token", APIBase: ts.URL}
|
||||||
|
req := ManagementRequest{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/v0/management/plugins/commandcode/usage",
|
||||||
|
Query: map[string][]string{
|
||||||
|
"commandcode_api_key": {"user_query-must-be-ignored"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp, err := HandleManagement(context.Background(), req, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleManagement error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alpha upstream non-200 → statusCode passed through, message points at
|
||||||
|
// commandcode_api_key, and the upstream body is NOT echoed (unlike the
|
||||||
|
// internal path).
|
||||||
|
func TestHandleManagement_Usage_AlphaUpstreamNon200(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"upstream secret detail xyzzy"}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(ts.Client())
|
||||||
|
defer func() {
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}()
|
||||||
|
|
||||||
|
cfg := &PluginConfig{CommandCodeAPIKey: "user_bad-key", APIBase: ts.URL}
|
||||||
|
resp, err := HandleManagement(context.Background(), ManagementRequest{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/plugins/commandcode/usage",
|
||||||
|
}, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleManagement error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("StatusCode = %d, want 401 (upstream status passed through)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errResp struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp.Body, &errResp); err != nil {
|
||||||
|
t.Fatalf("unmarshal error body: %v", err)
|
||||||
|
}
|
||||||
|
if errResp.OK || errResp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("error payload = %+v, want ok=false status_code=401", errResp)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errResp.Error, "commandcode upstream returned 401: check commandcode_api_key") {
|
||||||
|
t.Errorf("error = %q, want the commandcode_api_key hint message", errResp.Error)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(resp.Body), "upstream secret detail") {
|
||||||
|
t.Errorf("alpha branch must not echo the upstream body, got: %s", string(resp.Body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither credential → 400 with the preserved "session_token is required"
|
||||||
|
// prefix (isLocalCredentialError in /all depends on it).
|
||||||
|
func TestHandleManagement_Usage_NoCredentialsStillSessionTokenMessage(t *testing.T) {
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
defer func() {
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}()
|
||||||
|
|
||||||
|
resp, err := HandleManagement(context.Background(), ManagementRequest{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/plugins/commandcode/usage",
|
||||||
|
}, &PluginConfig{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleManagement error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Fatalf("StatusCode = %d, want 400, body=%s", resp.StatusCode, string(resp.Body))
|
||||||
|
}
|
||||||
|
if msg := extractErrorResponseMessage(resp.Body); !strings.HasPrefix(msg, "session_token is required") {
|
||||||
|
t.Errorf("error = %q, want the preserved 'session_token is required' prefix", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /all: POST body commandcode_api_key overrides the configured session_token
|
||||||
|
// (and config key) — the Command Code provider goes through /alpha + Bearer.
|
||||||
|
func TestHandleManagement_AllUsage_CommandCodeAPIKeyOverride(t *testing.T) {
|
||||||
|
ts := newAlphaTestServer(t, "user_all-key")
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(ts.Client())
|
||||||
|
defer func() {
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}()
|
||||||
|
|
||||||
|
cfg := &PluginConfig{
|
||||||
|
SessionToken: "cfg-token-must-lose",
|
||||||
|
APIBase: ts.URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody, _ := json.Marshal(map[string]string{"commandcode_api_key": "user_all-key"})
|
||||||
|
req := ManagementRequest{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/plugins/commandcode/all",
|
||||||
|
Body: reqBody,
|
||||||
|
}
|
||||||
|
resp, err := HandleManagement(context.Background(), req, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleManagement error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("StatusCode = %d, want 200 (commandcode succeeded via /alpha), body=%s", resp.StatusCode, string(resp.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var all AllUsageResponse
|
||||||
|
if err := json.Unmarshal(resp.Body, &all); err != nil {
|
||||||
|
t.Fatalf("unmarshal error: %v", err)
|
||||||
|
}
|
||||||
|
if !all.OK {
|
||||||
|
t.Error("expected ok=true")
|
||||||
|
}
|
||||||
|
var ccUsage FormattedUsageResponse
|
||||||
|
if err := json.Unmarshal(all.CommandCode, &ccUsage); err != nil || !ccUsage.OK {
|
||||||
|
t.Errorf("commandcode payload invalid: err=%v usage=%+v", err, ccUsage)
|
||||||
|
}
|
||||||
|
if ccUsage.Credits.TotalCredits != 555 {
|
||||||
|
t.Errorf("commandcode total_credits = %v, want 555 (alpha payload)", ccUsage.Credits.TotalCredits)
|
||||||
|
}
|
||||||
|
// OpenCode key missing → local-credential error for that provider only.
|
||||||
|
if _, present := all.Errors["opencode"]; !present {
|
||||||
|
t.Errorf("expected errors[opencode], got %v", all.Errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+22
-1
@@ -13,7 +13,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
PluginID = "commandcode"
|
PluginID = "commandcode"
|
||||||
PluginName = "commandcode"
|
PluginName = "commandcode"
|
||||||
PluginVersion = "0.4.3"
|
PluginVersion = "0.5.0"
|
||||||
PluginAuthor = "zgs225"
|
PluginAuthor = "zgs225"
|
||||||
PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode"
|
PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode"
|
||||||
PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg"
|
PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg"
|
||||||
@@ -24,6 +24,7 @@ const (
|
|||||||
type PluginConfig struct {
|
type PluginConfig struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
SessionToken string `yaml:"session_token" json:"session_token"`
|
SessionToken string `yaml:"session_token" json:"session_token"`
|
||||||
|
CommandCodeAPIKey string `yaml:"commandcode_api_key" json:"commandcode_api_key"`
|
||||||
APIBase string `yaml:"api_base" json:"api_base"`
|
APIBase string `yaml:"api_base" json:"api_base"`
|
||||||
OpenCodeAPIKey string `yaml:"opencode_api_key" json:"opencode_api_key"`
|
OpenCodeAPIKey string `yaml:"opencode_api_key" json:"opencode_api_key"`
|
||||||
OpenCodeAPIKeys []string `yaml:"opencode_api_keys" json:"opencode_api_keys"`
|
OpenCodeAPIKeys []string `yaml:"opencode_api_keys" json:"opencode_api_keys"`
|
||||||
@@ -37,6 +38,7 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error {
|
|||||||
}
|
}
|
||||||
var tmp struct {
|
var tmp struct {
|
||||||
SessionToken string `yaml:"session_token"`
|
SessionToken string `yaml:"session_token"`
|
||||||
|
CommandCodeAPIKey string `yaml:"commandcode_api_key"`
|
||||||
APIBase string `yaml:"api_base"`
|
APIBase string `yaml:"api_base"`
|
||||||
OpenCodeAPIKey string `yaml:"opencode_api_key"`
|
OpenCodeAPIKey string `yaml:"opencode_api_key"`
|
||||||
OpenCodeAPIKeys []string `yaml:"opencode_api_keys"`
|
OpenCodeAPIKeys []string `yaml:"opencode_api_keys"`
|
||||||
@@ -52,6 +54,11 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error {
|
|||||||
if tmp.SessionToken != "" {
|
if tmp.SessionToken != "" {
|
||||||
c.SessionToken = ExtractSessionToken(tmp.SessionToken)
|
c.SessionToken = ExtractSessionToken(tmp.SessionToken)
|
||||||
}
|
}
|
||||||
|
if tmp.CommandCodeAPIKey != "" {
|
||||||
|
// Provider API key is a plain Bearer token; do not run it through
|
||||||
|
// ExtractSessionToken (that is Command Code cookie specific).
|
||||||
|
c.CommandCodeAPIKey = strings.TrimSpace(tmp.CommandCodeAPIKey)
|
||||||
|
}
|
||||||
if tmp.APIBase != "" {
|
if tmp.APIBase != "" {
|
||||||
c.APIBase = strings.TrimRight(tmp.APIBase, "/")
|
c.APIBase = strings.TrimRight(tmp.APIBase, "/")
|
||||||
}
|
}
|
||||||
@@ -85,6 +92,15 @@ func (c *PluginConfig) GetSessionToken() string {
|
|||||||
return c.SessionToken
|
return c.SessionToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCommandCodeAPIKey safely returns the configured Command Code Provider
|
||||||
|
// API key. When non-empty, usage queries go through the /alpha endpoints
|
||||||
|
// with Bearer auth instead of the session-cookie /internal endpoints.
|
||||||
|
func (c *PluginConfig) GetCommandCodeAPIKey() string {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.CommandCodeAPIKey
|
||||||
|
}
|
||||||
|
|
||||||
// SetSessionToken safely sets the session token.
|
// SetSessionToken safely sets the session token.
|
||||||
func (c *PluginConfig) SetSessionToken(token string) {
|
func (c *PluginConfig) SetSessionToken(token string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -219,6 +235,11 @@ func (p *Plugin) handleRegister(raw []byte) ([]byte, error) {
|
|||||||
Type: "string",
|
Type: "string",
|
||||||
Description: "Command Code session token (__Secure-commandcode_prod_.session_token cookie value)",
|
Description: "Command Code session token (__Secure-commandcode_prod_.session_token cookie value)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "commandcode_api_key",
|
||||||
|
Type: "string",
|
||||||
|
Description: "Command Code Provider API key (user_…); when set, usage queries go through the /alpha endpoints with Bearer auth — no session cookie needed",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "api_base",
|
Name: "api_base",
|
||||||
Type: "string",
|
Type: "string",
|
||||||
|
|||||||
+42
-4
@@ -46,15 +46,15 @@ api_base: "https://custom-api.commandcode.ai"
|
|||||||
t.Errorf("Capabilities.ManagementAPI = false, want true")
|
t.Errorf("Capabilities.ManagementAPI = false, want true")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify config fields (v0.4.0: 4 → 5, adds opencode_api_keys)
|
// Verify config fields (v0.5.0: 5 → 6, adds commandcode_api_key)
|
||||||
if len(reg.Metadata.ConfigFields) != 5 {
|
if len(reg.Metadata.ConfigFields) != 6 {
|
||||||
t.Fatalf("ConfigFields len = %d, want 5", len(reg.Metadata.ConfigFields))
|
t.Fatalf("ConfigFields len = %d, want 6", len(reg.Metadata.ConfigFields))
|
||||||
}
|
}
|
||||||
fieldNames := map[string]bool{}
|
fieldNames := map[string]bool{}
|
||||||
for _, f := range reg.Metadata.ConfigFields {
|
for _, f := range reg.Metadata.ConfigFields {
|
||||||
fieldNames[f.Name] = true
|
fieldNames[f.Name] = true
|
||||||
}
|
}
|
||||||
if !fieldNames["session_token"] || !fieldNames["api_base"] || !fieldNames["opencode_api_key"] || !fieldNames["opencode_api_keys"] || !fieldNames["opencode_api_base"] {
|
if !fieldNames["session_token"] || !fieldNames["commandcode_api_key"] || !fieldNames["api_base"] || !fieldNames["opencode_api_key"] || !fieldNames["opencode_api_keys"] || !fieldNames["opencode_api_base"] {
|
||||||
t.Errorf("ConfigFields missing expected fields: %+v", reg.Metadata.ConfigFields)
|
t.Errorf("ConfigFields missing expected fields: %+v", reg.Metadata.ConfigFields)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +83,44 @@ session_token: "new-token-abc"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPluginConfig_CommandCodeAPIKey(t *testing.T) {
|
||||||
|
p := NewPlugin()
|
||||||
|
// Trimmed, and NOT run through ExtractSessionToken (it is a plain
|
||||||
|
// Bearer token, not a Command Code cookie string).
|
||||||
|
configYAML := []byte("commandcode_api_key: \" user_abc123xyz \"\n")
|
||||||
|
lifecycleReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: configYAML})
|
||||||
|
if _, err := p.HandleMethod("plugin.register", lifecycleReq); err != nil {
|
||||||
|
t.Fatalf("handleMethod(plugin.register) error: %v", err)
|
||||||
|
}
|
||||||
|
if got := p.config.GetCommandCodeAPIKey(); got != "user_abc123xyz" {
|
||||||
|
t.Errorf("CommandCodeAPIKey = %q, want user_abc123xyz (trimmed, raw)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitespace-only value clears the field.
|
||||||
|
p2 := NewPlugin()
|
||||||
|
spaceReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: []byte("commandcode_api_key: \" \"\n")})
|
||||||
|
if _, err := p2.HandleMethod("plugin.register", spaceReq); err != nil {
|
||||||
|
t.Fatalf("handleMethod(plugin.register) error: %v", err)
|
||||||
|
}
|
||||||
|
if got := p2.config.GetCommandCodeAPIKey(); got != "" {
|
||||||
|
t.Errorf("CommandCodeAPIKey = %q, want empty (whitespace-only)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Omitting the key in a reconfigure must not clear a configured value.
|
||||||
|
reconfReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: []byte("session_token: \"tok\"\n")})
|
||||||
|
if _, err := p.HandleMethod("plugin.reconfigure", reconfReq); err != nil {
|
||||||
|
t.Fatalf("handleMethod(plugin.reconfigure) error: %v", err)
|
||||||
|
}
|
||||||
|
if got := p.config.GetCommandCodeAPIKey(); got != "user_abc123xyz" {
|
||||||
|
t.Errorf("CommandCodeAPIKey after reconfigure = %q, want kept user_abc123xyz", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default is empty.
|
||||||
|
if got := NewPlugin().config.GetCommandCodeAPIKey(); got != "" {
|
||||||
|
t.Errorf("default CommandCodeAPIKey = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPluginAuthIdentifier_NotHandled(t *testing.T) {
|
func TestPluginAuthIdentifier_NotHandled(t *testing.T) {
|
||||||
p := NewPlugin()
|
p := NewPlugin()
|
||||||
raw, err := p.HandleMethod("auth.identifier", nil)
|
raw, err := p.HandleMethod("auth.identifier", nil)
|
||||||
|
|||||||
+38
-5
@@ -918,7 +918,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
<div>
|
<div>
|
||||||
<div class="brand-title">
|
<div class="brand-title">
|
||||||
用量配额
|
用量配额
|
||||||
<span class="version-tag">v0.4.3</span>
|
<span class="version-tag">v0.5.0</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1234,6 +1234,8 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
const lastUpdated = document.getElementById("lastUpdated");
|
const lastUpdated = document.getElementById("lastUpdated");
|
||||||
|
|
||||||
let fiveHourTargetTime = null;
|
let fiveHourTargetTime = null;
|
||||||
|
let monthlyTargetTime = null;
|
||||||
|
|
||||||
let weeklyTargetTime = null;
|
let weeklyTargetTime = null;
|
||||||
let timerInterval = null;
|
let timerInterval = null;
|
||||||
|
|
||||||
@@ -1625,12 +1627,43 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
});
|
});
|
||||||
allBadgeCommandcode.className = "status-badge " + badgeVisualClass(cc.level);
|
allBadgeCommandcode.className = "status-badge " + badgeVisualClass(cc.level);
|
||||||
allBadgeTextCommandcode.textContent = BADGE_TEXT[cc.level] || "-";
|
allBadgeTextCommandcode.textContent = BADGE_TEXT[cc.level] || "-";
|
||||||
|
// Plan + 三个限额窗口(百分比/进度条/重置时间),行结构与 OpenCode 卡片一致,
|
||||||
|
// 倒计时走 data-reset-at/data-reset-secs 属性,由 updateTimers() 统一刷新
|
||||||
|
const ccWins = [
|
||||||
|
{ name: "Rolling 5h", o: limits.five_hour || {} },
|
||||||
|
{ name: "Weekly", o: limits.weekly || {} },
|
||||||
|
{ name: "Monthly", o: limits.monthly || {} }
|
||||||
|
];
|
||||||
|
let ccRows = "";
|
||||||
|
ccWins.forEach(function (d) {
|
||||||
|
const w = d.o;
|
||||||
|
const pct = clampPercent(w.percentage);
|
||||||
|
const level = levelOf(pct, w.exceeded, "");
|
||||||
|
let resetAt = "";
|
||||||
|
let resetSecs = "";
|
||||||
|
if (w.reset_at) {
|
||||||
|
const t = new Date(w.reset_at);
|
||||||
|
if (!isNaN(t.getTime())) resetAt = t.toISOString();
|
||||||
|
}
|
||||||
|
if (!resetAt && w.reset_in_seconds > 0) {
|
||||||
|
resetSecs = String(w.reset_in_seconds);
|
||||||
|
}
|
||||||
|
const resetAttr = resetAt
|
||||||
|
? " data-reset-at=\"" + esc(resetAt) + "\""
|
||||||
|
: (resetSecs ? " data-reset-secs=\"" + esc(resetSecs) + "\"" : "");
|
||||||
|
const pctText = pct === null ? "-" : Math.round(pct) + "%";
|
||||||
|
const pctCls = level === "exceeded" ? " bad" : pct !== null && pct >= 80 ? " warn" : "";
|
||||||
|
const barCls = level === "exceeded" ? " danger" : level === "warning" ? " warning" : "";
|
||||||
|
ccRows += '<div class="oc-win-row">' +
|
||||||
|
'<span class="oc-win-name">' + d.name + '</span>' +
|
||||||
|
'<div class="progress-track oc-win-bar"><div class="progress-bar' + barCls + '" style="width:' + (pct === null ? 0 : pct) + '%"></div></div>' +
|
||||||
|
'<span class="oc-win-pct' + pctCls + '">' + pctText + '</span>' +
|
||||||
|
'<span class="oc-win-reset countdown-timer"' + resetAttr + '>-</span>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
allBodyCommandcode.innerHTML =
|
allBodyCommandcode.innerHTML =
|
||||||
summaryRow("Plan", esc(planName)) +
|
summaryRow("Plan", esc(planName)) +
|
||||||
summaryRow("Total Credits", formatUSD(credits.total_credits)) +
|
'<div class="oc-key-body">' + ccRows + '</div>';
|
||||||
summaryRow("Monthly Credits", formatUSD(credits.monthly_credits)) +
|
|
||||||
summaryRow("最差窗口", esc(worstName) + " " + (worstPct === null ? "-" : worstPct.toFixed(1) + "%")) +
|
|
||||||
miniBar(worstPct, worstLevel);
|
|
||||||
} else {
|
} else {
|
||||||
allBadgeCommandcode.className = "status-badge";
|
allBadgeCommandcode.className = "status-badge";
|
||||||
allBadgeTextCommandcode.textContent = "尚未加载";
|
allBadgeTextCommandcode.textContent = "尚未加载";
|
||||||
|
|||||||
@@ -142,6 +142,41 @@ func FetchUsageSummaryRaw(ctx context.Context, apiBase, sessionToken string, hos
|
|||||||
return fetchUpstream(ctx, apiBase, "internal/usage/summary", sessionToken, hostCallbackID)
|
return fetchUpstream(ctx, apiBase, "internal/usage/summary", sessionToken, hostCallbackID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchUpstreamAlpha performs a GET on a Command Code /alpha endpoint using a
|
||||||
|
// Provider API key (Bearer auth) instead of a session cookie, reusing the
|
||||||
|
// host.http.do bridge when available, else falling back to net/http.
|
||||||
|
func fetchUpstreamAlpha(ctx context.Context, apiBase, endpoint, apiKey, hostCallbackID string) ([]byte, int, error) {
|
||||||
|
apiKey = strings.TrimSpace(apiKey)
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, http.StatusBadRequest, errors.New("missing commandcode_api_key: please provide a valid Command Code Provider API key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiBase == "" {
|
||||||
|
apiBase = DefaultAPIBase
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("%s/%s", strings.TrimRight(apiBase, "/"), strings.TrimLeft(endpoint, "/"))
|
||||||
|
|
||||||
|
headers := map[string][]string{
|
||||||
|
"Authorization": {"Bearer " + apiKey},
|
||||||
|
"Accept": {"application/json"},
|
||||||
|
"User-Agent": {fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion)},
|
||||||
|
}
|
||||||
|
return doUpstreamRequest(ctx, http.MethodGet, url, headers, hostCallbackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchCommandCodeCreditsAlphaRaw fetches raw credit data from
|
||||||
|
// {apiBase}/alpha/billing/credits with Bearer auth (Provider API key),
|
||||||
|
// via host.http.do or net/http fallback.
|
||||||
|
func FetchCommandCodeCreditsAlphaRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
|
||||||
|
return fetchUpstreamAlpha(ctx, apiBase, "alpha/billing/credits", apiKey, hostCallbackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchCommandCodeUsageSummaryAlphaRaw fetches the billing-period (monthly)
|
||||||
|
// usage totals from {apiBase}/alpha/usage/summary with Bearer auth.
|
||||||
|
func FetchCommandCodeUsageSummaryAlphaRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
|
||||||
|
return fetchUpstreamAlpha(ctx, apiBase, "alpha/usage/summary", apiKey, hostCallbackID)
|
||||||
|
}
|
||||||
|
|
||||||
// FetchOpenCodeUsageRaw fetches raw OpenCode Go usage data from
|
// FetchOpenCodeUsageRaw fetches raw OpenCode Go usage data from
|
||||||
// {apiBase}/usage with Bearer auth, via host.http.do or net/http fallback.
|
// {apiBase}/usage with Bearer auth, via host.http.do or net/http fallback.
|
||||||
func FetchOpenCodeUsageRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
|
func FetchOpenCodeUsageRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
|
||||||
|
|||||||
@@ -790,3 +790,128 @@ func TestFetchOpenCodeUsageRaw_HostCaller(t *testing.T) {
|
|||||||
t.Errorf("host request Authorization = %v, want Bearer sk-host-key", auth)
|
t.Errorf("host request Authorization = %v, want Bearer sk-host-key", auth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.5.0: the /alpha endpoints authenticate with a Bearer Provider API key
|
||||||
|
// instead of the session cookie. The URL must be /alpha/billing/credits and
|
||||||
|
// no Cookie header may be sent.
|
||||||
|
func TestFetchCommandCodeCreditsAlphaRaw_FallbackHTTP(t *testing.T) {
|
||||||
|
var sawAuth, sawCookie, sawAccept, sawUA string
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/alpha/billing/credits" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sawAuth = r.Header.Get("Authorization")
|
||||||
|
sawCookie = r.Header.Get("Cookie")
|
||||||
|
sawAccept = r.Header.Get("Accept")
|
||||||
|
sawUA = r.Header.Get("User-Agent")
|
||||||
|
|
||||||
|
// Alpha credits omit opensourceMonthlyCredits (field difference vs
|
||||||
|
// the internal endpoint); formatCredits must tolerate that.
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits":700},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(ts.Client())
|
||||||
|
defer func() {
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}()
|
||||||
|
|
||||||
|
body, status, err := FetchCommandCodeCreditsAlphaRaw(context.Background(), ts.URL, "user_test-key", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchCommandCodeCreditsAlphaRaw error: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", status)
|
||||||
|
}
|
||||||
|
if sawAuth != "Bearer user_test-key" {
|
||||||
|
t.Errorf("Authorization = %q, want Bearer user_test-key", sawAuth)
|
||||||
|
}
|
||||||
|
if sawCookie != "" {
|
||||||
|
t.Errorf("Cookie = %q, want no Cookie header on the /alpha path", sawCookie)
|
||||||
|
}
|
||||||
|
if sawAccept != "application/json" {
|
||||||
|
t.Errorf("Accept = %q, want application/json", sawAccept)
|
||||||
|
}
|
||||||
|
if !strings.Contains(sawUA, "cliproxy-plugin-commandcode/") {
|
||||||
|
t.Errorf("User-Agent = %q, want cliproxy-plugin-commandcode/<version>", sawUA)
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, errParse := ParseAndFormatUsage(body, nil, time.Time{})
|
||||||
|
if errParse != nil {
|
||||||
|
t.Fatalf("ParseAndFormatUsage error: %v", errParse)
|
||||||
|
}
|
||||||
|
if usage.Credits.MonthlyCredits != 700 {
|
||||||
|
t.Errorf("MonthlyCredits = %v, want 700", usage.Credits.MonthlyCredits)
|
||||||
|
}
|
||||||
|
if usage.Credits.OpensourceMonthlyCredits != 0 {
|
||||||
|
t.Errorf("OpensourceMonthlyCredits = %v, want 0 (field absent in alpha payload)", usage.Credits.OpensourceMonthlyCredits)
|
||||||
|
}
|
||||||
|
// total = monthly + 0 when opensourceMonthlyCredits is missing.
|
||||||
|
if usage.Credits.TotalCredits != 700 {
|
||||||
|
t.Errorf("TotalCredits = %v, want 700 (= monthly when opensource field absent)", usage.Credits.TotalCredits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchCommandCodeUsageSummaryAlphaRaw_FallbackHTTP(t *testing.T) {
|
||||||
|
var sawAuth, sawCookie string
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/alpha/usage/summary" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sawAuth = r.Header.Get("Authorization")
|
||||||
|
sawCookie = r.Header.Get("Cookie")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 123}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(ts.Client())
|
||||||
|
defer func() {
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}()
|
||||||
|
|
||||||
|
body, status, err := FetchCommandCodeUsageSummaryAlphaRaw(context.Background(), ts.URL+"/", "user_test-key", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchCommandCodeUsageSummaryAlphaRaw error: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", status)
|
||||||
|
}
|
||||||
|
if sawAuth != "Bearer user_test-key" {
|
||||||
|
t.Errorf("Authorization = %q, want Bearer user_test-key", sawAuth)
|
||||||
|
}
|
||||||
|
if sawCookie != "" {
|
||||||
|
t.Errorf("Cookie = %q, want no Cookie header on the /alpha path", sawCookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary UpstreamUsageSummaryResponse
|
||||||
|
if err := json.Unmarshal(body, &summary); err != nil {
|
||||||
|
t.Fatalf("unmarshal summary error: %v", err)
|
||||||
|
}
|
||||||
|
if summary.TotalMonthlyCredits != 123 {
|
||||||
|
t.Errorf("TotalMonthlyCredits = %v, want 123", summary.TotalMonthlyCredits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchCommandCodeCreditsAlphaRaw_MissingKey(t *testing.T) {
|
||||||
|
SetHostCaller(nil)
|
||||||
|
for _, key := range []string{"", " "} {
|
||||||
|
_, status, err := FetchCommandCodeCreditsAlphaRaw(context.Background(), "", key, "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("key %q: expected error for missing key", key)
|
||||||
|
}
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
t.Errorf("key %q: status = %d, want 400", key, status)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "missing commandcode_api_key") {
|
||||||
|
t.Errorf("key %q: error = %q, want it to mention missing commandcode_api_key", key, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,3 +21,197 @@ try {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log("pagecheck: embedded JS syntax OK");
|
console.log("pagecheck: embedded JS syntax OK");
|
||||||
|
|
||||||
|
// --- Undeclared-identifier audit (guards against ReferenceErrors like the
|
||||||
|
// v0.4.4 `monthlyTargetTime is not defined` bug: a bare identifier read in
|
||||||
|
// updateTimers() that was never declared and only existed as a global
|
||||||
|
// property accidentally created by renderUsage()).
|
||||||
|
//
|
||||||
|
// Approach (deliberately simple/grep-style, no DOM execution):
|
||||||
|
// 1. strip comments and string literals
|
||||||
|
// 2. collect every var/let/const/function declaration name + function/
|
||||||
|
// callback/catch parameter
|
||||||
|
// 3. flag candidates: bare assignment targets (x =, x +=, x++, ...),
|
||||||
|
// bare if()/while() condition identifiers, and for-loop init identifiers
|
||||||
|
// 4. whitelist known globals; anything left is reported and fails the check
|
||||||
|
|
||||||
|
const whitelist = new Set([
|
||||||
|
// browser builtins referenced by the page
|
||||||
|
"document", "window", "localStorage", "sessionStorage", "fetch",
|
||||||
|
"setInterval", "setTimeout", "clearInterval", "clearTimeout", "console",
|
||||||
|
"alert", "Date", "Math", "Number", "String", "Boolean", "Array",
|
||||||
|
"Object", "JSON", "parseInt", "parseFloat", "isNaN", "Promise", "Error",
|
||||||
|
"escape", "unescape", "navigator", "location", "history", "URL",
|
||||||
|
"URLSearchParams", "FormData", "Headers", "Request", "Response",
|
||||||
|
"Intl", "Map", "Set", "AbortController", "requestAnimationFrame",
|
||||||
|
"cancelAnimationFrame", "structuredClone", "globalThis", "arguments",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function stripLiterals(source) {
|
||||||
|
// Remove comments, string literals, and regex literals; replace with
|
||||||
|
// harmless placeholders so identifier scanning never sees string content.
|
||||||
|
// Template literals are NOT fully discarded: their ${...} interpolations
|
||||||
|
// are kept as "( ... )" so identifiers inside them stay visible.
|
||||||
|
//
|
||||||
|
// A "/" only starts a regex literal when it appears in expression
|
||||||
|
// position (previous significant token is an operator/open bracket or a
|
||||||
|
// keyword such as return/typeof). Otherwise it is division.
|
||||||
|
let out = "";
|
||||||
|
let i = 0;
|
||||||
|
const n = source.length;
|
||||||
|
// Stack of lexer contexts. Each entry: { type: "expr", depth: number } or
|
||||||
|
// { type: "tmpl" }. The initial code runs in a never-ending expr context.
|
||||||
|
const stack = [{ type: "expr", depth: Infinity }];
|
||||||
|
// Last significant (non-whitespace) emitted char + last identifier word,
|
||||||
|
// used for the regex-vs-division heuristic.
|
||||||
|
let lastSig = "";
|
||||||
|
let lastWord = "";
|
||||||
|
const KEYWORDS_BEFORE_REGEX = new Set([
|
||||||
|
"return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
|
||||||
|
"do", "else", "case", "throw", "await", "yield",
|
||||||
|
]);
|
||||||
|
const emit = (text) => {
|
||||||
|
for (const ch of text) {
|
||||||
|
if (/\s/.test(ch)) continue;
|
||||||
|
if (/[A-Za-z0-9_$]/.test(ch)) {
|
||||||
|
lastWord = /[A-Za-z0-9_$]/.test(lastSig) ? lastWord + ch : ch;
|
||||||
|
} else {
|
||||||
|
lastWord = "";
|
||||||
|
}
|
||||||
|
lastSig = ch;
|
||||||
|
}
|
||||||
|
out += text;
|
||||||
|
};
|
||||||
|
const regexAllowed = () =>
|
||||||
|
lastSig === "" ||
|
||||||
|
"(,=:[!&|?+-*/%<>~^;{".includes(lastSig) ||
|
||||||
|
KEYWORDS_BEFORE_REGEX.has(lastWord);
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
const ctx = stack[stack.length - 1];
|
||||||
|
const c = source[i];
|
||||||
|
const next = source[i + 1];
|
||||||
|
|
||||||
|
if (ctx.type === "tmpl") {
|
||||||
|
// Inside template literal text: skip until ` or ${ ... }
|
||||||
|
if (c === "\\") { i += 2; continue; }
|
||||||
|
if (c === "`") { i++; stack.pop(); emit(" "); continue; }
|
||||||
|
if (c === "$" && next === "{") {
|
||||||
|
i += 2;
|
||||||
|
stack.push({ type: "expr", depth: 0 });
|
||||||
|
emit(" ( ");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// code context (top-level or template ${...} expression)
|
||||||
|
if (c === "/" && next === "/") {
|
||||||
|
while (i < n && source[i] !== "\n") i++;
|
||||||
|
} else if (c === "/" && next === "*") {
|
||||||
|
i += 2;
|
||||||
|
while (i < n && !(source[i] === "*" && source[i + 1] === "/")) i++;
|
||||||
|
i += 2;
|
||||||
|
} else if (c === "/" && regexAllowed()) {
|
||||||
|
// regex literal: skip to unescaped closing / (not inside [...])
|
||||||
|
i++;
|
||||||
|
let inClass = false;
|
||||||
|
while (i < n) {
|
||||||
|
if (source[i] === "\\") { i += 2; continue; }
|
||||||
|
if (source[i] === "[") { inClass = true; i++; continue; }
|
||||||
|
if (source[i] === "]") { inClass = false; i++; continue; }
|
||||||
|
if (source[i] === "/" && !inClass) { i++; break; }
|
||||||
|
if (source[i] === "\n") break; // malformed; bail out safely
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
while (i < n && /[a-z]/i.test(source[i])) i++; // flags
|
||||||
|
emit(" / ");
|
||||||
|
} else if (c === '"' || c === "'") {
|
||||||
|
const quote = c;
|
||||||
|
i++;
|
||||||
|
while (i < n) {
|
||||||
|
if (source[i] === "\\") { i += 2; continue; }
|
||||||
|
if (source[i] === quote) { i++; break; }
|
||||||
|
if (source[i] === "\n") break;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
emit(" " + quote + quote + " ");
|
||||||
|
} else if (c === "`") {
|
||||||
|
i++;
|
||||||
|
stack.push({ type: "tmpl" });
|
||||||
|
emit(" ");
|
||||||
|
} else {
|
||||||
|
if (c === "{") ctx.depth++;
|
||||||
|
if (c === "}") {
|
||||||
|
if (ctx.depth === 0) {
|
||||||
|
// closes a template interpolation: back into template text
|
||||||
|
stack.pop();
|
||||||
|
i++;
|
||||||
|
emit(" ) ");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ctx.depth--;
|
||||||
|
}
|
||||||
|
emit(c);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDeclarations(clean) {
|
||||||
|
const declared = new Set();
|
||||||
|
const addParamList = (raw) => {
|
||||||
|
for (const p of raw.split(",")) {
|
||||||
|
const name = p.trim().split(/[\s=]/)[0].replace(/^\.\.\./, "");
|
||||||
|
if (/^[A-Za-z_$][\w$]*$/.test(name)) declared.add(name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const m of clean.matchAll(/\b(?:var|let|const)\s+([A-Za-z_$][\w$]*)/g))
|
||||||
|
declared.add(m[1]);
|
||||||
|
// function declarations/expressions: name + params
|
||||||
|
for (const m of clean.matchAll(/\bfunction\s*([A-Za-z_$][\w$]*)?\s*\(([^()]*)\)/g)) {
|
||||||
|
if (m[1]) declared.add(m[1]);
|
||||||
|
addParamList(m[2]);
|
||||||
|
}
|
||||||
|
// arrow functions: (a, b) => and a =>
|
||||||
|
for (const m of clean.matchAll(/\(\s*([^()]*?)\s*\)\s*=>/g)) addParamList(m[1]);
|
||||||
|
for (const m of clean.matchAll(/(?<![\w$.(])\b([A-Za-z_$][\w$]*)\s*=>/g)) declared.add(m[1]);
|
||||||
|
// catch (e) and destructuring catch
|
||||||
|
for (const m of clean.matchAll(/\bcatch\s*\(?\s*\{?\s*([A-Za-z_$][\w$]*)/g)) declared.add(m[1]);
|
||||||
|
return declared;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clean = stripLiterals(js);
|
||||||
|
const declared = collectDeclarations(clean);
|
||||||
|
const flagged = new Set();
|
||||||
|
|
||||||
|
// 1. bare assignment targets / updates: x =, x +=, x++, x--, x ??=
|
||||||
|
for (const m of clean.matchAll(/(?:^|[{};\n])\s*([A-Za-z_$][\w$]*)\s*(?:=[^=>]|[+*\/%-]?=[^=]|\+\+|\-\-)/gm)) {
|
||||||
|
const name = m[1];
|
||||||
|
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
|
||||||
|
}
|
||||||
|
// 2. bare if()/while() condition identifiers
|
||||||
|
for (const m of clean.matchAll(/\b(?:if|while)\s*\(\s*(!*)\s*([A-Za-z_$][\w$]*)\s*(?:\)|&&|\|\||\?)/g)) {
|
||||||
|
const name = m[2];
|
||||||
|
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
|
||||||
|
}
|
||||||
|
// 3. for-loop init without let/var: for (i = 0; ...)
|
||||||
|
for (const m of clean.matchAll(/\bfor\s*\(\s*([A-Za-z_$][\w$]*)\s*=[^=]/g)) {
|
||||||
|
const name = m[1];
|
||||||
|
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flagged.size > 0) {
|
||||||
|
console.error("pagecheck: undeclared identifier(s) referenced in embedded JS:");
|
||||||
|
for (const name of [...flagged].sort()) console.error(" - " + name);
|
||||||
|
console.error(
|
||||||
|
"pagecheck: fix by declaring with let/const (see v0.4.5 monthlyTargetTime bug); " +
|
||||||
|
"if this is a false positive, extend scripts/pagecheck.js"
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
"pagecheck: undeclared-identifier scan OK (" + declared.size + " declarations checked)"
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user