diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe91f4c65..525d3a39f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,6 +168,7 @@ jobs: npm ci node --test test/openclaw-parser.test.js node --test --test-name-pattern="native Windows" test/usage-limits.test.js + node --test test/command-runner.test.js - name: Test Windows updater run: dotnet test TokenTrackerWin.Tests/TokenTrackerWin.Tests.csproj --configuration Release diff --git a/TokenTrackerBar/TokenTrackerBar/Models/LimitsSettingsStore.swift b/TokenTrackerBar/TokenTrackerBar/Models/LimitsSettingsStore.swift index c7ed799dd..e3c17e349 100644 --- a/TokenTrackerBar/TokenTrackerBar/Models/LimitsSettingsStore.swift +++ b/TokenTrackerBar/TokenTrackerBar/Models/LimitsSettingsStore.swift @@ -35,7 +35,7 @@ final class LimitsSettingsStore: ObservableObject { static let shared = LimitsSettingsStore(userDefaults: .standard) /// All known provider identifiers, in default display order. - static let allProviders: [String] = ["claude", "codex", "cursor", "gemini", "kimi", "kiro", "grok", "copilot", "antigravity", "zcode", "opencodeGo", "qoder", "qoderCn"] + static let allProviders: [String] = ["claude", "codex", "cursor", "gemini", "kimi", "kiro", "grok", "copilot", "antigravity", "zcode", "opencodeGo", "qoder", "qoderCn", "codingPlan"] static let displayNames: [String: String] = [ "claude": "Claude", @@ -51,6 +51,7 @@ final class LimitsSettingsStore: ObservableObject { "opencodeGo": "OpenCode Go", "qoder": "Qoder", "qoderCn": "Qoder CN", + "codingPlan": "Ark Coding Plan", ] static let iconNames: [String: String] = [ diff --git a/TokenTrackerBar/TokenTrackerBar/Models/UsageLimits.swift b/TokenTrackerBar/TokenTrackerBar/Models/UsageLimits.swift index f6b1f4096..e5d303421 100644 --- a/TokenTrackerBar/TokenTrackerBar/Models/UsageLimits.swift +++ b/TokenTrackerBar/TokenTrackerBar/Models/UsageLimits.swift @@ -15,10 +15,11 @@ struct UsageLimitsResponse: Codable, Equatable { let opencodeGo: OpencodeGoLimits? let qoder: QoderLimits? let qoderCn: QoderLimits? + let codingPlan: CodingPlanLimits? enum CodingKeys: String, CodingKey { case fetchedAt = "fetched_at" - case claude, codex, cursor, gemini, kimi, kiro, grok, antigravity, copilot, zcode, qoder, qoderCn + case claude, codex, cursor, gemini, kimi, kiro, grok, antigravity, copilot, zcode, qoder, qoderCn, codingPlan case opencodeGo = "opencodeGo" } } @@ -493,6 +494,26 @@ struct QoderLimits: Codable, Equatable { } } +struct CodingPlanLimits: Codable, Equatable { + let configured: Bool + let error: String? + let planLabel: String? + let primaryWindow: GenericLimitWindow? + let secondaryWindow: GenericLimitWindow? + let tertiaryWindow: GenericLimitWindow? + let cachedAt: String? + let stale: Bool? + + enum CodingKeys: String, CodingKey { + case configured, error, stale + case planLabel = "plan_label" + case primaryWindow = "primary_window" + case secondaryWindow = "secondary_window" + case tertiaryWindow = "tertiary_window" + case cachedAt = "cached_at" + } +} + struct AntigravityLimits: Codable, Equatable { let configured: Bool let error: String? @@ -536,6 +557,7 @@ extension UsageLimitsResponse { (opencodeGo?.configured ?? false, opencodeGo?.error), (qoder?.configured ?? false, qoder?.error), (qoderCn?.configured ?? false, qoderCn?.error), + (codingPlan?.configured ?? false, codingPlan?.error), ] return providers.contains { $0.0 && $0.1 == nil } } diff --git a/TokenTrackerBar/TokenTrackerBar/Models/WeeklyLimitResetDetector.swift b/TokenTrackerBar/TokenTrackerBar/Models/WeeklyLimitResetDetector.swift index d5bdfec5f..6e84bb17b 100644 --- a/TokenTrackerBar/TokenTrackerBar/Models/WeeklyLimitResetDetector.swift +++ b/TokenTrackerBar/TokenTrackerBar/Models/WeeklyLimitResetDetector.swift @@ -36,6 +36,7 @@ enum LimitResetProviderIconCatalog { case "zcode": return "zcode.svg" case "opencodeGo": return "opencode.svg" case "qoder": return "qoder.svg" + case "codingPlan": return "volcano-ark.svg" default: return nil } } @@ -251,6 +252,13 @@ extension UsageLimitsResponse { ("secondary", Strings.qoderBonusLabel, qoderCn.secondaryWindow), ]) } + if let codingPlan { + addGeneric("codingPlan", codingPlan.configured, codingPlan.error, [ + ("primary", "5h", codingPlan.primaryWindow), + ("secondary", "Weekly", codingPlan.secondaryWindow), + ("tertiary", "Monthly", codingPlan.tertiaryWindow), + ]) + } return out } diff --git a/TokenTrackerBar/TokenTrackerBar/Views/LimitsSettingsView.swift b/TokenTrackerBar/TokenTrackerBar/Views/LimitsSettingsView.swift index 284cbe1df..f71d6fcc3 100644 --- a/TokenTrackerBar/TokenTrackerBar/Views/LimitsSettingsView.swift +++ b/TokenTrackerBar/TokenTrackerBar/Views/LimitsSettingsView.swift @@ -126,7 +126,7 @@ struct LimitsSettingsView: View { @ViewBuilder private func providerIcon(id: String) -> some View { switch id { - case "cursor", "kimi", "kiro", "grok", "copilot", "zcode", "opencodeGo", "qoder", "qoderCn": + case "cursor", "kimi", "kiro", "grok", "copilot", "zcode", "opencodeGo", "qoder", "qoderCn", "codingPlan": let filename: String = { switch id { case "cursor": return "cursor.svg" @@ -137,6 +137,7 @@ struct LimitsSettingsView: View { case "opencodeGo": return "opencode.svg" case "qoder": return "qoder.svg" case "qoderCn": return "qoder-cn.svg" + case "codingPlan": return "volcano-ark.svg" default: return "copilot.svg" } }() diff --git a/TokenTrackerBar/TokenTrackerBar/Views/UsageLimitsView.swift b/TokenTrackerBar/TokenTrackerBar/Views/UsageLimitsView.swift index 520abe59e..c58188bec 100644 --- a/TokenTrackerBar/TokenTrackerBar/Views/UsageLimitsView.swift +++ b/TokenTrackerBar/TokenTrackerBar/Views/UsageLimitsView.swift @@ -115,6 +115,10 @@ struct UsageLimitsView: View { if let qoderCn = limits.qoderCn, qoderCn.configured, qoderCn.error == nil { groups.append(AnyView(toolSection(id: id, title: planTitle("Qoder CN", qoderCn.planLabel), assetName: "QoderCnLogo", toolName: "Qoder CN", specs: qoderSpecs(qoderCn), updatedAtISO: qoderCn.cachedAt, isStale: qoderCn.stale ?? false))) } + case "codingPlan": + if let codingPlan = limits.codingPlan, codingPlan.configured, codingPlan.error == nil { + groups.append(AnyView(toolSection(id: id, title: planTitle("Ark Coding Plan", codingPlan.planLabel), assetName: "VolcanoArkLogo", toolName: "Ark Coding Plan", specs: codingPlanSpecs(codingPlan), updatedAtISO: codingPlan.cachedAt, isStale: codingPlan.stale ?? false))) + } default: break } @@ -385,6 +389,14 @@ struct UsageLimitsView: View { return specs } + private func codingPlanSpecs(_ c: CodingPlanLimits) -> [LimitWindowSpec] { + var specs: [LimitWindowSpec] = [] + if let w = c.primaryWindow { specs.append(makeSpec("5h", w.usedPercent, windowSeconds: 5 * 3600, iso: w.resetAt)) } + if let w = c.secondaryWindow { specs.append(makeSpec("Weekly", w.usedPercent, windowSeconds: 7 * 86400, iso: w.resetAt)) } + if let w = c.tertiaryWindow { specs.append(makeSpec("Monthly", w.usedPercent, iso: w.resetAt)) } + return specs + } + private func copilotSpecs(_ c: CopilotLimits) -> [LimitWindowSpec] { var s: [LimitWindowSpec] = [] if let w = c.primaryWindow { s.append(makeSpec("Premium", w.usedPercent, iso: w.resetAt)) } @@ -595,7 +607,7 @@ struct UsageLimitsView: View { @ViewBuilder private func brandIcon(_ name: String) -> some View { switch name { - case "CursorLogo", "KimiLogo", "KiroLogo", "GrokLogo", "CopilotLogo", "ZcodeLogo", "OpenCodeLogo", "QoderLogo", "QoderCnLogo": + case "CursorLogo", "KimiLogo", "KiroLogo", "GrokLogo", "CopilotLogo", "ZcodeLogo", "OpenCodeLogo", "QoderLogo", "QoderCnLogo", "VolcanoArkLogo": let filename: String = { switch name { case "CursorLogo": return "cursor.svg" @@ -606,6 +618,7 @@ struct UsageLimitsView: View { case "OpenCodeLogo": return "opencode.svg" case "QoderLogo": return "qoder.svg" case "QoderCnLogo": return "qoder-cn.svg" + case "VolcanoArkLogo": return "volcano-ark.svg" default: return "copilot.svg" } }() diff --git a/dashboard/public/brand-logos/volcano-ark.svg b/dashboard/public/brand-logos/volcano-ark.svg new file mode 100644 index 000000000..0b78db174 --- /dev/null +++ b/dashboard/public/brand-logos/volcano-ark.svg @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/dashboard/src/content/copy.csv b/dashboard/src/content/copy.csv index 8a18a2fcd..0774f8a5b 100644 --- a/dashboard/src/content/copy.csv +++ b/dashboard/src/content/copy.csv @@ -1472,3 +1472,17 @@ landing.v3.how.scene.files_changed,landing,LandingPage,HowItWorksSection,scene_f landing.v3.how.scene.session_title,landing,LandingPage,HowItWorksSection,scene_session_title,"session log · last turn",,active landing.v3.how.scene.appended,landing,LandingPage,HowItWorksSection,scene_appended,"appended locally",,active landing.v3.how.scene.total_tokens,landing,LandingPage,HowItWorksSection,scene_total_tokens,"total tokens",,active +limits.provider.ark_coding_plan,ui,LimitsPage,UsageLimitsPanel,provider_ark_coding_plan,"Ark Coding Plan",,active +limits.label.ark_coding_plan_5h,ui,LimitsPage,UsageLimitsPanel,ark_coding_plan_5h,"5h",,active +limits.label.ark_coding_plan_weekly,ui,LimitsPage,UsageLimitsPanel,ark_coding_plan_weekly,"Weekly",,active +limits.label.ark_coding_plan_monthly,ui,LimitsPage,UsageLimitsPanel,ark_coding_plan_monthly,"Monthly",,active +limits.codingPlan.setupHint.title,ui,LimitsPage,UsageLimitsPanel,coding_plan_hint_title,"Connect Ark Coding Plan",,active +limits.codingPlan.setupHint.subtitle,ui,LimitsPage,UsageLimitsPanel,coding_plan_hint_subtitle,"Quota is read from the official Ark CLI (arkcli) on this machine.",,active +limits.codingPlan.setupHint.step1,ui,LimitsPage,UsageLimitsPanel,coding_plan_hint_step1,"Install the Ark CLI from npm.",,active +limits.codingPlan.setupHint.cta,ui,LimitsPage,UsageLimitsPanel,coding_plan_hint_cta,"View install guide",,active +limits.codingPlan.setupHint.step2,ui,LimitsPage,UsageLimitsPanel,coding_plan_hint_step2,"Sign in with your Volcengine account (browser SSO recommended).",,active +limits.codingPlan.setupHint.step2_remote,ui,LimitsPage,UsageLimitsPanel,coding_plan_hint_step2_remote,"Headless / remote terminals: arkcli auth login --no-browser",,active +limits.codingPlan.setupHint.step3,ui,LimitsPage,UsageLimitsPanel,coding_plan_hint_step3,"Run these, then refresh this page:",,active +limits.codingPlan.setupHint.note_app,ui,LimitsPage,UsageLimitsPanel,coding_plan_hint_note_app,"Refresh after signing in — no app restart needed.",,active +limits.codingPlan.setupHint.copy,ui,LimitsPage,UsageLimitsPanel,coding_plan_hint_copy,"Copy",,active +limits.codingPlan.setupHint.copied,ui,LimitsPage,UsageLimitsPanel,coding_plan_hint_copied,"Copied",,active diff --git a/dashboard/src/content/i18n/zh-TW/core.json b/dashboard/src/content/i18n/zh-TW/core.json index bb94cb579..c0cdfda4f 100644 --- a/dashboard/src/content/i18n/zh-TW/core.json +++ b/dashboard/src/content/i18n/zh-TW/core.json @@ -168,6 +168,10 @@ "limits.label.qoder_ultimate": "Ultimate 免費呼叫", "limits.label.qoder_cn_credits": "積分", "limits.label.qoder_cn_ultimate": "Ultimate 免費呼叫", + "limits.provider.ark_coding_plan": "Ark Coding Plan", + "limits.label.ark_coding_plan_5h": "5h", + "limits.label.ark_coding_plan_weekly": "每週", + "limits.label.ark_coding_plan_monthly": "每月", "limits.qoder_calls.detail": "已用 {{used}} / {{limit}} 次 · 剩餘 {{remaining}} 次", "limits.hover.expires_at": "{{time}} 到期", "limits.label.claude_5h": "5h", @@ -536,6 +540,16 @@ "limits.opencodeGo.setupHint.note_app": "macOS App:使用 launchctl setenv 設定,然後重新開啟 App。", "limits.opencodeGo.setupHint.copy": "複製", "limits.opencodeGo.setupHint.copied": "已複製", + "limits.codingPlan.setupHint.title": "連接 Ark Coding Plan", + "limits.codingPlan.setupHint.subtitle": "額度透過本機的官方 Ark CLI(arkcli)讀取。", + "limits.codingPlan.setupHint.step1": "透過 npm 安裝 Ark CLI。", + "limits.codingPlan.setupHint.cta": "查看安裝指南", + "limits.codingPlan.setupHint.step2": "使用火山引擎帳號登入(推薦瀏覽器 SSO)。", + "limits.codingPlan.setupHint.step2_remote": "無瀏覽器 / 遠端終端:arkcli auth login --no-browser", + "limits.codingPlan.setupHint.step3": "依序執行以下指令,然後重新整理本頁:", + "limits.codingPlan.setupHint.note_app": "登入後重新整理頁面即可,無需重新啟動 App。", + "limits.codingPlan.setupHint.copy": "複製", + "limits.codingPlan.setupHint.copied": "已複製", "skills.card.manage": "管理", "skills.dot.synced_aria": "{{agent}}:已同步。點選可取消同步。", "skills.dot.off_aria": "{{agent}}:未同步。點選可同步。", diff --git a/dashboard/src/content/i18n/zh/core.json b/dashboard/src/content/i18n/zh/core.json index d00d8f42e..6992c5554 100644 --- a/dashboard/src/content/i18n/zh/core.json +++ b/dashboard/src/content/i18n/zh/core.json @@ -168,6 +168,10 @@ "limits.label.qoder_ultimate": "Ultimate 免费调用", "limits.label.qoder_cn_credits": "积分", "limits.label.qoder_cn_ultimate": "Ultimate 免费调用", + "limits.provider.ark_coding_plan": "Ark Coding Plan", + "limits.label.ark_coding_plan_5h": "5h", + "limits.label.ark_coding_plan_weekly": "每周", + "limits.label.ark_coding_plan_monthly": "每月", "limits.qoder_calls.detail": "已用 {{used}} / {{limit}} 次 · 剩余 {{remaining}} 次", "limits.hover.expires_at": "{{time}} 到期", "limits.label.claude_5h": "5h", @@ -604,6 +608,16 @@ "limits.opencodeGo.setupHint.note_app": "macOS App:使用 launchctl setenv 设置,然后重新打开 App。", "limits.opencodeGo.setupHint.copy": "复制", "limits.opencodeGo.setupHint.copied": "已复制", + "limits.codingPlan.setupHint.title": "连接 Ark Coding Plan", + "limits.codingPlan.setupHint.subtitle": "额度通过本机的官方 Ark CLI(arkcli)读取。", + "limits.codingPlan.setupHint.step1": "通过 npm 安装 Ark CLI。", + "limits.codingPlan.setupHint.cta": "查看安装指南", + "limits.codingPlan.setupHint.step2": "使用火山引擎账号登录(推荐浏览器 SSO)。", + "limits.codingPlan.setupHint.step2_remote": "无浏览器 / 远程终端:arkcli auth login --no-browser", + "limits.codingPlan.setupHint.step3": "依次执行以下命令,然后刷新本页:", + "limits.codingPlan.setupHint.note_app": "登录后刷新页面即可,无需重启 App。", + "limits.codingPlan.setupHint.copy": "复制", + "limits.codingPlan.setupHint.copied": "已复制", "skills.card.manage": "管理", "skills.dot.synced_aria": "{{agent}}:已同步。点击可取消同步。", "skills.dot.off_aria": "{{agent}}:未同步。点击可同步。", diff --git a/dashboard/src/hooks/use-limits-display-prefs.test.js b/dashboard/src/hooks/use-limits-display-prefs.test.js index 8e00c4b91..b1aa2d091 100644 --- a/dashboard/src/hooks/use-limits-display-prefs.test.js +++ b/dashboard/src/hooks/use-limits-display-prefs.test.js @@ -104,6 +104,7 @@ describe("useLimitsDisplayPrefs", () => { "antigravity", "claude", "codex", + "codingPlan", "copilot", "cursor", "gemini", diff --git a/dashboard/src/hooks/use-usage-limits.test.tsx b/dashboard/src/hooks/use-usage-limits.test.tsx index 742d700ee..c1c23e9f1 100644 --- a/dashboard/src/hooks/use-usage-limits.test.tsx +++ b/dashboard/src/hooks/use-usage-limits.test.tsx @@ -42,6 +42,7 @@ const existingLimits = { zcode: { configured: false }, opencodeGo: { configured: false }, qoder: { configured: false }, + codingPlan: { configured: false }, }; const freshLimits = { diff --git a/dashboard/src/hooks/use-usage-limits.ts b/dashboard/src/hooks/use-usage-limits.ts index 1e68aca8d..b7b6f62c5 100644 --- a/dashboard/src/hooks/use-usage-limits.ts +++ b/dashboard/src/hooks/use-usage-limits.ts @@ -68,6 +68,18 @@ interface UsageLimitsData { cached_at?: string | null; source?: string | null; }; + codingPlan: { + configured: boolean; + error?: string | null; + plan_label?: string | null; + primary_window?: { used_percent: number; reset_at?: string | null } | null; + secondary_window?: { used_percent: number; reset_at?: string | null } | null; + tertiary_window?: { used_percent: number; reset_at?: string | null } | null; + cached?: boolean; + stale?: boolean; + cached_at?: string | null; + source?: string | null; + }; } interface UsageLimitsInitialState { diff --git a/dashboard/src/lib/limits-providers.js b/dashboard/src/lib/limits-providers.js index c34dbfad3..9f9bd1bef 100644 --- a/dashboard/src/lib/limits-providers.js +++ b/dashboard/src/lib/limits-providers.js @@ -15,6 +15,7 @@ export const LIMIT_PROVIDER_IDS = [ "opencodeGo", "qoder", "qoderCn", + "codingPlan", ]; /** Keys for ProviderIcon — mono logos use inline SVG; colored logos use /brand-logos/. */ @@ -36,6 +37,8 @@ export const LIMIT_PROVIDER_ICON_KEYS = { // The CN edition ships its own green-crescent brand mark, distinct from the // international black double-crescent — resolved to the QODER-CN icon asset. qoderCn: "QODER-CN", + // Volcano Engine Ark Coding Plan — own brand mark under /brand-logos/. + codingPlan: "VOLCANO-ARK", }; export function limitProviderIconKey(id) { @@ -70,6 +73,8 @@ export function limitProviderName(id) { return copy("limits.provider.qoder"); case "qoderCn": return copy("limits.provider.qoder_cn"); + case "codingPlan": + return copy("limits.provider.ark_coding_plan"); default: return String(id || ""); } diff --git a/dashboard/src/lib/pet-quips.js b/dashboard/src/lib/pet-quips.js index 154cdcfe4..3b4447788 100644 --- a/dashboard/src/lib/pet-quips.js +++ b/dashboard/src/lib/pet-quips.js @@ -307,6 +307,7 @@ const PET_LIMIT_PROVIDER_NAMES = { zcode: "ZCode", opencodeGo: "OpenCode Go", qoder: "Qoder", + codingPlan: "Ark Coding Plan", }; // Unix timestamps are normally seconds; values above this order of magnitude @@ -393,6 +394,7 @@ function collectPetLimitRows(limits) { addGeneric("zcode", limits.zcode, [["GLM-5.2", limits.zcode?.primary_window], ["GLM-5 Turbo", limits.zcode?.secondary_window], ["Other", limits.zcode?.tertiary_window]]); addGeneric("opencodeGo", limits.opencodeGo, [["5h", limits.opencodeGo?.primary_window], ["Weekly", limits.opencodeGo?.secondary_window], ["Month", limits.opencodeGo?.tertiary_window]]); addGeneric("qoder", limits.qoder, [["Credits", limits.qoder?.primary_window], ["Ultimate Free Calls", limits.qoder?.secondary_window]]); + addGeneric("codingPlan", limits.codingPlan, [["5h", limits.codingPlan?.primary_window], ["Week", limits.codingPlan?.secondary_window], ["Month", limits.codingPlan?.tertiary_window]]); rows.sort((a, b) => { if (b.usedPercent !== a.usedPercent) return b.usedPercent - a.usedPercent; diff --git a/dashboard/src/pages/LimitsPage.jsx b/dashboard/src/pages/LimitsPage.jsx index f6024e2cb..3ef8b0e46 100644 --- a/dashboard/src/pages/LimitsPage.jsx +++ b/dashboard/src/pages/LimitsPage.jsx @@ -147,6 +147,7 @@ export function LimitsPage() { opencodeGo={usageLimits?.opencodeGo} qoder={usageLimits?.qoder} qoderCn={usageLimits?.qoderCn} + codingPlan={usageLimits?.codingPlan} order={prefs.order} visibility={prefs.visibility} displayMode={prefs.displayMode} diff --git a/dashboard/src/ui/dashboard/components/ProviderIcon.jsx b/dashboard/src/ui/dashboard/components/ProviderIcon.jsx index 5c8986ae5..2af28c221 100644 --- a/dashboard/src/ui/dashboard/components/ProviderIcon.jsx +++ b/dashboard/src/ui/dashboard/components/ProviderIcon.jsx @@ -346,6 +346,8 @@ const PROVIDER_LOGO_MAP = { // crescent) differs from the international black double-crescent, so it gets // its own traced asset instead of reusing qoder.svg. "QODER-CN": "/brand-logos/qoder-cn.svg", + // Volcano Ark (火山方舟) Coding Plan — the Volcengine 3-mountain mark. + "VOLCANO-ARK": "/brand-logos/volcano-ark.svg", }; // AnythingLLM publishes this compact mark in white. Keep the official asset diff --git a/dashboard/src/ui/dashboard/components/UsageLimitsPanel.jsx b/dashboard/src/ui/dashboard/components/UsageLimitsPanel.jsx index b89f45b44..e4c58cbc2 100644 --- a/dashboard/src/ui/dashboard/components/UsageLimitsPanel.jsx +++ b/dashboard/src/ui/dashboard/components/UsageLimitsPanel.jsx @@ -488,6 +488,7 @@ function renderProviderGroup(id, data, mode, expanded, onToggle) { {copy("limits.status.not_connected")} {id === "opencodeGo" ? : null} + {id === "codingPlan" ? : null} ); } @@ -506,6 +507,7 @@ function renderProviderGroup(id, data, mode, expanded, onToggle) { ? renderProviderExtra(PROVIDER_LIMIT_SPECS.kiro.extra, data) : null} {id === "opencodeGo" ? : null} + {id === "codingPlan" ? : null} ); } @@ -521,7 +523,7 @@ function renderProviderGroup(id, data, mode, expanded, onToggle) { badge = ; } } - if ((id === "qoder" || id === "qoderCn") && data.cached) { + if ((id === "qoder" || id === "qoderCn" || id === "codingPlan") && data.cached) { badge = ( { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(snippet); + setCopied(true); + setTimeout(() => setCopied(false), 1600); + } catch (_e) { + // Clipboard can be unavailable in embedded or restricted contexts. + } + }; + + return ( +
+
{copy("limits.codingPlan.setupHint.title")}
+
{copy("limits.codingPlan.setupHint.subtitle")}
+ +
    + +
    {copy("limits.codingPlan.setupHint.step1")}
    + e.stopPropagation()} + className="mt-1 inline-flex items-center gap-1 rounded-md bg-oai-brand/10 px-2 py-1 font-medium text-oai-brand hover:bg-oai-brand/15 transition-colors" + > + {copy("limits.codingPlan.setupHint.cta")} + + +
    + +
    {copy("limits.codingPlan.setupHint.step2")}
    +
    {copy("limits.codingPlan.setupHint.step2_remote")}
    +
    + +
    + {copy("limits.codingPlan.setupHint.step3")} + +
    +
    {snippet}
    +
    {copy("limits.codingPlan.setupHint.note_app")}
    +
    +
