@indigoai-us/hq-cli 5.122.2 → 5.123.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/README.md +46 -0
- package/dist/command-catalog.generated.d.ts +6 -3
- package/dist/command-catalog.generated.js +7 -3
- package/dist/commands/dm.d.ts +54 -1
- package/dist/commands/dm.js +204 -8
- package/dist/commands/mesh.js +7 -2
- package/dist/commands/update.js +6 -0
- package/dist/commands/whoami.d.ts +2 -2
- package/dist/commands/whoami.js +18 -19
- package/dist/lib/billing/plan-lock.d.ts +64 -69
- package/dist/lib/billing/plan-lock.js +230 -161
- package/dist/lib/mesh/api.js +96 -28
- package/dist/lib/mesh/live/backfill-held.d.ts +43 -7
- package/dist/lib/mesh/live/backfill-held.js +323 -42
- package/dist/lib/mesh/live/daemon/board-refresh.d.ts +4 -1
- package/dist/lib/mesh/live/daemon/board-refresh.js +71 -30
- package/dist/lib/mesh/live/daemon/config.d.ts +9 -0
- package/dist/lib/mesh/live/daemon/config.js +45 -0
- package/dist/lib/mesh/live/daemon/credentials.d.ts +6 -1
- package/dist/lib/mesh/live/daemon/credentials.js +20 -4
- package/dist/lib/mesh/live/daemon/doctor.d.ts +6 -0
- package/dist/lib/mesh/live/daemon/doctor.js +29 -2
- package/dist/lib/mesh/live/daemon/index.d.ts +4 -2
- package/dist/lib/mesh/live/daemon/index.js +3 -2
- package/dist/lib/mesh/live/daemon/install.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/install.js +54 -4
- package/dist/lib/mesh/live/daemon/run.d.ts +6 -0
- package/dist/lib/mesh/live/daemon/run.js +50 -3
- package/dist/lib/mesh/live/daemon/state.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/version-drift.d.ts +5 -0
- package/dist/lib/mesh/live/daemon/version-drift.js +47 -0
- package/dist/lib/mesh/live/paths.d.ts +2 -0
- package/dist/lib/mesh/live/paths.js +4 -0
- package/dist/lib/work-context/reconcile.js +67 -6
- package/dist/lib/work-context/state.d.ts +5 -3
- package/dist/lib/work-context/state.js +28 -4
- package/dist/utils/cli-telemetry.d.ts +21 -0
- package/dist/utils/cli-telemetry.js +77 -1
- package/dist/utils/self-update.d.ts +3 -0
- package/dist/utils/self-update.js +11 -0
- package/package.json +3 -2
|
@@ -1,59 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* CLI-side read + render of a workspace's Starter plan-limit nag
|
|
3
|
+
* (starter-plan-hard-limits / US-011, rewritten for US-036, then US-040).
|
|
4
4
|
*
|
|
5
5
|
* Starter (free) workspaces are capped on members, integrations, secrets and
|
|
6
6
|
* agents (deployments and storage nag but never arm a stop). Going over does
|
|
7
7
|
* NOT stop the workspace working (US-033 owner decision 12): everything that
|
|
8
8
|
* exists keeps working, and the only two things that pause are adding new
|
|
9
9
|
* files to the vault and adding new secrets (owner decision 13). Every string
|
|
10
|
-
* this module renders is a nag, never a claim that HQ has stopped.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* module
|
|
10
|
+
* this module renders is a nag, never a claim that HQ has stopped.
|
|
11
|
+
*
|
|
12
|
+
* US-040: hq-pro no longer attaches `planLock` on `/membership/me` or
|
|
13
|
+
* `/v1/billing/usage-limits`. This module reads the nag payload from
|
|
14
|
+
* usage-limits (`planLimits` resource rows, top-level `freeMonth`,
|
|
15
|
+
* `upgradeUrl`) and never a lock object.
|
|
14
16
|
*
|
|
15
17
|
* Copy rule (US-036): no string a customer reads may contain "locked" or
|
|
16
18
|
* "read-only". `plan-lock.test.ts` scans every rendered string for both.
|
|
17
19
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* removes the "n of 5" detail from the notice. An absent field is UNKNOWN, so
|
|
21
|
-
* nothing here ever infers a lock (or an unlock) from missing data
|
|
22
|
-
* (hq-absent-field-never-means-constraining-value).
|
|
20
|
+
* An absent field is UNKNOWN, so nothing here ever infers over-limit from
|
|
21
|
+
* missing data (hq-absent-field-never-means-constraining-value).
|
|
23
22
|
*/
|
|
24
23
|
import chalk from "chalk";
|
|
25
24
|
import { vaultApiFetch } from "../../utils/vault-api.js";
|
|
26
25
|
/**
|
|
27
|
-
*
|
|
28
|
-
* nagging set at these four: `deployments` and `storageBytes` are
|
|
29
|
-
* never appear here.
|
|
30
|
-
* server that adds a fifth dimension renders as the generic line rather than as
|
|
31
|
-
* a false claim about members.
|
|
26
|
+
* Dimensions the bind notice names with a remedy. Owner decision 7 (2026-09-17)
|
|
27
|
+
* fixes the nagging set at these four: `deployments` and `storageBytes` are
|
|
28
|
+
* nag-only and never appear here.
|
|
32
29
|
*/
|
|
33
|
-
export const
|
|
30
|
+
export const PLAN_LIMIT_REASONS = [
|
|
34
31
|
"users",
|
|
35
32
|
"integrations",
|
|
36
33
|
"secrets",
|
|
37
34
|
"agents",
|
|
38
35
|
];
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
/** Starter member cap quoted when the server did not send `removeMembersTo`. */
|
|
36
|
+
/** @deprecated US-040 — alias of {@link PLAN_LIMIT_REASONS}. */
|
|
37
|
+
export const PLAN_LOCK_REASONS = PLAN_LIMIT_REASONS;
|
|
38
|
+
/** Starter member cap quoted when the server did not send a users.limit. */
|
|
43
39
|
export const STARTER_MEMBER_TARGET = 5;
|
|
44
40
|
/**
|
|
45
|
-
* Starter integration cap quoted when the server did not send
|
|
46
|
-
*
|
|
47
|
-
* (US-020 owner decision 11, 2026-09-17 — it was zero before that).
|
|
41
|
+
* Starter integration cap quoted when the server did not send a limit.
|
|
42
|
+
* One: Starter includes a single integration (US-020 owner decision 11).
|
|
48
43
|
*/
|
|
49
44
|
export const STARTER_INTEGRATION_TARGET = 1;
|
|
50
|
-
/** Starter secret cap quoted when the server did not send
|
|
45
|
+
/** Starter secret cap quoted when the server did not send a secrets.limit. */
|
|
51
46
|
export const STARTER_SECRET_TARGET = 10;
|
|
52
|
-
/** Starter agent cap quoted when the server did not send
|
|
47
|
+
/** Starter agent cap quoted when the server did not send an agents.limit. */
|
|
53
48
|
export const STARTER_AGENT_TARGET = 0;
|
|
54
49
|
/** Upgrade destination quoted when the server did not send one. */
|
|
55
50
|
export const DEFAULT_UPGRADE_URL = "https://hq.computer/billing";
|
|
56
|
-
/** The paid plan the
|
|
51
|
+
/** The paid plan the nag sends owners to. Copy lives in ONE place. */
|
|
57
52
|
export const WORKFORCE_PLAN_LABEL = "HQ Workforce ($500/mo)";
|
|
58
53
|
function finiteNumber(value) {
|
|
59
54
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
@@ -64,58 +59,65 @@ function asRecord(value) {
|
|
|
64
59
|
}
|
|
65
60
|
return value;
|
|
66
61
|
}
|
|
62
|
+
/** Published Starter cap for a dimension, quoted when the server sends no limit. */
|
|
63
|
+
function starterCap(reason) {
|
|
64
|
+
switch (reason) {
|
|
65
|
+
case "users":
|
|
66
|
+
return STARTER_MEMBER_TARGET;
|
|
67
|
+
case "integrations":
|
|
68
|
+
return STARTER_INTEGRATION_TARGET;
|
|
69
|
+
case "secrets":
|
|
70
|
+
return STARTER_SECRET_TARGET;
|
|
71
|
+
case "agents":
|
|
72
|
+
return STARTER_AGENT_TARGET;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
67
75
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
76
|
+
* Parse one usage row. An absent or null `limit` is UNKNOWN, never zero: the
|
|
77
|
+
* renderer quotes the published Starter cap instead of "7 of 0 members"
|
|
78
|
+
* (hq-absent-field-never-means-constraining-value).
|
|
71
79
|
*/
|
|
72
|
-
|
|
80
|
+
function parseUsageRow(reason, value) {
|
|
73
81
|
const rec = asRecord(value);
|
|
74
82
|
if (!rec)
|
|
75
83
|
return null;
|
|
76
|
-
|
|
84
|
+
const used = finiteNumber(rec.used);
|
|
85
|
+
if (used === null)
|
|
77
86
|
return null;
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (deprovisionAgentsTo !== null) {
|
|
102
|
-
fixOptions.deprovisionAgentsTo = deprovisionAgentsTo;
|
|
103
|
-
}
|
|
87
|
+
const limit = finiteNumber(rec.limit);
|
|
88
|
+
return { used, limit: limit ?? starterCap(reason) };
|
|
89
|
+
}
|
|
90
|
+
function rowIsOver(value) {
|
|
91
|
+
const rec = asRecord(value);
|
|
92
|
+
if (!rec)
|
|
93
|
+
return false;
|
|
94
|
+
if (rec.over === true)
|
|
95
|
+
return true;
|
|
96
|
+
const used = finiteNumber(rec.used);
|
|
97
|
+
const limit = finiteNumber(rec.limit);
|
|
98
|
+
return used !== null && limit !== null && used > limit;
|
|
99
|
+
}
|
|
100
|
+
export function parseFreeMonth(value) {
|
|
101
|
+
const rec = asRecord(value);
|
|
102
|
+
if (!rec)
|
|
103
|
+
return undefined;
|
|
104
|
+
if (rec.eligible !== true)
|
|
105
|
+
return undefined;
|
|
106
|
+
const url = typeof rec.url === "string" && rec.url.trim().length > 0
|
|
107
|
+
? rec.url.trim()
|
|
108
|
+
: undefined;
|
|
109
|
+
const trialDays = finiteNumber(rec.trialDays) ?? undefined;
|
|
104
110
|
return {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
? rec.upgradeUrl.trim()
|
|
109
|
-
: DEFAULT_UPGRADE_URL,
|
|
110
|
-
fixOptions,
|
|
111
|
+
eligible: true,
|
|
112
|
+
...(trialDays !== undefined ? { trialDays } : {}),
|
|
113
|
+
...(url ? { url } : {}),
|
|
111
114
|
};
|
|
112
115
|
}
|
|
113
116
|
/**
|
|
114
117
|
* Pick the membership row for `companySlug` (or `cmp_…` uid) out of a decoded
|
|
115
|
-
* `/membership/me` body
|
|
116
|
-
* `planLock` on it — is UNKNOWN, so this returns null rather than guessing.
|
|
118
|
+
* `/membership/me` body. No matching row is UNKNOWN. Does not read `planLock`.
|
|
117
119
|
*/
|
|
118
|
-
export function
|
|
120
|
+
export function selectCompanyMembership(body, companyRef) {
|
|
119
121
|
const rec = asRecord(body);
|
|
120
122
|
const rows = Array.isArray(rec?.memberships) ? rec.memberships : [];
|
|
121
123
|
const ref = companyRef.trim().toLowerCase();
|
|
@@ -129,11 +131,7 @@ export function selectPlanLock(body, companyRef) {
|
|
|
129
131
|
const uid = typeof row.companyUid === "string" ? row.companyUid.toLowerCase() : "";
|
|
130
132
|
if (slug !== ref && uid !== ref)
|
|
131
133
|
continue;
|
|
132
|
-
const lock = parsePlanLock(row.planLock);
|
|
133
|
-
if (!lock)
|
|
134
|
-
return null;
|
|
135
134
|
return {
|
|
136
|
-
lock,
|
|
137
135
|
companyUid: typeof row.companyUid === "string" ? row.companyUid : undefined,
|
|
138
136
|
};
|
|
139
137
|
}
|
|
@@ -159,12 +157,68 @@ export function selectMemberUsage(body) {
|
|
|
159
157
|
: STARTER_MEMBER_TARGET;
|
|
160
158
|
return { used: users.used, limit };
|
|
161
159
|
}
|
|
160
|
+
function selectUpgradeUrl(body) {
|
|
161
|
+
const rec = asRecord(body);
|
|
162
|
+
const raw = rec?.upgradeUrl;
|
|
163
|
+
if (typeof raw === "string" && raw.trim().length > 0)
|
|
164
|
+
return raw.trim();
|
|
165
|
+
return DEFAULT_UPGRADE_URL;
|
|
166
|
+
}
|
|
162
167
|
/**
|
|
163
|
-
* Read
|
|
164
|
-
*
|
|
165
|
-
* answer with a lock for this company — callers must then say nothing.
|
|
168
|
+
* Read over-limit dimensions from a usage-limits body. `planLimits` nested
|
|
169
|
+
* map is preferred when present; otherwise the top-level resource rows.
|
|
166
170
|
*/
|
|
167
|
-
export
|
|
171
|
+
export function selectOverReasons(body) {
|
|
172
|
+
const rec = asRecord(body);
|
|
173
|
+
if (!rec)
|
|
174
|
+
return [];
|
|
175
|
+
const nested = asRecord(rec.planLimits);
|
|
176
|
+
const source = nested ?? rec;
|
|
177
|
+
const reasons = [];
|
|
178
|
+
for (const key of PLAN_LIMIT_REASONS) {
|
|
179
|
+
if (rowIsOver(source[key]))
|
|
180
|
+
reasons.push(key);
|
|
181
|
+
}
|
|
182
|
+
return reasons;
|
|
183
|
+
}
|
|
184
|
+
export function selectUsages(body) {
|
|
185
|
+
const rec = asRecord(body);
|
|
186
|
+
if (!rec)
|
|
187
|
+
return {};
|
|
188
|
+
const nested = asRecord(rec.planLimits);
|
|
189
|
+
const source = nested ?? rec;
|
|
190
|
+
const out = {};
|
|
191
|
+
for (const key of PLAN_LIMIT_REASONS) {
|
|
192
|
+
const row = parseUsageRow(key, source[key]);
|
|
193
|
+
if (row)
|
|
194
|
+
out[key] = row;
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
function statusFromUsage(companySlug, companyUid, body) {
|
|
199
|
+
const usages = selectUsages(body);
|
|
200
|
+
const reasons = selectOverReasons(body);
|
|
201
|
+
const rec = asRecord(body);
|
|
202
|
+
const anyOverFlag = rec?.anyOver === true;
|
|
203
|
+
return {
|
|
204
|
+
companySlug,
|
|
205
|
+
companyUid,
|
|
206
|
+
over: anyOverFlag || reasons.length > 0,
|
|
207
|
+
reasons,
|
|
208
|
+
members: selectMemberUsage(body) ?? usages.users,
|
|
209
|
+
usages,
|
|
210
|
+
upgradeUrl: selectUpgradeUrl(body),
|
|
211
|
+
freeMonth: parseFreeMonth(rec?.freeMonth),
|
|
212
|
+
plan: selectPlan(body) ?? undefined,
|
|
213
|
+
checkedAt: new Date().toISOString(),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Read the live nag for one company from usage-limits. `/membership/me` is
|
|
218
|
+
* used only to resolve slug → companyUid. Returns null when the server did
|
|
219
|
+
* not answer for this company — callers must then say nothing.
|
|
220
|
+
*/
|
|
221
|
+
export async function fetchPlanLimitStatus(token, companyRef, opts = {}) {
|
|
168
222
|
const timeoutMs = opts.timeoutMs ?? 8000;
|
|
169
223
|
const res = await vaultApiFetch({
|
|
170
224
|
token,
|
|
@@ -173,42 +227,29 @@ export async function fetchPlanLockStatus(token, companyRef, opts = {}) {
|
|
|
173
227
|
});
|
|
174
228
|
if (!res.ok)
|
|
175
229
|
return null;
|
|
176
|
-
const selected =
|
|
177
|
-
if (!selected)
|
|
230
|
+
const selected = selectCompanyMembership(await res.json().catch(() => null), companyRef);
|
|
231
|
+
if (!selected?.companyUid)
|
|
232
|
+
return null;
|
|
233
|
+
try {
|
|
234
|
+
const usage = await vaultApiFetch({
|
|
235
|
+
token,
|
|
236
|
+
path: "/v1/billing/usage-limits",
|
|
237
|
+
query: { companyUid: selected.companyUid },
|
|
238
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
239
|
+
});
|
|
240
|
+
if (!usage.ok)
|
|
241
|
+
return null;
|
|
242
|
+
const body = await usage.json().catch(() => null);
|
|
243
|
+
if (!body)
|
|
244
|
+
return null;
|
|
245
|
+
return statusFromUsage(companyRef, selected.companyUid, body);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
178
248
|
return null;
|
|
179
|
-
let members;
|
|
180
|
-
let plan;
|
|
181
|
-
// Decoration only. The lock answer above is authoritative; the usage read
|
|
182
|
-
// adds the member count and the plan id that the notices and the `Plan:`
|
|
183
|
-
// orientation line quote. A failure here must never suppress — or invent —
|
|
184
|
-
// a lock, so every field it feeds stays optional.
|
|
185
|
-
if (selected.companyUid) {
|
|
186
|
-
try {
|
|
187
|
-
const usage = await vaultApiFetch({
|
|
188
|
-
token,
|
|
189
|
-
path: "/v1/billing/usage-limits",
|
|
190
|
-
query: { companyUid: selected.companyUid },
|
|
191
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
192
|
-
});
|
|
193
|
-
if (usage.ok) {
|
|
194
|
-
const body = await usage.json().catch(() => null);
|
|
195
|
-
members = selectMemberUsage(body) ?? undefined;
|
|
196
|
-
plan = selectPlan(body) ?? undefined;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
catch {
|
|
200
|
-
// Network/timeout: leave both unknown.
|
|
201
|
-
}
|
|
202
249
|
}
|
|
203
|
-
return {
|
|
204
|
-
companySlug: companyRef,
|
|
205
|
-
companyUid: selected.companyUid,
|
|
206
|
-
lock: selected.lock,
|
|
207
|
-
members,
|
|
208
|
-
plan,
|
|
209
|
-
checkedAt: new Date().toISOString(),
|
|
210
|
-
};
|
|
211
250
|
}
|
|
251
|
+
/** @deprecated US-040 — alias of {@link fetchPlanLimitStatus}. */
|
|
252
|
+
export const fetchPlanLockStatus = fetchPlanLimitStatus;
|
|
212
253
|
/** Why the workspace is over, one clause per reason. */
|
|
213
254
|
export function reasonLabel(reason) {
|
|
214
255
|
switch (reason) {
|
|
@@ -222,48 +263,80 @@ export function reasonLabel(reason) {
|
|
|
222
263
|
return "agents are not included on Starter";
|
|
223
264
|
}
|
|
224
265
|
}
|
|
225
|
-
function
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
266
|
+
function usageTarget(reason, status) {
|
|
267
|
+
const used = status.usages[reason]?.limit;
|
|
268
|
+
if (typeof used === "number")
|
|
269
|
+
return used;
|
|
270
|
+
switch (reason) {
|
|
271
|
+
case "users":
|
|
272
|
+
return status.members?.limit ?? STARTER_MEMBER_TARGET;
|
|
273
|
+
case "integrations":
|
|
274
|
+
return STARTER_INTEGRATION_TARGET;
|
|
275
|
+
case "secrets":
|
|
276
|
+
return STARTER_SECRET_TARGET;
|
|
277
|
+
case "agents":
|
|
278
|
+
return STARTER_AGENT_TARGET;
|
|
279
|
+
}
|
|
233
280
|
}
|
|
234
281
|
/** The remedy sentence for one reason — what the owner does to clear it. */
|
|
235
|
-
function reasonRemedy(reason,
|
|
282
|
+
function reasonRemedy(reason, status) {
|
|
283
|
+
const target = usageTarget(reason, status);
|
|
236
284
|
switch (reason) {
|
|
237
285
|
case "users":
|
|
238
|
-
return `remove members until you are at ${
|
|
286
|
+
return `remove members until you are at ${target} or fewer`;
|
|
239
287
|
case "integrations":
|
|
240
|
-
return `disconnect integrations until you are at ${
|
|
288
|
+
return `disconnect integrations until you are at ${target} or fewer`;
|
|
241
289
|
case "secrets":
|
|
242
|
-
return `delete secrets until you are at ${
|
|
243
|
-
case "agents":
|
|
244
|
-
const target = agentTarget(lock);
|
|
290
|
+
return `delete secrets until you are at ${target} or fewer`;
|
|
291
|
+
case "agents":
|
|
245
292
|
return target > 0
|
|
246
293
|
? `deprovision agents until you are at ${target} or fewer`
|
|
247
294
|
: "deprovision the workspace's agents";
|
|
248
|
-
}
|
|
249
295
|
}
|
|
250
296
|
}
|
|
251
297
|
/** The short per-reason clause used inside the one-line turn notice. */
|
|
252
298
|
function reasonDetail(reason, status) {
|
|
253
|
-
const
|
|
299
|
+
const row = status.usages[reason];
|
|
300
|
+
const target = usageTarget(reason, status);
|
|
301
|
+
switch (reason) {
|
|
302
|
+
case "users":
|
|
303
|
+
return row || status.members
|
|
304
|
+
? `${(row ?? status.members).used} of ${target} members`
|
|
305
|
+
: `over its ${target}-member limit`;
|
|
306
|
+
case "integrations":
|
|
307
|
+
return row
|
|
308
|
+
? `${row.used} of ${target} integrations`
|
|
309
|
+
: `over its ${target}-integration limit`;
|
|
310
|
+
case "secrets":
|
|
311
|
+
return row
|
|
312
|
+
? `${row.used} of ${target} secrets`
|
|
313
|
+
: `over its ${target}-secret limit`;
|
|
314
|
+
case "agents":
|
|
315
|
+
return "agents are not included on Starter";
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function usageLine(reason, status) {
|
|
319
|
+
const row = status.usages[reason];
|
|
320
|
+
const target = usageTarget(reason, status);
|
|
254
321
|
switch (reason) {
|
|
255
322
|
case "users": {
|
|
256
|
-
const
|
|
323
|
+
const members = row ?? status.members;
|
|
257
324
|
return members
|
|
258
|
-
?
|
|
259
|
-
: `
|
|
325
|
+
? ` Members: ${members.used} of ${target}.`
|
|
326
|
+
: ` Members allowed on Starter: ${target}.`;
|
|
260
327
|
}
|
|
261
328
|
case "integrations":
|
|
262
|
-
return
|
|
329
|
+
return row
|
|
330
|
+
? ` Integrations: ${row.used} of ${target}.`
|
|
331
|
+
: ` Integrations allowed on Starter: ${target}.`;
|
|
263
332
|
case "secrets":
|
|
264
|
-
return
|
|
333
|
+
return row
|
|
334
|
+
? ` Secrets: ${row.used} of ${target}.`
|
|
335
|
+
: ` Secrets allowed on Starter: ${target}.`;
|
|
265
336
|
case "agents":
|
|
266
|
-
return
|
|
337
|
+
return row
|
|
338
|
+
? ` Agents: ${row.used} of ${target}.`
|
|
339
|
+
: ` Agents allowed on Starter: ${target}.`;
|
|
267
340
|
}
|
|
268
341
|
}
|
|
269
342
|
/**
|
|
@@ -285,43 +358,41 @@ function capitalize(text) {
|
|
|
285
358
|
* against the cap, and the ways out. Plain text — colour is applied by the
|
|
286
359
|
* caller so scripts capturing stdout get a clean block.
|
|
287
360
|
*
|
|
288
|
-
* Every line is derived from `
|
|
361
|
+
* Every line is derived from `status.reasons`. A workspace over on secrets is
|
|
289
362
|
* never told it has too many members, and an empty reason list (a server
|
|
290
363
|
* dimension this CLI does not know) renders a generic line rather than a claim
|
|
291
364
|
* about a dimension nobody measured.
|
|
292
365
|
*/
|
|
293
366
|
export function renderPlanLimitNotice(status) {
|
|
294
|
-
const {
|
|
367
|
+
const { companySlug, freeMonth } = status;
|
|
295
368
|
const lines = [];
|
|
296
369
|
lines.push(`HQ Starter — ${companySlug} is over its plan limits.`);
|
|
297
|
-
const reasons =
|
|
298
|
-
?
|
|
370
|
+
const reasons = status.reasons.length
|
|
371
|
+
? status.reasons.map(reasonLabel).join("; ")
|
|
299
372
|
: "over the Starter plan limits";
|
|
300
373
|
lines.push(` Why: ${reasons}.`);
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
lines.push(members
|
|
304
|
-
? ` Members: ${members.used} of ${target}.`
|
|
305
|
-
: ` Members allowed on Starter: ${target}.`);
|
|
306
|
-
}
|
|
307
|
-
if (lock.reasons.includes("integrations")) {
|
|
308
|
-
lines.push(` Integrations allowed on Starter: ${integrationTarget(lock)}.`);
|
|
309
|
-
}
|
|
310
|
-
if (lock.reasons.includes("secrets")) {
|
|
311
|
-
lines.push(` Secrets allowed on Starter: ${secretTarget(lock)}.`);
|
|
312
|
-
}
|
|
313
|
-
if (lock.reasons.includes("agents")) {
|
|
314
|
-
lines.push(` Agents allowed on Starter: ${agentTarget(lock)}.`);
|
|
374
|
+
for (const reason of status.reasons) {
|
|
375
|
+
lines.push(usageLine(reason, status));
|
|
315
376
|
}
|
|
316
377
|
lines.push(HARD_STOP_SENTENCE_LINES[0]);
|
|
317
378
|
lines.push(HARD_STOP_SENTENCE_LINES[1]);
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
? lock.reasons.map((reason) => reasonRemedy(reason, lock)).join(", and ")
|
|
379
|
+
const remedy = status.reasons.length
|
|
380
|
+
? status.reasons.map((reason) => reasonRemedy(reason, status)).join(", and ")
|
|
321
381
|
: "come back under the Starter plan limits";
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
382
|
+
const freeMonthUrl = freeMonth?.url;
|
|
383
|
+
const hasFreeMonth = Boolean(freeMonth?.eligible && freeMonthUrl);
|
|
384
|
+
if (hasFreeMonth) {
|
|
385
|
+
lines.push(" Three ways to sort it:");
|
|
386
|
+
lines.push(` 1. Start your free month — add a card, first month free: ${freeMonthUrl}`);
|
|
387
|
+
lines.push(` 2. ${capitalize(remedy)}.`);
|
|
388
|
+
lines.push(` 3. Upgrade to ${WORKFORCE_PLAN_LABEL}.`);
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
lines.push(" Two ways to sort it:");
|
|
392
|
+
lines.push(` 1. ${capitalize(remedy)}.`);
|
|
393
|
+
lines.push(` 2. Upgrade to ${WORKFORCE_PLAN_LABEL}.`);
|
|
394
|
+
}
|
|
395
|
+
lines.push(` Upgrade: ${status.upgradeUrl}`);
|
|
325
396
|
return lines.join("\n");
|
|
326
397
|
}
|
|
327
398
|
/**
|
|
@@ -329,24 +400,22 @@ export function renderPlanLimitNotice(status) {
|
|
|
329
400
|
* Kept to a single line on purpose — it repeats each turn.
|
|
330
401
|
*/
|
|
331
402
|
export function renderPlanLimitLine(status) {
|
|
332
|
-
const detail = status.
|
|
333
|
-
? status.
|
|
334
|
-
.map((reason) => reasonDetail(reason, status))
|
|
335
|
-
.join("; ")
|
|
403
|
+
const detail = status.reasons.length
|
|
404
|
+
? status.reasons.map((reason) => reasonDetail(reason, status)).join("; ")
|
|
336
405
|
: "over the Starter plan limits";
|
|
337
406
|
return (`Company ${status.companySlug} is over its Starter limits (${detail}). ` +
|
|
338
|
-
`New files and new secrets are paused until this is sorted: ${status.
|
|
407
|
+
`New files and new secrets are paused until this is sorted: ${status.upgradeUrl}`);
|
|
339
408
|
}
|
|
340
409
|
/**
|
|
341
410
|
* The `Plan: …` orientation line. Starter only: a paid or enterprise
|
|
342
411
|
* workspace — and an UNKNOWN plan — gets no line at all.
|
|
343
412
|
*/
|
|
344
413
|
export function renderPlanLine(status) {
|
|
345
|
-
if (status.
|
|
414
|
+
if (status.over)
|
|
346
415
|
return "Plan: Starter — over its limits";
|
|
347
416
|
if (status.plan !== "free")
|
|
348
417
|
return null;
|
|
349
|
-
const target = status.
|
|
418
|
+
const target = status.members?.limit ?? STARTER_MEMBER_TARGET;
|
|
350
419
|
if (status.members) {
|
|
351
420
|
return `Plan: Starter — ${status.members.used} of ${target} members`;
|
|
352
421
|
}
|
package/dist/lib/mesh/api.js
CHANGED
|
@@ -139,38 +139,106 @@ function asRecord(value) {
|
|
|
139
139
|
? value
|
|
140
140
|
: null;
|
|
141
141
|
}
|
|
142
|
-
|
|
142
|
+
function threadRows(data) {
|
|
143
|
+
const rows = Array.isArray(data?.threads)
|
|
144
|
+
? data.threads
|
|
145
|
+
: Array.isArray(data?.items)
|
|
146
|
+
? data.items
|
|
147
|
+
: [];
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const row of rows) {
|
|
150
|
+
const rec = asRecord(row);
|
|
151
|
+
if (!rec)
|
|
152
|
+
continue;
|
|
153
|
+
out.push(rec);
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
function sortThreadsByActivity(threads) {
|
|
158
|
+
return [...threads].sort((a, b) => String(b.lastActivityAt || b.createdAt || "").localeCompare(String(a.lastActivityAt || a.createdAt || "")));
|
|
159
|
+
}
|
|
160
|
+
function isActiveThread(thread) {
|
|
161
|
+
const status = String(thread.threadStatus || "").toLowerCase();
|
|
162
|
+
return !status || ACTIVE_STATUSES.has(status);
|
|
163
|
+
}
|
|
164
|
+
function threadKey(thread) {
|
|
165
|
+
const id = typeof thread.threadId === "string" ? thread.threadId.trim() : "";
|
|
166
|
+
return id || undefined;
|
|
167
|
+
}
|
|
168
|
+
async function listThreadsByQuery(token, query) {
|
|
169
|
+
const collected = [];
|
|
170
|
+
let cursor = "";
|
|
171
|
+
for (let page = 0; page < 10; page += 1) {
|
|
172
|
+
const pageQuery = { ...query, limit: "100" };
|
|
173
|
+
if (cursor)
|
|
174
|
+
pageQuery.cursor = cursor;
|
|
175
|
+
const data = asRecord(await meshJson(token, "/v1/work-mesh/threads", { query: pageQuery }));
|
|
176
|
+
collected.push(...threadRows(data));
|
|
177
|
+
cursor = typeof data?.nextCursor === "string" ? data.nextCursor : "";
|
|
178
|
+
if (!cursor)
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
return collected;
|
|
182
|
+
}
|
|
183
|
+
async function listThreadsByStatus(token, companyUid) {
|
|
143
184
|
const collected = [];
|
|
144
185
|
for (const status of ACTIVE_STATUSES) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
break;
|
|
186
|
+
collected.push(...(await listThreadsByQuery(token, { companyUid, status })));
|
|
187
|
+
}
|
|
188
|
+
return collected;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Project threads are often missing from the status index. Prefer
|
|
192
|
+
* `projectId` + `status=open`; if that combined query is rejected, fall
|
|
193
|
+
* back to `projectId` alone.
|
|
194
|
+
*/
|
|
195
|
+
async function listProjectThreads(token, companyUid, projectId) {
|
|
196
|
+
try {
|
|
197
|
+
return await listThreadsByQuery(token, {
|
|
198
|
+
companyUid,
|
|
199
|
+
projectId,
|
|
200
|
+
status: "open",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
try {
|
|
205
|
+
return await listThreadsByQuery(token, { companyUid, projectId });
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return [];
|
|
169
209
|
}
|
|
170
210
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
211
|
+
}
|
|
212
|
+
function mergeProjectAndStatusThreads(projectThreads, statusThreads, projectId) {
|
|
213
|
+
const seen = new Set();
|
|
214
|
+
const merged = [];
|
|
215
|
+
for (const thread of sortThreadsByActivity(projectThreads.filter((t) => (!t.projectId || t.projectId === projectId) && isActiveThread(t)))) {
|
|
216
|
+
const key = threadKey(thread);
|
|
217
|
+
if (key) {
|
|
218
|
+
if (seen.has(key))
|
|
219
|
+
continue;
|
|
220
|
+
seen.add(key);
|
|
221
|
+
}
|
|
222
|
+
merged.push(thread);
|
|
223
|
+
}
|
|
224
|
+
for (const thread of sortThreadsByActivity(statusThreads.filter((t) => t.projectId === projectId && isActiveThread(t)))) {
|
|
225
|
+
const key = threadKey(thread);
|
|
226
|
+
if (key) {
|
|
227
|
+
if (seen.has(key))
|
|
228
|
+
continue;
|
|
229
|
+
seen.add(key);
|
|
230
|
+
}
|
|
231
|
+
merged.push(thread);
|
|
232
|
+
}
|
|
233
|
+
return merged;
|
|
234
|
+
}
|
|
235
|
+
export async function listActiveThreads(token, companyUid, projectId) {
|
|
236
|
+
const statusListed = await listThreadsByStatus(token, companyUid);
|
|
237
|
+
if (!projectId) {
|
|
238
|
+
return sortThreadsByActivity(statusListed);
|
|
239
|
+
}
|
|
240
|
+
const projectListed = await listProjectThreads(token, companyUid, projectId);
|
|
241
|
+
return mergeProjectAndStatusThreads(projectListed, statusListed, projectId);
|
|
174
242
|
}
|
|
175
243
|
export function clamp(value, max) {
|
|
176
244
|
return value.length <= max ? value : value.slice(0, max);
|