+
+ ); +} + /** * Width of the widest rendered row label, so every label column matches it. * Mirrors the macOS popover behavior: bars stay aligned without reserving @@ -719,8 +787,8 @@ function useWidestLabelWidth(containerRef) { return labelWidth; } -export function UsageLimitsPanel({ claude, codex, cursor, gemini, kimi, kiro, grok, antigravity, copilot, zcode, opencodeGo, qoder, qoderCn, order, visibility, displayMode }) { - const dataById = { claude, codex, cursor, gemini, kimi, kiro, grok, antigravity, copilot, zcode, opencodeGo, qoder, qoderCn }; +export function UsageLimitsPanel({ claude, codex, cursor, gemini, kimi, kiro, grok, antigravity, copilot, zcode, opencodeGo, qoder, qoderCn, codingPlan, order, visibility, displayMode }) { + const dataById = { claude, codex, cursor, gemini, kimi, kiro, grok, antigravity, copilot, zcode, opencodeGo, qoder, qoderCn, codingPlan }; const containerRef = useRef(null); const labelWidth = useWidestLabelWidth(containerRef); const [expandedId, setExpandedId] = useState(null); diff --git a/dashboard/src/ui/dashboard/components/UsageLimitsPanel.test.jsx b/dashboard/src/ui/dashboard/components/UsageLimitsPanel.test.jsx index 377b114c8..e91988a08 100644 --- a/dashboard/src/ui/dashboard/components/UsageLimitsPanel.test.jsx +++ b/dashboard/src/ui/dashboard/components/UsageLimitsPanel.test.jsx @@ -278,6 +278,37 @@ describe("UsageLimitsPanel", () => { expect(screen.queryByText("12%")).not.toBeInTheDocument(); }); + it("renders Ark Coding Plan 5h / Weekly / Monthly windows", () => { + const { rerender } = render( + , + ); + + // Brand + tier, without repeating "Coding Plan" (title: "Ark Coding Plan Lite"). + expect(screen.getByText("Ark Coding Plan Lite")).toBeInTheDocument(); + expect(screen.getByText("5h")).toBeInTheDocument(); + expect(screen.getByText("Weekly")).toBeInTheDocument(); + expect(screen.getByText("Monthly")).toBeInTheDocument(); + expect(screen.getByText("33%")).toBeInTheDocument(); + expect(screen.getByText("16%")).toBeInTheDocument(); + expect(screen.getByText("9%")).toBeInTheDocument(); + + // Not-configured fallback shows the Ark CLI setup guide. + rerender(); + expect(screen.getByText("Ark Coding Plan")).toBeInTheDocument(); + expect(screen.getByText("Not connected")).toBeInTheDocument(); + expect(screen.getByText(copy("limits.codingPlan.setupHint.title"))).toBeInTheDocument(); + }); + it("renders Qoder credits with exact amounts in the hover detail", () => { render( SIGKILL escalation). Reserve that plus slack so the serial +// chain (discovery -> usage plan -> plans get / profile show -> cache +// read) always settles inside the provider budget and the disk-cache +// fallback actually gets served instead of losing the outer race. +// Same shape as codexResetCreditListTimeoutMs's guard in usage-limits.js. +const ARK_PROVIDER_BUDGET_GUARD_MS = 1_500; + +// arkcli period label -> canonical window slot. `session` is the 5-hour +// rolling window; `weekly` and `monthly` refresh on calendar boundaries. +const ARK_PERIOD_WINDOW = { + session: "primary_window", + weekly: "secondary_window", + monthly: "tertiary_window", +}; + +function clampPercent(value) { + return Math.max(0, Math.min(100, value)); +} + +function normalizeResetAt(value) { + if (value === null || value === undefined || value === "") return null; + if (typeof value === "number" || /^\d+(?:\.\d+)?$/.test(String(value).trim())) { + const raw = Number(value); + if (!Number.isFinite(raw)) return null; + const milliseconds = raw > 10_000_000_000 ? raw : raw * 1000; + const date = new Date(milliseconds); + return Number.isFinite(date.getTime()) ? date.toISOString() : null; + } + const timestamp = Date.parse(String(value)); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null; +} + +// The provider's display name is already "Ark Coding Plan", so the plan label +// carries only the tier — otherwise the panel title would read +// "Ark Coding Plan Coding Plan Lite". +function planLabelForTier(tier) { + const normalized = String(tier || "").trim().toLowerCase(); + if (normalized === "pro") return "Pro"; + if (normalized === "lite") return "Lite"; + return tier && String(tier).trim() ? String(tier).trim() : null; +} + +/** + * Normalize the JSON payload returned by `arkcli plans get --format json`. + * The tier ("lite" / "pro") lives on the plans payload, not on the usage + * payload, so it is resolved here and merged into the plan label. + * Returns null when there is no Coding Plan entry. + */ +function normalizeArkPlansResponse(body) { + if (!body || typeof body !== "object") return null; + const plans = Array.isArray(body.plans) ? body.plans : []; + const plan = plans.find((entry) => entry?.key === "coding-plan"); + return plan?.tier ? String(plan.tier) : null; +} + +function arkProfileIdentity(body) { + const profile = body?.profile && typeof body.profile === "object" ? body.profile : body; + const name = typeof body?.profile === "string" + ? body.profile + : profile?.name || profile?.profile || profile?.profile_name; + let userId = profile?.user_id || profile?.userId || body?.user_id || body?.userId; + if (!userId) { + // `arkcli profile show` reports the account through owner_trn / + // identity_key (e.g. "trn:iam::1234567890:root" / "volc-1234567890") + // instead of a user_id field — extract the numeric id so identities + // coming from `usage plan`'s viewer and from `profile show` compare + // equal. + const trnMatch = String(profile?.owner_trn || "").match(/::(\d+):/); + if (trnMatch) userId = trnMatch[1]; + else { + const keyMatch = String(profile?.identity_key || "").match(/-(\d+)$/); + if (keyMatch) userId = keyMatch[1]; + } + } + const identity = [name, userId].filter(Boolean).join(":"); + return identity || null; +} + +/** + * Normalize the JSON payload returned by `arkcli usage plan --format json`. + * Returns `null` when the account has no active Coding Plan subscription + * (caller reports `configured: false`). Throws when the payload shape is + * unusable so the caller can fall back to the disk cache. + */ +function normalizeArkCodingPlanResponse(body) { + if (!body || typeof body !== "object") { + throw new Error("Ark Coding Plan response is not an object."); + } + const items = Array.isArray(body.items) ? body.items : []; + const item = items.find((entry) => entry?.product === "coding-plan"); + if (!item || item.subscribed !== true) return null; + + const windows = {}; + const periods = Array.isArray(item.periods) ? item.periods : []; + for (const period of periods) { + const slot = ARK_PERIOD_WINDOW[period?.label]; + if (!slot) continue; + const percent = Number(period.percent); + if (!Number.isFinite(percent)) continue; + windows[slot] = { + used_percent: clampPercent(percent), + reset_at: normalizeResetAt(period.reset_at), + unit: "calls", + }; + } + if (!windows.primary_window && !windows.secondary_window && !windows.tertiary_window) { + throw new Error("Ark Coding Plan response contains no usable quota periods."); + } + + return { + configured: true, + error: null, + plan_label: planLabelForTier(item.tier), + primary_window: windows.primary_window || null, + secondary_window: windows.secondary_window || null, + tertiary_window: windows.tertiary_window || null, + source: "provider-api", + profile_identity: arkProfileIdentity(body.viewer), + }; +} + +function arkCodingPlanCachePath({ home = os.homedir() } = {}) { + return path.join(home, ".tokentracker", "tracker", ARK_LIMITS_CACHE_FILE); +} + +function readArkCodingPlanLimitsCache({ home = os.homedir(), nowMs = Date.now(), profileIdentity } = {}) { + try { + const parsed = JSON.parse(fs.readFileSync(arkCodingPlanCachePath({ home }), "utf8")); + // Deliberately fail-open: the guard only applies when `profile show` + // could establish the current identity. When it also failed, serving + // the stale cache beats erroring out — availability over strictness. + if (profileIdentity && parsed?.profile_identity !== profileIdentity) return null; + const cachedAtMs = Date.parse(parsed?.cached_at || ""); + if (!Number.isFinite(cachedAtMs) || cachedAtMs > nowMs + 60_000) return null; + // A window whose reset_at has passed is stale — the quota has already + // rolled over, so serving its old used_percent would mislead. Drop it. + const windows = [parsed?.primary_window, parsed?.secondary_window, parsed?.tertiary_window]; + const surviving = windows.map((window) => { + if (!window) return null; + const resetAtMs = Date.parse(window.reset_at || ""); + if (Number.isFinite(resetAtMs) && resetAtMs <= nowMs) return null; + return window; + }); + if (surviving.every((window) => !window)) return null; + // Undated windows can't be checked against a reset, so drop each one when + // the snapshot is too old. A future-dated sibling must not keep it alive. + const bounded = surviving.map((window) => { + if (!window) return null; + return Number.isFinite(Date.parse(window.reset_at || "")) + || nowMs - cachedAtMs <= ARK_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS + ? window + : null; + }); + if (bounded.every((window) => !window)) return null; + return { + configured: true, + error: null, + plan_label: typeof parsed?.plan_label === "string" ? parsed.plan_label : null, + primary_window: bounded[0], + secondary_window: bounded[1], + tertiary_window: bounded[2], + cached: true, + stale: true, + cached_at: parsed.cached_at, + source: "disk-cache", + }; + } catch (_error) { + return null; + } +} + +function writeArkCodingPlanLimitsCache(limits, { home = os.homedir(), nowMs = Date.now() } = {}) { + if (!limits?.configured || limits.error) return; + const cachePath = arkCodingPlanCachePath({ home }); + const payload = { + plan_label: limits.plan_label || null, + profile_identity: limits.profile_identity || null, + primary_window: limits.primary_window || null, + secondary_window: limits.secondary_window || null, + tertiary_window: limits.tertiary_window || null, + cached_at: new Date(nowMs).toISOString(), + }; + try { + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + const tmpPath = `${cachePath}.${process.pid}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 }); + fs.renameSync(tmpPath, cachePath); + } catch (_error) {} +} + +// runCommand / whichBinary / resolveBinaryPath now live in ./command-runner, +// shared with usage-limits.js (single implementation, no forked copies). + +// arkcli keeps its config and credentials under ~/.arkcli (plus a couple of +// platform-typical alternates). Checking for these directories is a +// spawn-free way to tell "arkcli has never been installed" apart from +// "installed but the quota call failed" — machines without the CLI skip the +// binary probe entirely on every poll, so the 5s refresh cadence never pays +// a `which` spawn for a provider they cannot use. +function hasArkCliInstallEvidence({ home = os.homedir(), platform = process.platform } = {}) { + const candidates = [ + path.join(home, ".arkcli"), + path.join(home, ".config", "arkcli"), + ]; + if (platform === "win32") { + candidates.push(path.join(home, "AppData", "Roaming", "arkcli")); + } + for (const dir of candidates) { + try { + if (fs.statSync(dir).isDirectory()) return true; + } catch (_error) {} + } + return false; +} + +function trimStderr(stderr) { + const text = String(stderr || "").trim(); + if (!text) return null; + return text.length > ARK_CLI_STDERR_TRIM + ? `${text.slice(0, ARK_CLI_STDERR_TRIM)}…` + : text; +} + +/** + * Fetch Ark Coding Plan quota windows from the local `arkcli` binary. + * + * Resolution order: + * 1. no config-dir evidence AND no arkcli in the global bin dirs + * -> { configured: false }, zero spawns + * 2. arkcli binary not resolvable -> { configured: false } + * 3. `arkcli usage plan` succeeds but no subscription -> { configured: false } + * 4. live success -> { configured: true, ...windows } + * 5. command/parse failure -> bounded disk cache -> { configured: true, ...stale } + * 6. nothing usable -> { configured: true, error } + * + * Only `usage plan` runs on the happy path. `plans get` (tier label) runs + * only when the usage response carries no tier, and `profile show` (the + * cross-account cache guard) only when the disk cache is consulted. + * + * `providerTimeoutMs` bounds the whole serial chain (mirrors the codex + * remaining-budget pattern): each CLI call's timeout is clamped to what + * is left of the budget, and calls whose share has run out are skipped + * so the disk-cache fallback still resolves inside the outer race. + */ +async function fetchArkCodingPlanLimits({ + commandRunner, + home = os.homedir(), + nowMs = Date.now(), + platform = process.platform, + signal, + globalBinDirs, + providerTimeoutMs = ARK_PROVIDER_TIMEOUT_MS, +} = {}) { + // Mirror of codexResetCreditListTimeoutMs: every CLI call in the serial + // chain gets a timeout clamped to what is left of the provider budget, + // so the chain can never outrun the outer provider race and starve the + // disk-cache fallback. + const startedAtMs = performance.now(); + const budgetedTimeoutMs = (fullTimeoutMs) => { + if (!Number.isFinite(providerTimeoutMs) || providerTimeoutMs <= 0) return fullTimeoutMs; + const remainingMs = providerTimeoutMs - (performance.now() - startedAtMs); + if (remainingMs <= 0) return 0; + const guardedMs = Math.floor(remainingMs - ARK_PROVIDER_BUDGET_GUARD_MS); + if (guardedMs <= 0) return 0; + return Math.min(fullTimeoutMs, guardedMs); + }; + + const searchDirs = () => Array.isArray(globalBinDirs) + ? globalBinDirs + : commonGlobalBinDirectories({ home, platform }); + + let arkcliPath; + if (hasArkCliInstallEvidence({ home, platform })) { + try { + arkcliPath = await resolveBinaryPath("arkcli", { commandRunner, home, platform, signal, globalBinDirs }); + } catch (_error) { + arkcliPath = null; + } + } else { + // No config-dir evidence — still resolve spawn-free first: an arkcli + // found in a global bin directory counts as install evidence too (the + // CLI may keep its config somewhere we don't know about). Only when + // that also misses do we bail, so machines without the CLI still pay + // zero spawns per poll. + arkcliPath = statBinaryInDirs("arkcli", searchDirs(), platform); + } + if (!arkcliPath) return { configured: false }; + + const commandOptions = { + signal, + killProcessGroup: true, + platform, + // npm installs CLI entrypoints as .cmd shims on Windows, which a direct + // spawn cannot execute. Every argument here is a constant with no shell + // metacharacters, so shell execution is safe for this call site only — + // it must stay opt-in (see command-runner.js). + useShell: platform === "win32", + }; + + // Budget already drained before the primary call could run: report the + // timeout without touching the cache — an unverified cache (profile + // identity unknown) must not be served on this path, mirroring the + // outer provider race's behavior. The verified-cache fallback below is + // what keeps hung-CLI polls serving last-known data. + const usageTimeoutMs = budgetedTimeoutMs(ARK_USAGE_PLAN_TIMEOUT_MS); + if (usageTimeoutMs <= 0) { + return { configured: true, error: "Ark Coding Plan provider timed out before arkcli could run." }; + } + + // Spawn the resolved absolute path, never the bare name: on Windows + // cmd.exe searches the current directory before PATH, so a bare + // `arkcli` would let an `arkcli.bat` dropped in the server cwd hijack + // the spawn. + const result = await runCommand( + commandRunner, + arkcliPath, + ["usage", "plan", "--format", "json"], + { ...commandOptions, timeout: usageTimeoutMs }, + ); + + const failWithCache = async (message) => { + // Short leash, further clamped to the remaining provider budget: this + // runs after `usage plan` already burned most of the budget; a slow or + // hung arkcli here must not starve the cache read that the caller is + // actually waiting for. When no budget is left the spawn is skipped + // entirely and the cache is read fail-open (identity unknown). + const profileTimeoutMs = budgetedTimeoutMs(ARK_PROFILE_SHOW_TIMEOUT_MS); + const profileIdentity = profileTimeoutMs > 0 + ? await runCommand( + commandRunner, + arkcliPath, + ["profile", "show", "--format", "json"], + { ...commandOptions, timeout: profileTimeoutMs }, + ).then((profileResult) => { + if (profileResult?.error || profileResult?.status !== 0) return null; + try { + return arkProfileIdentity(JSON.parse(String(profileResult.stdout || ""))); + } catch (_error) { + return null; + } + }).catch(() => null) + : null; + const cached = readArkCodingPlanLimitsCache({ home, nowMs, profileIdentity }); + if (cached) return cached; + return { configured: true, error: message }; + }; + + if (result?.error || result?.status !== 0) { + const detail = result?.error?.message + || (result?.status !== 0 && result?.status !== null + ? `arkcli exited with code ${result.status}` + : "arkcli usage plan failed"); + const stderr = trimStderr(result?.stderr); + return failWithCache(stderr ? `${detail}: ${stderr}` : detail); + } + + let body; + try { + body = JSON.parse(String(result?.stdout || "")); + } catch (_error) { + return failWithCache("arkcli usage plan returned invalid JSON."); + } + + let limits; + try { + limits = normalizeArkCodingPlanResponse(body); + } catch (error) { + return failWithCache(error?.message || "Ark Coding Plan response could not be parsed."); + } + if (!limits) { + // Only a response that explicitly carries the coding-plan entry with + // `subscribed: false` confirms the plan was retired — drop the cache + // then, or a later transient CLI failure would resurrect the retired + // plan's numbers through failWithCache. A payload with *no* + // coding-plan entry at all (`{}`, `{"items":[]}` — not logged in, + // backend degraded, product key renamed) is ambiguous and must NOT + // destroy the cache: transient signals never drive persistent state. + const entry = Array.isArray(body?.items) + ? body.items.find((candidate) => candidate?.product === "coding-plan") + : null; + if (entry && entry.subscribed === false) { + try { + fs.unlinkSync(arkCodingPlanCachePath({ home })); + } catch (_error) {} + } + return { configured: false }; + } + + if (!limits.plan_label) { + // The tier ("lite" / "pro") lives on the `plans get` payload; fetch it + // only when the usage response did not carry one, and only while the + // provider budget still has room for the spawn. A skipped fetch just + // leaves the label null — never worth losing the live data over. + const plansTimeoutMs = budgetedTimeoutMs(ARK_USAGE_PLAN_TIMEOUT_MS); + if (plansTimeoutMs > 0) { + const plansResult = await runCommand( + commandRunner, + arkcliPath, + ["plans", "get", "--format", "json"], + { ...commandOptions, timeout: plansTimeoutMs }, + ); + if (!plansResult?.error && plansResult?.status === 0) { + try { + const tier = normalizeArkPlansResponse(JSON.parse(String(plansResult.stdout || ""))); + if (tier) limits.plan_label = planLabelForTier(tier); + } catch (_error) {} + } + } + } + + writeArkCodingPlanLimitsCache(limits, { home, nowMs }); + return limits; +} + +module.exports = { + ARK_PERIOD_WINDOW, + normalizeArkPlansResponse, + arkProfileIdentity, + normalizeArkCodingPlanResponse, + readArkCodingPlanLimitsCache, + writeArkCodingPlanLimitsCache, + hasArkCliInstallEvidence, + fetchArkCodingPlanLimits, +}; diff --git a/src/lib/command-runner.js b/src/lib/command-runner.js new file mode 100644 index 000000000..6423ff3ef --- /dev/null +++ b/src/lib/command-runner.js @@ -0,0 +1,286 @@ +"use strict"; + +// Shared command runner for local-CLI limit providers. +// +// Extracted from usage-limits.js (which keeps a require of it) so +// ark-coding-plan-limits.js and future providers don't have to copy it. +// This is a superset of both former copies: the original's `completeWhen` +// early-settlement hook, plus the hardening that previously only lived in +// the ark copy — an abort `signal` wired into the spawn lifecycle, a +// `platform` override with `where.exe` discovery on native Windows, and a +// byte-capped maxBuffer for piped spawn output. + +const cp = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +function runCommand(commandRunner, command, args, options = {}) { + const merged = { + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + useShell: false, + ...options, + }; + if (typeof commandRunner === "function") { + return Promise.resolve(commandRunner(command, args, merged)); + } + + const { + timeout, + maxBuffer, + completeWhen, + completionGraceMs = 250, + killProcessGroup = false, + platform = process.platform, + signal, + useShell = false, + ...spawnOptions + } = merged; + return new Promise((resolve) => { + if (signal?.aborted) { + const error = new Error(`spawn ${command} aborted`); + error.name = "AbortError"; + resolve({ status: null, stdout: "", stderr: "", error }); + return; + } + + const useProcessGroup = killProcessGroup && platform !== "win32"; + // `useShell` is opt-in per call site. It exists for npm's Windows .cmd + // shims, which Node's spawn cannot execute directly — but shell + // execution means cmd.exe re-parses the joined command line, so any + // argument carrying shell metacharacters (e.g. a powershell -Command + // script with `|`) would be split. Default false keeps direct spawns, + // which is what every pre-existing usage-limits call site expects. + // Under shell execution quote the command unconditionally: cmd.exe + // metacharacters are not limited to whitespace (`C:\Users\a&b\...` + // has none yet splits at `&`), and Windows account names allow them. + const shellCommand = useShell && !command.startsWith('"') + ? `"${command}"` + : command; + let child; + try { + child = cp.spawn(shellCommand, args, { + ...spawnOptions, + detached: useProcessGroup || spawnOptions.detached, + shell: useShell, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + resolve({ status: null, stdout: "", stderr: "", error }); + return; + } + + let stdout = ""; + let stderr = ""; + let outputBytes = 0; + let settled = false; + let timedOut = false; + let timer = null; + let hardTimer = null; + let completionTimer = null; + let abortListener = null; + + const settle = ({ status = null, error = null } = {}) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + if (hardTimer) clearTimeout(hardTimer); + if (completionTimer) clearTimeout(completionTimer); + if (abortListener) signal?.removeEventListener("abort", abortListener); + let finalError = error; + if (!finalError && timedOut) { + finalError = new Error(`spawn ${command} ETIMEDOUT`); + finalError.code = "ETIMEDOUT"; + } + const result = { status, stdout, stderr }; + if (finalError) result.error = finalError; + resolve(result); + }; + + const signalChild = (killSignal) => { + try { + if (useProcessGroup && Number.isInteger(child.pid)) { + process.kill(-child.pid, killSignal); + } else { + child.kill(killSignal); + } + } catch (_error) {} + }; + + const stopChild = ({ timeoutExpired = false } = {}) => { + if (settled) return; + if (timeoutExpired) timedOut = true; + signalChild("SIGTERM"); + // A CLI may leave descendants or inherited stdio alive after SIGTERM. + // Escalate after a short grace period and settle even if close never fires. + hardTimer = setTimeout(() => { + signalChild("SIGKILL"); + settle({ status: null }); + }, 1000); + if (typeof hardTimer.unref === "function") hardTimer.unref(); + }; + + const scheduleCompletion = () => { + if (typeof completeWhen !== "function" || settled) return; + let complete = false; + try { + complete = Boolean(completeWhen(stdout, stderr)); + } catch (_error) {} + if (!complete) return; + if (completionTimer) clearTimeout(completionTimer); + completionTimer = setTimeout( + () => stopChild(), + Math.max(0, Number(completionGraceMs) || 0), + ); + }; + + const appendOutput = (key, chunk) => { + if (settled) return; + if (key === "stdout") stdout += chunk; + else stderr += chunk; + outputBytes += Buffer.byteLength(chunk, "utf8"); + // Unlike exec/execFile, spawn does not apply a maxBuffer guard to piped + // streams. Enforce the combined byte cap here so a verbose CLI cannot + // grow this process without bound. + if (outputBytes > maxBuffer) { + const error = new Error(`spawn ${command} maxBuffer length exceeded`); + error.code = "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"; + signalChild("SIGKILL"); + settle({ status: null, error }); + return; + } + scheduleCompletion(); + }; + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk) => appendOutput("stdout", chunk)); + child.stderr?.on("data", (chunk) => appendOutput("stderr", chunk)); + child.on("error", (error) => settle({ status: null, error })); + child.on("close", (code) => settle({ status: timedOut ? null : code })); + + if (signal) { + abortListener = () => stopChild(); + signal.addEventListener("abort", abortListener, { once: true }); + if (signal.aborted) abortListener(); + } + if (Number.isFinite(timeout) && timeout > 0) { + timer = setTimeout(() => stopChild({ timeoutExpired: true }), timeout); + } + }); +} + +// Locate a binary on PATH. Unix uses `which`; native Windows ships no `which` +// (it has `where.exe` instead), so blindly spawning `which` there returns +// ENOENT and every provider would report itself unconfigured even when the +// binary is installed and signed in. +async function whichBinary(binary, { commandRunner, platform = process.platform, signal } = {}) { + const probe = platform === "win32" ? "where" : "which"; + const result = await runCommand(commandRunner, probe, [binary], { + timeout: 2000, + signal, + platform, + killProcessGroup: true, + }); + if (result?.error || result?.status !== 0) return null; + const stdout = typeof result?.stdout === "string" ? result.stdout.trim() : ""; + if (!stdout) return null; + // `where` on Windows emits CRLF and may list several matches across PATH + // entries; take the first line without the trailing `\r`, or the polluted + // path would fail at spawn time. + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) || null; +} + +async function isBinaryAvailable(binary, { commandRunner, platform, signal } = {}) { + return (await whichBinary(binary, { commandRunner, platform, signal })) !== null; +} + +// Expand a versioned install root (e.g. ~/.nvm/versions/node// or +// fnm's node-versions//installation/) into its per-version bin +// directories. Returns [] when the root does not exist. +function versionedBinDirs(root, inner) { + try { + return fs.readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) + .sort((a, b) => b.name.localeCompare(a.name, "en", { numeric: true })) + .map((entry) => path.join(root, entry.name, ...inner)); + } catch (_error) { + return []; + } +} + +// Directories where globally installed CLIs commonly live even when the +// current process has a minimal PATH (e.g. a Finder-launched macOS app does +// not inherit the login-shell PATH, so Homebrew and npm global binaries are +// unreachable through `which`). Probed with statSync — no spawn. +function commonGlobalBinDirectories({ home = os.homedir(), platform = process.platform } = {}) { + if (platform === "win32") { + return [ + path.join(home, "AppData", "Roaming", "npm"), + ]; + } + return [ + "/opt/homebrew/bin", + "/usr/local/bin", + path.join(home, ".npm-global", "bin"), + // volta keeps shims for every global install here + path.join(home, ".volta", "bin"), + // nvm / fnm install npm globals under a per-version prefix that a + // minimal PATH never sees; newest version first. + ...versionedBinDirs(path.join(home, ".nvm", "versions", "node"), ["bin"]), + ...versionedBinDirs(path.join(home, ".local", "share", "fnm", "node-versions"), ["installation", "bin"]), + ...(platform === "darwin" + ? versionedBinDirs(path.join(home, "Library", "Application Support", "fnm", "node-versions"), ["installation", "bin"]) + : []), + ]; +} + +// Probe `binary` inside the given directories with statSync — no spawn. +// Exported so providers can treat a hit as install evidence without +// paying for a `which` process. +function statBinaryInDirs(binary, searchDirs, platform = process.platform) { + for (const dir of searchDirs) { + const candidate = path.join(dir, binary); + for (const suffix of platform === "win32" ? ["", ".cmd", ".exe"] : [""]) { + try { + if (fs.statSync(candidate + suffix).isFile()) return candidate + suffix; + } catch (_error) {} + } + } + return null; +} + +/** + * Resolve a binary to an absolute path: `which`/`where` first, then a + * spawn-free probe of the common global-install directories above. Returns + * null when the binary cannot be found. Callers should spawn the returned + * path (not the bare name): a bare name goes through PATH search again — + * and on Windows cmd.exe searches the current directory first, which would + * let an `arkcli.bat` dropped in the server cwd hijack the spawn. + */ +async function resolveBinaryPath(binary, { commandRunner, home, platform = process.platform, signal, globalBinDirs } = {}) { + let resolved = null; + try { + resolved = await whichBinary(binary, { commandRunner, platform, signal }); + } catch (_error) { + resolved = null; + } + if (resolved) return resolved; + const searchDirs = Array.isArray(globalBinDirs) + ? globalBinDirs + : commonGlobalBinDirectories({ home, platform }); + return statBinaryInDirs(binary, searchDirs, platform); +} + +module.exports = { + runCommand, + whichBinary, + isBinaryAvailable, + commonGlobalBinDirectories, + statBinaryInDirs, + resolveBinaryPath, +}; diff --git a/src/lib/usage-limits.js b/src/lib/usage-limits.js index 79a007edf..ed6b06b1f 100644 --- a/src/lib/usage-limits.js +++ b/src/lib/usage-limits.js @@ -29,8 +29,14 @@ const { fetchGrokLimits } = require("./grok-limits"); const { fetchZcodeLimits } = require("./zcode-limits"); const { fetchOpencodeGoLimits } = require("./opencode-go-limits"); const { fetchQoderLimits, fetchQoderCnLimits } = require("./qoder-limits"); +const { fetchArkCodingPlanLimits } = require("./ark-coding-plan-limits"); const { fetchProviderServiceStatus } = require("./provider-status"); const { readSqliteJsonRows, readSqliteJsonRowsAsync } = require("./sqlite-reader"); +const { + runCommand, + whichBinary, + isBinaryAvailable, +} = require("./command-runner"); const execFileAsync = promisify(cp.execFile); @@ -147,6 +153,17 @@ function withProviderTimeout(promise, label, timeoutMs) { return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } +// Provider timeouts normally race a promise so that an uncooperative remote +// request cannot block all limit reads. Local CLI providers also need an abort +// signal: without it, their spawned commands may continue after the caller has +// already received a timeout result. +function withAbortableProviderTimeout(start, label, timeoutMs) { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return start(undefined); + const controller = new AbortController(); + return withProviderTimeout(start(controller.signal), label, timeoutMs) + .finally(() => controller.abort()); +} + function parseRetryAfterSeconds(headers) { const ra = headers?.get ? headers.get("retry-after") : null; const sec = ra ? Number.parseInt(ra, 10) : NaN; @@ -953,8 +970,7 @@ const GEMINI_CLI_FALLBACK_OAUTH_CLIENT = Object.freeze({ }); async function extractGeminiOauthClientCredentials({ commandRunner, home } = {}) { - const result = await runCommand(commandRunner, "which", ["gemini"], { timeout: 2000 }); - const geminiPath = typeof result?.stdout === "string" ? result.stdout.trim() : ""; + const geminiPath = (await whichBinary("gemini", { commandRunner })) ?? ""; const geminiPaths = [ ...(geminiPath ? [geminiPath] : []), @@ -1228,145 +1244,10 @@ async function fetchGeminiLimits({ home, env, fetchImpl = fetch, commandRunner } } } -// Async command runner. Previously this wrapped `cp.spawnSync`, which blocked the -// Node event loop for the full command duration (up to 20s for Kiro) and froze every -// other local-api endpoint plus the other providers' withProviderTimeout races. -// Returns a promise for a spawnSync-shaped result: { status, stdout, stderr, error? }. -// Injected runners (tests) may stay synchronous — their return value is wrapped in -// Promise.resolve so both sync and async runners work. -function runCommand(commandRunner, command, args, options = {}) { - const merged = { - encoding: "utf8", - maxBuffer: 10 * 1024 * 1024, - ...options, - }; - if (typeof commandRunner === "function") { - return Promise.resolve(commandRunner(command, args, merged)); - } - - const { - timeout, - maxBuffer, - completeWhen, - completionGraceMs = 250, - killProcessGroup = false, - ...spawnOptions - } = merged; - return new Promise((resolve) => { - let child; - const useProcessGroup = - killProcessGroup && process.platform !== "win32"; - try { - child = cp.spawn(command, args, { - ...spawnOptions, - detached: useProcessGroup || spawnOptions.detached, - stdio: ["ignore", "pipe", "pipe"], - }); - } catch (error) { - resolve({ status: null, stdout: "", stderr: "", error }); - return; - } - - let stdout = ""; - let stderr = ""; - let settled = false; - let timedOut = false; - let timer = null; - let hardTimer = null; - let completionTimer = null; - - const settle = ({ status = null, error = null } = {}) => { - if (settled) return; - settled = true; - if (timer) clearTimeout(timer); - if (hardTimer) clearTimeout(hardTimer); - if (completionTimer) clearTimeout(completionTimer); - let finalError = error; - if (!finalError && timedOut) { - finalError = new Error(`spawn ${command} ETIMEDOUT`); - finalError.code = "ETIMEDOUT"; - } - const result = { status, stdout, stderr }; - if (finalError) result.error = finalError; - resolve(result); - }; - - const signalChild = (signal) => { - try { - if (useProcessGroup && Number.isInteger(child.pid)) { - process.kill(-child.pid, signal); - } else { - child.kill(signal); - } - } catch (_error) {} - }; - - const stopChild = ({ timeoutExpired = false } = {}) => { - if (settled) return; - if (timeoutExpired) timedOut = true; - signalChild("SIGTERM"); - // Guarantee settlement even if the process group ignores SIGTERM or - // keeps inherited stdio open. - hardTimer = setTimeout(() => { - signalChild("SIGKILL"); - settle({ status: null }); - }, 1000); - if (typeof hardTimer.unref === "function") hardTimer.unref(); - }; - - if (Number.isFinite(timeout) && timeout > 0) { - timer = setTimeout(() => { - stopChild({ timeoutExpired: true }); - }, timeout); - } - - const scheduleCompletion = () => { - if (typeof completeWhen !== "function" || settled) return; - let complete = false; - try { - complete = Boolean(completeWhen(stdout, stderr)); - } catch (_error) {} - if (!complete) return; - if (completionTimer) clearTimeout(completionTimer); - completionTimer = setTimeout( - () => stopChild(), - Math.max(0, Number(completionGraceMs) || 0), - ); - }; - - const collect = (stream, append) => { - if (!stream) return; - stream.setEncoding("utf8"); - stream.on("data", (chunk) => { - append(chunk); - if (stdout.length + stderr.length > maxBuffer) { - const error = new Error(`spawn ${command} maxBuffer length exceeded`); - error.code = "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"; - signalChild("SIGKILL"); - settle({ status: null, error }); - return; - } - scheduleCompletion(); - }); - }; - collect(child.stdout, (chunk) => { stdout += chunk; }); - collect(child.stderr, (chunk) => { stderr += chunk; }); - - child.on("error", (error) => settle({ status: null, error })); - child.on("close", (code) => settle({ status: timedOut ? null : code })); - }); -} - -async function whichBinary(binary, { commandRunner } = {}) { - const result = await runCommand(commandRunner, "which", [binary], { timeout: 2000 }); - if (result?.error || result?.status !== 0) return null; - const stdout = typeof result?.stdout === "string" ? result.stdout.trim() : ""; - return stdout ? stdout.split("\n")[0] : null; -} - -async function isBinaryAvailable(binary, { commandRunner } = {}) { - return (await whichBinary(binary, { commandRunner })) !== null; -} +// runCommand / whichBinary / isBinaryAvailable now live in ./command-runner +// (shared with ark-coding-plan-limits.js). The async runner there keeps the +// same spawnSync-shaped contract: { status, stdout, stderr, error? }, and +// injected test runners may stay synchronous. function stripAnsi(text) { return String(text || "").replace(/\x1B\[[0-9;?]*[A-Za-z]|\x1B\].*?\x07/g, ""); @@ -3182,7 +3063,7 @@ async function fetchUsageLimitsUncached({ : null; const providerFetch = withFetchTimeout(fetchImpl, providerTimeoutMs); - const [claudeResult, codexResult, cursor, kimi, gemini, kiro, antigravity, copilot, grok, zcode, opencodeGo, qoder, qoderCn, claudeServiceStatus] = await Promise.all([ + const [claudeResult, codexResult, cursor, kimi, gemini, kiro, antigravity, copilot, grok, zcode, opencodeGo, qoder, qoderCn, codingPlan, claudeServiceStatus] = await Promise.all([ claudeToken && !freshClaudeCache && !claudeRetryAtMs ? withProviderTimeout(fetchClaudeUsageLimits(claudeToken, { fetchImpl: providerFetch, maxAttempts: 1 }), "Claude", providerTimeoutMs).then( (value) => ({ status: "fulfilled", value }), @@ -3238,6 +3119,22 @@ async function fetchUsageLimitsUncached({ "Qoder CN", providerTimeoutMs, ).catch((reason) => ({ configured: true, error: reason?.message || "Unknown error" })), + // Ark Coding Plan (火山方舟): subscription quota via the user's own + // arkcli binary. No token-consumption source — consumption for the + // compatible CLIs is already counted from their local files; this only + // surfaces the 5h/week/month quota percentages. + withAbortableProviderTimeout( + (signal) => fetchArkCodingPlanLimits({ + commandRunner, + home, + nowMs, + platform, + signal, + providerTimeoutMs, + }), + "Ark Coding Plan", + providerTimeoutMs, + ).catch((reason) => ({ configured: true, error: reason?.message || "Unknown error" })), // Public status-page probe (fail-soft, own 5-min cache in provider-status.js). // Only probed for configured accounts — without a token the Claude section // never renders, so the reading would have nowhere to go. @@ -3398,6 +3295,7 @@ async function fetchUsageLimitsUncached({ opencodeGo: withPlanLabel(opencodeGo, opencodeGo?.plan_label, "OpenCode Go"), qoder: withPlanLabel(qoder, qoder?.plan_label, "Qoder"), qoderCn: withPlanLabel(qoderCn, qoderCn?.plan_label, "Qoder CN"), + codingPlan: withPlanLabel(codingPlan, codingPlan?.plan_label, "Ark Coding Plan"), }; for (const [providerName, provider] of Object.entries(data)) { diff --git a/test/ark-coding-plan-limits.test.js b/test/ark-coding-plan-limits.test.js new file mode 100644 index 000000000..f62d630e3 --- /dev/null +++ b/test/ark-coding-plan-limits.test.js @@ -0,0 +1,752 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { + normalizeArkPlansResponse, + arkProfileIdentity, + normalizeArkCodingPlanResponse, + fetchArkCodingPlanLimits, + writeArkCodingPlanLimitsCache, +} = require("../src/lib/ark-coding-plan-limits"); +const { + runCommand, + resolveBinaryPath, + whichBinary, + commonGlobalBinDirectories, +} = require("../src/lib/command-runner"); + +const PROFILE_JSON = JSON.stringify({ profile: "coding-plan_test_region_personal", user_id: "test-user-001" }); + +const USAGE_JSON = JSON.stringify({ + viewer: { + auth_method: "sso", + user_id: "test-user-001", + profile: "coding-plan_test_region_personal", + }, + items: [ + { + product: "coding-plan", + edition: "personal", + subscribed: true, + periods: [ + { label: "session", percent: 32.7377, reset_at: "2026-08-11T17:42:00+08:00" }, + { label: "weekly", percent: 15.670588333333333, reset_at: "2026-08-17T00:00:00+08:00" }, + { label: "monthly", percent: 8.179090833333333, reset_at: "2026-09-09T23:59:59+08:00" }, + ], + }, + ], +}); + +// Fetch-path fixtures use reset times relative to `nowMs` so the cache +// rollover tests never go stale as wall-clock time moves past a fixed date. +// The viewer identity mirrors PROFILE_JSON so cache snapshots written from +// the usage payload match the identity guard computed from `profile show`. +function usageJsonFor({ nowMs = Date.now(), withTier = false } = {}) { + const iso = (ms) => new Date(ms).toISOString(); + return JSON.stringify({ + viewer: { + auth_method: "sso", + user_id: "test-user-001", + profile: "coding-plan_test_region_personal", + }, + items: [ + { + product: "coding-plan", + edition: "personal", + subscribed: true, + ...(withTier ? { tier: "lite" } : {}), + periods: [ + { label: "session", percent: 32.7377, reset_at: iso(nowMs + 3 * 3600_000) }, + { label: "weekly", percent: 15.670588333333333, reset_at: iso(nowMs + 3 * 86400_000) }, + { label: "monthly", percent: 8.179090833333333, reset_at: iso(nowMs + 20 * 86400_000) }, + ], + }, + ], + }); +} + +const PLANS_JSON = JSON.stringify({ + plans: [ + { key: "coding-plan", name: "Coding Plan", scope: "personal", tier: "lite", status: "Running" }, + ], +}); + +// Injects a spawnSync-shaped runner that dispatches on the command name. +// Ark commands arrive as the absolute path resolved by the discovery probe, +// never as a bare "arkcli" — matching what the provider spawns. +function isArkCommand(command) { + return /arkcli(\.exe)?$/i.test(String(command || "")); +} + +function mockRunner({ + which = true, + plansStdout = PLANS_JSON, + usageStdout = usageJsonFor(), + usageStatus = 0, + usageError = null, +} = {}) { + return (command, args) => { + if (command === "which") { + return which + ? { status: 0, stdout: "/usr/local/bin/arkcli\n", stderr: "" } + : { status: 1, stdout: "", stderr: "" }; + } + // Native Windows discovery probe (where.exe). + if (command === "where") { + return which + ? { status: 0, stdout: "C:\\Program Files\\arkcli.exe\n", stderr: "" } + : { status: 1, stdout: "", stderr: "" }; + } + if (isArkCommand(command)) { + if (args[0] === "plans") { + return { status: 0, stdout: plansStdout, stderr: "" }; + } + if (args[0] === "usage") { + return { + status: usageStatus, + stdout: usageStdout, + stderr: usageError ? "boom" : "", + ...(usageError ? { error: usageError } : {}), + }; + } + if (args[0] === "profile") { + return { status: 0, stdout: PROFILE_JSON, stderr: "" }; + } + } + return { status: 1, stdout: "", stderr: "unknown command" }; + }; +} + +// Temporary HOME with the ~/.arkcli install-evidence directory by default — +// the spawn-free gate requires it before any binary probe runs. +function tmpHome(t, { arkcliDir = true } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ark-plan-test-")); + if (arkcliDir) fs.mkdirSync(path.join(dir, ".arkcli"), { recursive: true }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +test("normalizeArkCodingPlanResponse maps three periods to windows", () => { + const result = normalizeArkCodingPlanResponse(JSON.parse(USAGE_JSON)); + assert.equal(result.configured, true); + assert.equal(result.primary_window.used_percent, 32.7377); + assert.equal(result.primary_window.reset_at, "2026-08-11T09:42:00.000Z"); + assert.equal(result.primary_window.unit, "calls"); + assert.equal(result.secondary_window.used_percent, 15.670588333333333); + assert.equal(result.secondary_window.reset_at, "2026-08-16T16:00:00.000Z"); + assert.equal(result.tertiary_window.used_percent, 8.179090833333333); + assert.equal(result.tertiary_window.reset_at, "2026-09-09T15:59:59.000Z"); + assert.equal(result.source, "provider-api"); +}); + +test("normalizeArkCodingPlanResponse returns null when not subscribed", () => { + const body = JSON.parse(USAGE_JSON); + body.items[0].subscribed = false; + assert.equal(normalizeArkCodingPlanResponse(body), null); + assert.equal(normalizeArkCodingPlanResponse({ items: [] }), null); +}); + +test("normalizeArkCodingPlanResponse throws on unusable payload", () => { + assert.throws(() => normalizeArkCodingPlanResponse(null)); + assert.throws(() => normalizeArkCodingPlanResponse({ items: [{ product: "coding-plan", subscribed: true, periods: [] }] })); +}); + +test("normalizeArkPlansResponse extracts tier from plans payload", () => { + assert.equal(normalizeArkPlansResponse(JSON.parse(PLANS_JSON)), "lite"); + assert.equal(normalizeArkPlansResponse({ plans: [] }), null); + assert.equal(normalizeArkPlansResponse({ plans: [{ key: "agent-plan", tier: "pro" }] }), null); +}); + +test("arkProfileIdentity extracts the account id from profile show's owner_trn", () => { + // `arkcli profile show --format json` has no user_id field; the account + // surfaces through owner_trn / identity_key instead. + const body = { + name: "coding-plan_cn-beijing_personal", + owner_trn: "trn:iam::1234567890:root", + identity_key: "volc-1234567890", + }; + assert.equal(arkProfileIdentity(body), "coding-plan_cn-beijing_personal:1234567890"); + assert.equal( + arkProfileIdentity({ name: "p", identity_key: "volc-9876543210" }), + "p:9876543210", + ); + // The usage payload's viewer shape must produce the same identity so the + // cache guard compares equal across both sources. + assert.equal( + arkProfileIdentity({ user_id: "1234567890", profile: "coding-plan_cn-beijing_personal" }), + "coding-plan_cn-beijing_personal:1234567890", + ); +}); + +test("runCommand stops a verbose child when its combined output exceeds maxBuffer", async () => { + const result = await runCommand( + undefined, + process.execPath, + [path.join(__dirname, "fixtures", "noisy-command.js")], + { maxBuffer: 1024, timeout: 2_000 }, + ); + assert.equal(result.status, null); + assert.equal(result.error?.code, "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"); +}); + +test("resolveBinaryPath falls back to a spawn-free probe of global bin dirs", async (t) => { + const home = tmpHome(t, { arkcliDir: false }); + const binDir = path.join(home, ".npm-global", "bin"); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(path.join(binDir, "arkcli"), "#!/bin/sh\n", { mode: 0o755 }); + // `which` fails (minimal PATH); the directory probe must find the binary + // and return its absolute path without any extra spawn. + const resolved = await resolveBinaryPath("arkcli", { + commandRunner: async () => ({ status: 1, stdout: "", stderr: "" }), + home, + globalBinDirs: [path.join(home, ".npm-global", "bin")], + }); + assert.equal(resolved, path.join(binDir, "arkcli")); +}); + +test("resolveBinaryPath returns null when nothing resolves", async (t) => { + const home = tmpHome(t, { arkcliDir: false }); + const resolved = await resolveBinaryPath("arkcli", { + commandRunner: async () => ({ status: 1, stdout: "", stderr: "" }), + home, + globalBinDirs: [path.join(home, "empty-bin")], + }); + assert.equal(resolved, null); +}); + +test("fetchArkCodingPlanLimits succeeds with real payloads", async (t) => { + const home = tmpHome(t); + const result = await fetchArkCodingPlanLimits({ commandRunner: mockRunner(), home }); + assert.equal(result.configured, true); + assert.equal(result.error, null); + assert.equal(result.plan_label, "Lite"); + assert.equal(result.primary_window.used_percent, 32.7377); + assert.equal(result.secondary_window.used_percent, 15.670588333333333); + assert.equal(result.tertiary_window.used_percent, 8.179090833333333); + assert.equal(result.source, "provider-api"); + // Cache should have been written. + const cachePath = path.join(home, ".tokentracker", "tracker", "ark-coding-plan-limits-cache.json"); + assert.equal(fs.existsSync(cachePath), true); +}); + +test("fetchArkCodingPlanLimits skips every spawn without install evidence", async (t) => { + const calls = []; + const runner = (command, args) => { + calls.push({ command, args }); + return mockRunner()(command, args); + }; + // No config-dir evidence AND no arkcli in any global bin dir → arkcli was + // never installed on this machine; the provider must bail out before any + // probe so the 5s poll cadence never pays a spawn for it. + const result = await fetchArkCodingPlanLimits({ + commandRunner: runner, + home: tmpHome(t, { arkcliDir: false }), + globalBinDirs: [], + }); + assert.deepEqual(result, { configured: false }); + assert.equal(calls.length, 0); +}); + +test("fetchArkCodingPlanLimits accepts a global-bin arkcli without config-dir evidence", async (t) => { + // arkcli may keep its config outside ~/.arkcli (e.g. ~/.config/arkcli on + // Linux): a binary found in a global bin directory still counts as + // install evidence, resolved spawn-free. + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ark-plan-bindir-")); + const fakeArkcli = path.join(binDir, "arkcli"); + fs.writeFileSync(fakeArkcli, "#!/bin/sh\n"); + t.after(() => fs.rmSync(binDir, { recursive: true, force: true })); + + const calls = []; + const runner = (command, args) => { + calls.push({ command, args }); + return mockRunner({ + which: false, + // Tier on the usage payload keeps `plans get` out of this test — + // the assertion below expects exactly one spawn. + usageStdout: usageJsonFor({ withTier: true }), + })(command, args); + }; + const result = await fetchArkCodingPlanLimits({ + commandRunner: runner, + home: tmpHome(t, { arkcliDir: false }), + globalBinDirs: [binDir], + }); + assert.equal(result.configured, true); + assert.equal(result.plan_label, "Lite"); + // The stat-resolved absolute path is spawned directly — no `which` spawn + // was spent to find it. + assert.deepEqual(calls.map(({ command }) => command), [fakeArkcli]); +}); + +test("fetchArkCodingPlanLimits reports configured:false when arkcli is missing", async (t) => { + const result = await fetchArkCodingPlanLimits({ + commandRunner: mockRunner({ which: false }), + home: tmpHome(t), + // Empty probe list keeps the directory fallback away from the real + // filesystem: this machine has arkcli in /opt/homebrew/bin, and the + // fallback would otherwise find it and break the test. + globalBinDirs: [], + }); + assert.deepEqual(result, { configured: false }); +}); + +test("fetchArkCodingPlanLimits reports configured:false when not subscribed", async (t) => { + const body = JSON.parse(USAGE_JSON); + body.items[0].subscribed = false; + const result = await fetchArkCodingPlanLimits({ + commandRunner: mockRunner({ usageStdout: JSON.stringify(body) }), + home: tmpHome(t), + }); + assert.deepEqual(result, { configured: false }); +}); + +test("fetchArkCodingPlanLimits falls back to disk cache on command failure", async (t) => { + const home = tmpHome(t); + // First run succeeds and writes the cache. + await fetchArkCodingPlanLimits({ commandRunner: mockRunner(), home }); + // Second run fails; the cached snapshot must be served with stale flags. + const runner = mockRunner({ + usageError: new Error("ETIMEDOUT"), + usageStatus: null, + }); + const result = await fetchArkCodingPlanLimits({ commandRunner: runner, home }); + assert.equal(result.configured, true); + assert.equal(result.stale, true); + assert.equal(result.source, "disk-cache"); + assert.equal(result.primary_window.used_percent, 32.7377); +}); + +test("fetchArkCodingPlanLimits surfaces an error when nothing is usable", async (t) => { + const runner = mockRunner({ + usageError: new Error("ETIMEDOUT"), + usageStatus: null, + }); + const result = await fetchArkCodingPlanLimits({ commandRunner: runner, home: tmpHome(t) }); + assert.equal(result.configured, true); + assert.match(result.error, /ETIMEDOUT/); +}); + +test("fetchArkCodingPlanLimits discovers arkcli via where.exe on Windows", async (t) => { + const calls = []; + const runner = (command, args) => { + calls.push({ command, args }); + return mockRunner()(command, args); + }; + const result = await fetchArkCodingPlanLimits({ + commandRunner: runner, + home: tmpHome(t), + platform: "win32", + }); + assert.equal(result.configured, true); + assert.equal(result.plan_label, "Lite"); + // Native Windows discovery must use `where`, never the Unix `which` — on + // Windows `which` does not exist, so spawning it returns ENOENT and every + // provider would look unconfigured even when arkcli is installed. + const commands = calls.map(({ command }) => command); + assert.ok(commands.includes("where"), `expected where.exe probe, got calls: ${commands.join(", ")}`); + assert.ok(!commands.includes("which"), `must not use which on win32, got calls: ${commands.join(", ")}`); + assert.deepEqual(calls.find(({ command }) => command === "where")?.args, ["arkcli"]); +}); + +test("fetchArkCodingPlanLimits spawns the resolved absolute path, not a bare name", async (t) => { + const arkCommands = []; + const runner = (command, args) => { + if (isArkCommand(command)) arkCommands.push(command); + return mockRunner()(command, args); + }; + await fetchArkCodingPlanLimits({ commandRunner: runner, home: tmpHome(t) }); + assert.ok(arkCommands.length > 0); + // Every ark spawn goes through the absolute path from the discovery probe. + // A bare "arkcli" would re-run PATH search — and on Windows cmd.exe + // searches the current directory first, enabling a cwd hijack. + assert.ok( + arkCommands.every((command) => path.isAbsolute(command)), + `expected absolute paths, got: ${arkCommands.join(", ")}`, + ); +}); + +test("fetchArkCodingPlanLimits executes Ark commands through the Windows shell", async (t) => { + const options = []; + const runner = (command, args, commandOptions) => { + options.push({ command, commandOptions }); + return mockRunner()(command, args); + }; + await fetchArkCodingPlanLimits({ commandRunner: runner, home: tmpHome(t), platform: "win32" }); + assert.ok(options.every(({ command, commandOptions }) => command === "where" || commandOptions.platform === "win32")); +}); + +test("fetchArkCodingPlanLimits skips plans get when the usage payload carries a tier", async (t) => { + const calls = []; + const runner = (command, args) => { + calls.push(`${String(command).split(path.sep).pop()} ${args[0]}`); + return mockRunner({ usageStdout: usageJsonFor({ withTier: true }) })(command, args); + }; + const result = await fetchArkCodingPlanLimits({ commandRunner: runner, home: tmpHome(t) }); + assert.equal(result.configured, true); + assert.equal(result.plan_label, "Lite"); + // `plans get` is a per-fetch extra round trip — it must only run when the + // usage response did not already carry the tier. + assert.ok(!calls.some((entry) => entry.endsWith("plans")), `plans get must be skipped, got: ${calls.join(" | ")}`); +}); + +test("fetchArkCodingPlanLimits fetches the tier on demand when usage lacks it", async (t) => { + const order = []; + const runner = (command, args) => { + if (isArkCommand(command) && args[0] === "usage") order.push("usage"); + if (isArkCommand(command) && args[0] === "plans") order.push("plans"); + return mockRunner()(command, args); + }; + const result = await fetchArkCodingPlanLimits({ commandRunner: runner, home: tmpHome(t) }); + assert.equal(result.configured, true); + assert.equal(result.plan_label, "Lite"); + assert.deepEqual(order, ["usage", "plans"]); +}); + +test("fetchArkCodingPlanLimits does not serve cache windows past their reset_at", async (t) => { + const home = tmpHome(t); + const nowMs = Date.now(); + const expired = new Date(nowMs - 60_000).toISOString(); + // Write a cache whose every window reset before `nowMs` — the quota has + // rolled over, so the old percentages must not be served as stale data. + writeArkCodingPlanLimitsCache({ + configured: true, + error: null, + plan_label: "Lite", + profile_identity: "coding-plan_test_region_personal:test-user-001", + primary_window: { used_percent: 100, reset_at: expired, unit: "calls" }, + secondary_window: { used_percent: 50, reset_at: expired, unit: "calls" }, + tertiary_window: { used_percent: 10, reset_at: expired, unit: "calls" }, + }, { home, nowMs }); + + const runner = mockRunner({ + usageError: new Error("ETIMEDOUT"), + usageStatus: null, + }); + const result = await fetchArkCodingPlanLimits({ commandRunner: runner, home, nowMs }); + assert.equal(result.configured, true); + assert.equal(result.stale, undefined, "expired cache must not be served as stale data"); + assert.equal(result.source, undefined); + assert.match(result.error, /ETIMEDOUT/); +}); + +test("fetchArkCodingPlanLimits keeps only cache windows that have not reset", async (t) => { + const home = tmpHome(t); + const nowMs = Date.now(); + const expired = new Date(nowMs - 60_000).toISOString(); + const future = new Date(nowMs + 60_000).toISOString(); + writeArkCodingPlanLimitsCache({ + configured: true, + error: null, + plan_label: "Lite", + profile_identity: "coding-plan_test_region_personal:test-user-001", + primary_window: { used_percent: 100, reset_at: expired, unit: "calls" }, + secondary_window: { used_percent: 50, reset_at: future, unit: "calls" }, + tertiary_window: { used_percent: 10, reset_at: expired, unit: "calls" }, + }, { home, nowMs }); + + const result = await fetchArkCodingPlanLimits({ + commandRunner: mockRunner({ usageError: new Error("ETIMEDOUT"), usageStatus: null }), + home, + nowMs, + }); + assert.equal(result.configured, true); + assert.equal(result.stale, true); + assert.equal(result.source, "disk-cache"); + assert.equal(result.primary_window, null); + assert.equal(result.secondary_window.used_percent, 50); + assert.equal(result.tertiary_window, null); +}); + +test("readArkCodingPlanLimitsCache expires an undated window even with a dated sibling", async (t) => { + const home = tmpHome(t); + const nowMs = Date.now(); + writeArkCodingPlanLimitsCache({ + configured: true, + primary_window: { used_percent: 90, reset_at: null, unit: "calls" }, + secondary_window: { used_percent: 20, reset_at: new Date(nowMs + 86400_000).toISOString(), unit: "calls" }, + }, { home, nowMs: nowMs - 13 * 3600_000 }); + + const result = require("../src/lib/ark-coding-plan-limits").readArkCodingPlanLimitsCache({ home, nowMs }); + assert.equal(result.primary_window, null); + assert.equal(result.secondary_window.used_percent, 20); +}); + +test("fetchArkCodingPlanLimits passes its cancellation signal to every Ark command", async (t) => { + const controller = new AbortController(); + const signals = []; + const runner = (command, args, options) => { + signals.push({ command, signal: options?.signal }); + return mockRunner()(command, args); + }; + + const result = await fetchArkCodingPlanLimits({ + commandRunner: runner, + home: tmpHome(t), + signal: controller.signal, + }); + assert.equal(result.configured, true); + // which probe + usage plan + (tier missing →) plans get. + assert.equal(signals.length, 3); + assert.ok(signals.every(({ signal }) => signal === controller.signal)); +}); + +test("fetchArkCodingPlanLimits refuses a cache from another profile", async (t) => { + const home = tmpHome(t); + const nowMs = Date.now(); + writeArkCodingPlanLimitsCache({ + configured: true, + profile_identity: "profile-a:user-a", + primary_window: { used_percent: 42, reset_at: new Date(nowMs + 3600_000).toISOString(), unit: "calls" }, + }, { home, nowMs }); + const runner = (command, args) => { + if (command === "which") return { status: 0, stdout: "/usr/local/bin/arkcli\n", stderr: "" }; + if (isArkCommand(command) && args[0] === "profile") { + return { status: 0, stdout: JSON.stringify({ profile: "profile-b", user_id: "user-b" }), stderr: "" }; + } + return { status: null, stdout: "", stderr: "", error: new Error("ETIMEDOUT") }; + }; + const result = await fetchArkCodingPlanLimits({ commandRunner: runner, home, nowMs }); + assert.equal(result.stale, undefined); + assert.match(result.error, /ETIMEDOUT/); +}); + +test("fetchArkCodingPlanLimits opts into shell execution only for arkcli spawns on Windows", async (t) => { + const seen = []; + const runner = (command, args, options) => { + seen.push({ command, args, options }); + return mockRunner()(command, args); + }; + const result = await fetchArkCodingPlanLimits({ + commandRunner: runner, + home: tmpHome(t), + platform: "win32", + }); + assert.equal(result.configured, true); + + // where.exe is a real executable: a direct spawn resolves it fine, and + // shell execution would hand its arguments to cmd.exe for re-parsing. + const whereCall = seen.find(({ command }) => command === "where"); + assert.ok(whereCall, "expected a where.exe discovery probe"); + assert.equal(whereCall.options.useShell, false); + + // npm installs arkcli as a .cmd shim on Windows, which only a shell + // spawn can execute. Every argument is a constant, so this is safe. + const arkCalls = seen.filter(({ command }) => isArkCommand(command)); + assert.ok(arkCalls.length > 0); + for (const call of arkCalls) { + assert.equal(call.options.useShell, true, `expected shell for ${call.args.join(" ")}`); + } +}); + +test("fetchArkCodingPlanLimits bounds profile show with a short timeout on the cache path", async (t) => { + const seen = []; + const runner = (command, args, options) => { + seen.push({ command, args, options }); + return mockRunner({ usageStatus: 1 })(command, args); + }; + const result = await fetchArkCodingPlanLimits({ + commandRunner: runner, + home: tmpHome(t), + globalBinDirs: [], + }); + // usage plan failed and no cache exists — but the profile guard still ran. + assert.equal(result.configured, true); + assert.match(result.error, /exited with code 1/); + + const usageCall = seen.find(({ args }) => args[0] === "usage"); + assert.equal(usageCall.options.timeout, 10_000); + // `profile show` runs after `usage plan` already failed; a slow arkcli + // here must not starve the disk-cache read waiting behind it. + const profileCall = seen.find(({ args }) => args[0] === "profile"); + assert.ok(profileCall, "expected profile show on the cache-guard path"); + assert.equal(profileCall.options.timeout, 2_500); +}); + +test("fetchArkCodingPlanLimits shrinks later CLI timeouts as the provider budget drains", async (t) => { + const seen = []; + const runner = async (command, args, options) => { + seen.push({ command, args, options }); + // Binary discovery burns real wall-clock budget, as it would against + // a PATH full of slow directories. + if (command === "which") { + await new Promise((resolve) => setTimeout(resolve, 300)); + return { status: 0, stdout: "/usr/local/bin/arkcli\n", stderr: "" }; + } + // usage plan fails so the cache-guard path (profile show) runs too. + return mockRunner({ usageStatus: 1 })(command, args); + }; + const result = await fetchArkCodingPlanLimits({ + commandRunner: runner, + home: tmpHome(t), + providerTimeoutMs: 4_000, + }); + assert.equal(result.configured, true); + + const usageCall = seen.find(({ args }) => args[0] === "usage"); + assert.ok(usageCall, "expected usage plan to still run"); + // ~300ms spent on discovery leaves ~3.7s; minus the 1.5s kill guard the + // usage timeout must clamp well below its full 10s. + assert.ok(usageCall.options.timeout > 0 && usageCall.options.timeout <= 2_600, + `usage timeout should be clamped to the remaining budget, got ${usageCall.options.timeout}`); + + const profileCall = seen.find(({ args }) => args[0] === "profile"); + assert.ok(profileCall, "expected profile show on the cache-guard path"); + assert.ok(profileCall.options.timeout > 0 && profileCall.options.timeout < 2_500, + `profile timeout should shrink below its full 2.5s, got ${profileCall.options.timeout}`); +}); + +test("fetchArkCodingPlanLimits still serves the disk cache after a hung usage plan drains the budget", async (t) => { + const home = tmpHome(t); + const nowMs = Date.now(); + writeArkCodingPlanLimitsCache({ + configured: true, + plan_label: "Lite", + primary_window: { used_percent: 42, reset_at: new Date(nowMs + 3600_000).toISOString(), unit: "calls" }, + }, { home, nowMs }); + + const seen = []; + const result = await fetchArkCodingPlanLimits({ + commandRunner: async (command, args, options) => { + seen.push({ command, args, options }); + if (command === "which") { + return { status: 0, stdout: "/usr/local/bin/arkcli\n", stderr: "" }; + } + if (args[0] === "usage") { + // Simulate `usage plan` running until its budgeted timeout kills + // it: real elapsed time, then a timeout-shaped failure. + await new Promise((resolve) => setTimeout(resolve, 1_200)); + return { status: null, stdout: "", stderr: "", error: new Error("spawn arkcli ETIMEDOUT") }; + } + return mockRunner()(command, args, options); + }, + home, + nowMs, + providerTimeoutMs: 2_600, + }); + + // Last-known data instead of an error — clamping the serial chain to + // the provider budget is what lets the cache fallback resolve inside + // the outer race. + assert.equal(result.cached, true); + assert.equal(result.stale, true); + assert.equal(result.source, "disk-cache"); + assert.equal(result.plan_label, "Lite"); + + // `usage plan` ran to its (shrunk) timeout; `profile show` no longer + // fits in the remaining budget, so it is skipped and the cache is read + // fail-open — exactly the hung-CLI scenario the guard exists for. + const arkCommands = seen.filter(({ command }) => isArkCommand(command)).map(({ args }) => args[0]); + assert.deepEqual(arkCommands, ["usage"], "profile show must be skipped once the budget is drained"); +}); + +test("whichBinary strips CRLF when where lists multiple matches", async () => { + // Windows `where` emits CRLF and one line per PATH hit. The first line + // must come back without its `\r`, or the polluted path fails at spawn. + const runner = (command) => { + if (command === "where") { + return { status: 0, stdout: "C:\\A\\arkcli.cmd\r\nC:\\B\\arkcli.cmd\r\n", stderr: "" }; + } + return { status: 1, stdout: "", stderr: "" }; + }; + const resolved = await whichBinary("arkcli", { commandRunner: runner, platform: "win32" }); + assert.equal(resolved, "C:\\A\\arkcli.cmd"); +}); + +test("fetchArkCodingPlanLimits drops the cache once the plan is unsubscribed", async (t) => { + const home = tmpHome(t); + const nowMs = Date.now(); + const cachePath = path.join(home, ".tokentracker", "tracker", "ark-coding-plan-limits-cache.json"); + + // Subscribed era: a live read wrote the cache. + writeArkCodingPlanLimitsCache({ + configured: true, + plan_label: "Lite", + primary_window: { used_percent: 42, reset_at: new Date(nowMs + 3600_000).toISOString(), unit: "calls" }, + }, { home, nowMs }); + assert.equal(fs.existsSync(cachePath), true); + + // The user unsubscribes: the authoritative live response says so. + const unsubscribed = JSON.parse(usageJsonFor({ nowMs })); + unsubscribed.items[0].subscribed = false; + const gone = await fetchArkCodingPlanLimits({ + commandRunner: mockRunner({ usageStdout: JSON.stringify(unsubscribed) }), + home, + nowMs, + }); + assert.deepEqual(gone, { configured: false }); + assert.equal(fs.existsSync(cachePath), false, "cache must be dropped on unsubscribe"); + + // A later transient CLI failure must not resurrect the retired plan. + const hung = await fetchArkCodingPlanLimits({ + commandRunner: (command, args) => { + if (command === "which") return { status: 0, stdout: "/usr/local/bin/arkcli\n", stderr: "" }; + if (isArkCommand(command)) { + return { status: null, stdout: "", stderr: "", error: new Error("ETIMEDOUT") }; + } + return { status: 1, stdout: "", stderr: "" }; + }, + home, + nowMs, + }); + assert.equal(hung.stale, undefined); + assert.equal(hung.cached, undefined); + assert.match(hung.error, /ETIMEDOUT/); +}); + +test("fetchArkCodingPlanLimits keeps the cache when the payload carries no coding-plan entry", async (t) => { + const home = tmpHome(t); + const nowMs = Date.now(); + const cachePath = path.join(home, ".tokentracker", "tracker", "ark-coding-plan-limits-cache.json"); + writeArkCodingPlanLimitsCache({ + configured: true, + plan_label: "Lite", + primary_window: { used_percent: 42, reset_at: new Date(nowMs + 3600_000).toISOString(), unit: "calls" }, + }, { home, nowMs }); + + // `{}`, empty items, or a renamed product key can all be transient states + // (not logged in, backend degraded) — none of them is a confirmed + // unsubscribe, so none may destroy the disk cache. + const ambiguous = [ + {}, + { items: [] }, + { items: [{ product: "some-other-plan", subscribed: true }] }, + ]; + for (const body of ambiguous) { + const result = await fetchArkCodingPlanLimits({ + commandRunner: mockRunner({ usageStdout: JSON.stringify(body) }), + home, + nowMs, + }); + assert.deepEqual(result, { configured: false }); + } + assert.equal(fs.existsSync(cachePath), true, "ambiguous payloads must not destroy the cache"); +}); + +test("commonGlobalBinDirectories expands nvm and fnm version directories", (t) => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "ark-bin-home-")); + t.after(() => fs.rmSync(home, { recursive: true, force: true })); + fs.mkdirSync(path.join(home, ".nvm", "versions", "node", "v22.1.0", "bin"), { recursive: true }); + fs.mkdirSync(path.join(home, ".nvm", "versions", "node", "v18.0.0", "bin"), { recursive: true }); + fs.mkdirSync(path.join(home, ".local", "share", "fnm", "node-versions", "v22.1.0", "installation", "bin"), { recursive: true }); + // A stray file inside the versions root must not become a bin candidate. + fs.writeFileSync(path.join(home, ".nvm", "versions", "node", "release-notes.txt"), "not a version"); + + const dirs = commonGlobalBinDirectories({ home, platform: "linux" }); + // nvm entries are expanded newest-first so the active Node's global bin + // is probed before stale versions. + assert.deepEqual( + dirs.filter((dir) => dir.includes(".nvm")), + [ + path.join(home, ".nvm", "versions", "node", "v22.1.0", "bin"), + path.join(home, ".nvm", "versions", "node", "v18.0.0", "bin"), + ], + ); + assert.ok(dirs.includes( + path.join(home, ".local", "share", "fnm", "node-versions", "v22.1.0", "installation", "bin"), + )); +}); diff --git a/test/command-runner.test.js b/test/command-runner.test.js new file mode 100644 index 000000000..915adaf96 --- /dev/null +++ b/test/command-runner.test.js @@ -0,0 +1,112 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const cp = require("node:child_process"); +const { EventEmitter } = require("node:events"); +const test = require("node:test"); + +const { runCommand } = require("../src/lib/command-runner"); + +// These tests stub cp.spawn itself. Injecting a mock commandRunner cannot +// reach the real spawn branch: runCommand early-returns for function +// runners, so options assertions on a mock only prove what the *caller* +// passed — a regression that forces `shell: true` inside the runner (the +// DEP0190 class bug that once broke detectAntigravityProcess) would stay +// invisible. Asserting on the actual cp.spawn call closes that gap. +function stubSpawn() { + const calls = []; + const original = cp.spawn; + cp.spawn = (command, args, options) => { + calls.push({ command, args, options: { ...options } }); + const child = new EventEmitter(); + child.pid = 4321; + child.stdout = Object.assign(new EventEmitter(), { setEncoding() {} }); + child.stderr = Object.assign(new EventEmitter(), { setEncoding() {} }); + child.kill = () => true; + queueMicrotask(() => child.emit("close", 0)); + return child; + }; + return { calls, restore: () => { cp.spawn = original; } }; +} + +test("runCommand spawns directly by default — no shell, even on Windows", async () => { + const { calls, restore } = stubSpawn(); + try { + const args = ["-NoProfile", "-NonInteractive", "-Command", "Get-CimInstance | ConvertTo-Json"]; + const result = await runCommand(undefined, "powershell.exe", args, { platform: "win32", timeout: 1000 }); + assert.equal(result.status, 0); + + assert.equal(calls.length, 1); + // The previous regression forced shell execution on win32 inside the + // runner, which let cmd.exe split the script at `|`. Direct spawn is + // the contract for every call site that did not opt into useShell. + assert.equal(calls[0].options.shell, false); + assert.deepEqual(calls[0].args, args); + assert.equal(calls[0].command, "powershell.exe"); + } finally { + restore(); + } +}); + +test("runCommand quotes the command unconditionally under useShell", async () => { + const { calls, restore } = stubSpawn(); + try { + // No whitespace, but `&` is a cmd.exe metacharacter — Windows account + // names may contain it, and the npm global prefix lives under the + // user directory. Space-only quoting would leave this path unquoted + // and cmd.exe would split it at `&`. + const command = "C:\\Users\\a&b\\AppData\\Roaming\\npm\\arkcli.cmd"; + const result = await runCommand(undefined, command, ["usage", "plan"], { + platform: "win32", + useShell: true, + timeout: 1000, + }); + assert.equal(result.status, 0); + + assert.equal(calls.length, 1); + assert.equal(calls[0].options.shell, true); + assert.equal(calls[0].command, `"${command}"`); + } finally { + restore(); + } +}); + +test("runCommand leaves the command untouched without useShell", async () => { + const { calls, restore } = stubSpawn(); + try { + const command = "C:\\Program Files\\ark cli\\arkcli.cmd"; + await runCommand(undefined, command, ["usage"], { platform: "win32", timeout: 1000 }); + assert.equal(calls[0].command, command); + assert.equal(calls[0].options.shell, false); + } finally { + restore(); + } +}); + +test("Windows .cmd path with cmd metacharacters survives both spawn modes", async () => { + // A realistic npm-global install under a Windows account whose name + // contains `&`: no whitespace, but cmd.exe would split the line at the + // `&` when the path is handed to a shell unquoted. + const command = "C:\\Users\\a&b\\AppData\\Roaming\\npm\\arkcli.cmd"; + const args = ["usage", "plan", "--format", "json"]; + + const { calls, restore } = stubSpawn(); + try { + // Shell mode: the whole path must be wrapped in quotes so the joined + // command line keeps `&` inside a single token. + await runCommand(undefined, command, args, { platform: "win32", useShell: true, timeout: 1000 }); + assert.equal(calls[0].options.shell, true); + assert.equal(calls[0].command, `"${command}"`); + const joinedShell = [calls[0].command, ...calls[0].args].join(" "); + assert.ok(/^"[^"]*&[^"]*"/.test(joinedShell), "metacharacter must stay inside quotes"); + + // Default mode: direct spawn — args array passed through verbatim, + // no shell to re-parse the line, nothing quoted or split. + await runCommand(undefined, command, args, { platform: "win32", timeout: 1000 }); + assert.equal(calls[1].options.shell, false); + assert.equal(calls[1].command, command); + assert.deepEqual(calls[1].args, args); + } finally { + restore(); + } +}); diff --git a/test/fixtures/noisy-command.js b/test/fixtures/noisy-command.js new file mode 100644 index 000000000..70a79f9f4 --- /dev/null +++ b/test/fixtures/noisy-command.js @@ -0,0 +1,3 @@ +"use strict"; + +process.stdout.write("x".repeat(64 * 1024)); diff --git a/test/usage-limits.test.js b/test/usage-limits.test.js index 2d65c3439..19500f01a 100644 --- a/test/usage-limits.test.js +++ b/test/usage-limits.test.js @@ -26,6 +26,7 @@ const { fetchAntigravityLimits, fetchCopilotLimits, } = require("../src/lib/usage-limits"); +const { writeArkCodingPlanLimitsCache } = require("../src/lib/ark-coding-plan-limits"); // Match a fetch URL by host (exact or subdomain) rather than substring, so the // filter can't be fooled by lookalike hosts — and so CodeQL's @@ -3073,8 +3074,8 @@ lang 123 me 23u IPv4 0x124 0t0 TCP 127.0.0.1:51235 (LIS it("detects Antigravity from native Windows process enumeration", async () => { const calls = []; - const commandRunner = (command, args) => { - calls.push({ command, args }); + const commandRunner = (command, args, options) => { + calls.push({ command, args, options }); return { stdout: JSON.stringify([ { ProcessId: 321, CommandLine: "C:\\Program Files\\Windsurf\\language_server_windows_x64.exe --app_data_dir windsurf" }, @@ -3088,6 +3089,10 @@ lang 123 me 23u IPv4 0x124 0t0 TCP 127.0.0.1:51235 (LIS assert.equal(calls[0].command, "powershell.exe"); assert.deepEqual(calls[0].args.slice(0, 3), ["-NoProfile", "-NonInteractive", "-Command"]); + // Regression guard: the -Command script contains a literal `|`; with + // shell execution cmd.exe would split it there and break the query. + // Direct spawn is the contract for every pre-existing call site. + assert.equal(calls[0].options.useShell, false); assert.equal(result.configured, true); assert.equal(result.pid, 654); assert.equal(result.csrfToken, "win-token"); @@ -3581,6 +3586,60 @@ describe("getUsageLimits plan_label", () => { }); }); +describe("getUsageLimits Ark timeout fallback", () => { + it("does not return an unverified Ark cache after timeout and forwards the requested platform", async () => { + resetUsageLimitsCache(); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tokentracker-limits-ark-timeout-")); + try { + // Install-evidence dir: without ~/.arkcli the provider bails out + // before any probe, and the timeout path below never runs. + fs.mkdirSync(path.join(tmp, ".arkcli"), { recursive: true }); + const nowMs = Date.now(); + writeArkCodingPlanLimitsCache({ + configured: true, + error: null, + plan_label: "Lite", + primary_window: { + used_percent: 42, + reset_at: new Date(nowMs + 3_600_000).toISOString(), + unit: "calls", + }, + }, { home: tmp, nowMs }); + + const calls = []; + const result = await getUsageLimits({ + home: tmp, + platform: "win32", + providerTimeoutMs: 20, + securityRunner() { + return { status: 1, stdout: "" }; + }, + commandRunner(command, args) { + calls.push({ command, args }); + if (command === "where") { + return { status: 0, stdout: "C:\\Program Files\\arkcli.exe\n", stderr: "" }; + } + // The provider spawns the resolved absolute path, never a bare + // "arkcli" — hang it so the outer provider timeout fires. + if (/arkcli(\.exe)?$/i.test(command)) return new Promise(() => {}); + return { status: 1, stdout: "", stderr: "" }; + }, + fetchImpl() { + return new Promise(() => {}); + }, + }); + + assert.deepEqual(calls.find(({ command }) => command === "where")?.args, ["arkcli"]); + assert.equal(result.codingPlan.configured, true); + assert.equal(result.codingPlan.stale, undefined); + assert.match(result.codingPlan.error, /timed out/i); + } finally { + resetUsageLimitsCache(); + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + describe("getUsageLimits Claude stale fallback", () => { const FUTURE_RESET = "2099-01-01T00:00:00.000Z";