quoxcli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +110 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/admin.js +7 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/governance.js +7 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +58 -0
- package/lib/api/endpoints/index.js +4 -0
- package/lib/api/endpoints/lucid.js +17 -0
- package/lib/api/endpoints/mcp.js +35 -0
- package/lib/api/endpoints/mon.js +122 -0
- package/lib/api/endpoints/notesq.js +2 -0
- package/lib/api/endpoints/quoxplan.js +51 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +13 -0
- package/lib/commands/actions.js +51 -13
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agentAttach.js +15 -0
- package/lib/commands/approvals.js +193 -3
- package/lib/commands/ask.js +1 -1
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +15 -0
- package/lib/commands/claude.js +4 -1
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/contacts.js +2 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/evidence.js +2 -1
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +78 -5
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +30 -3
- package/lib/commands/lucid.js +7 -0
- package/lib/commands/lucidGoals.js +389 -0
- package/lib/commands/mail.js +427 -25
- package/lib/commands/mcp.js +190 -0
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +69 -144
- package/lib/commands/monitoring.js +68 -0
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +617 -0
- package/lib/commands/objective.js +5 -2
- package/lib/commands/offload.js +52 -12
- package/lib/commands/plan.js +487 -52
- package/lib/commands/provider.js +29 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/run.js +2 -1
- package/lib/commands/server.js +37 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/skills.js +4 -1
- package/lib/commands/slot.js +49 -9
- package/lib/commands/status.js +92 -44
- package/lib/commands/tool.js +30 -0
- package/lib/commands/vault.js +6 -3
- package/lib/commands/volt.js +6 -1
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +880 -0
- package/lib/commands/wazuhIntake.js +358 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -0
- package/lib/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +27 -1
- package/lib/core/commandRegistrars.js +9 -0
- package/lib/core/config.js +42 -3
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/governedSession.js +135 -5
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/offload.js +120 -0
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +30 -5
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/selfUpdate.js +2 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/EXTENSIONS.md +142 -19
- package/lib/quoxon/api.js +189 -34
- package/lib/quoxon/app.js +1277 -188
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +56 -2
- package/lib/quoxon/approvalSentence.js +8 -4
- package/lib/quoxon/approvalsView.js +72 -12
- package/lib/quoxon/ask.js +4 -2
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +8 -2
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +8 -1
- package/lib/quoxon/commands.js +42 -12
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/delegationDragon.js +49 -0
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dnd.js +118 -0
- package/lib/quoxon/dragonCompanion.js +37 -7
- package/lib/quoxon/dragonMoments.js +78 -0
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +55 -8
- package/lib/quoxon/extensions.js +400 -38
- package/lib/quoxon/foreignText.js +49 -0
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +125 -22
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/launcher.js +2 -1
- package/lib/quoxon/loopCard.js +7 -2
- package/lib/quoxon/loopCards.js +17 -8
- package/lib/quoxon/loopStream.js +20 -9
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +27 -6
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/dockerOffer.js +3 -1
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +22 -0
- package/lib/quoxon/print.js +2 -0
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/render.js +68 -16
- package/lib/quoxon/resend.js +14 -0
- package/lib/quoxon/roster.js +10 -5
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/rows.js +3 -1
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +88 -0
- package/lib/quoxon/slash/agents.js +2 -1
- package/lib/quoxon/slash/argSources.js +9 -2
- package/lib/quoxon/slash/args.js +4 -2
- package/lib/quoxon/slash/broadcast.js +48 -11
- package/lib/quoxon/slash/broadcastRun.js +24 -7
- package/lib/quoxon/slash/cliLeaves.json +98 -1
- package/lib/quoxon/slash/dashboard.js +2 -0
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/listView.js +2 -1
- package/lib/quoxon/slash/palette.js +63 -1
- package/lib/quoxon/slash/paletteController.js +15 -5
- package/lib/quoxon/slash/restart.js +9 -4
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +24 -3
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/slotRun.js +2 -0
- package/lib/quoxon/suggestCommand.js +1 -1
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/webPty.js +15 -0
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +42 -0
- package/lib/render/planBrief.js +614 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +61 -3
- package/package.json +3 -2
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
- package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
- package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
- package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
- package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
- package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
- package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
- package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
- package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
package/lib/core/confirm.js
CHANGED
|
@@ -17,6 +17,51 @@
|
|
|
17
17
|
* @param {string} message - confirmation prompt text
|
|
18
18
|
* @returns {Promise<boolean>}
|
|
19
19
|
*/
|
|
20
|
+
import { QuoxUserError } from './errors.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Ask y/N, but treat a closed stdin (EOF) as "no answer" (resolves false)
|
|
24
|
+
* instead of leaving the prompt pending, which Node reports as a leaked
|
|
25
|
+
* "Detected unsettled top-level await" warning.
|
|
26
|
+
*/
|
|
27
|
+
async function askConfirm(message) {
|
|
28
|
+
const { default: inquirer } = await import('inquirer');
|
|
29
|
+
let onEnd;
|
|
30
|
+
const eof = new Promise((resolve) => {
|
|
31
|
+
onEnd = () => resolve(false);
|
|
32
|
+
process.stdin.once('end', onEnd);
|
|
33
|
+
process.stdin.once('close', onEnd);
|
|
34
|
+
});
|
|
35
|
+
try {
|
|
36
|
+
const ans = await Promise.race([
|
|
37
|
+
inquirer.prompt([{ type: 'confirm', name: 'confirm', message, default: false }]),
|
|
38
|
+
eof,
|
|
39
|
+
]);
|
|
40
|
+
return ans === false ? false : ans.confirm;
|
|
41
|
+
} finally {
|
|
42
|
+
process.stdin.off('end', onEnd);
|
|
43
|
+
process.stdin.off('close', onEnd);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* y/N for verbs that have their own non-interactive flag (e.g. `--i-accept`).
|
|
49
|
+
* With no terminal it does not open a prompt: it throws a one-line
|
|
50
|
+
* QuoxUserError naming the flag, exit code 2.
|
|
51
|
+
* @param {string} message
|
|
52
|
+
* @param {{ flag?: string, purpose?: string }} [opts]
|
|
53
|
+
* @returns {Promise<boolean>}
|
|
54
|
+
*/
|
|
55
|
+
export async function confirmInteractive(message, { flag = '--yes', purpose = 'confirm' } = {}) {
|
|
56
|
+
if (!process.stdin.isTTY) {
|
|
57
|
+
throw new QuoxUserError(
|
|
58
|
+
`No terminal to answer y/N here. Read the output above, then re-run with ${flag} to ${purpose}.`,
|
|
59
|
+
{ exitCode: 2, code: 'no_terminal' },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return askConfirm(message);
|
|
63
|
+
}
|
|
64
|
+
|
|
20
65
|
export async function confirmDestructive(program, options, message) {
|
|
21
66
|
if (options.force === true || program.opts().yes === true) {
|
|
22
67
|
return true;
|
|
@@ -26,12 +71,28 @@ export async function confirmDestructive(program, options, message) {
|
|
|
26
71
|
throw new Error(`${message} — this needs confirmation; re-run with --force (or -y) in a non-interactive shell`);
|
|
27
72
|
}
|
|
28
73
|
|
|
74
|
+
return askConfirm(message);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Ask for a typed line (e.g. the name of the object about to be deleted). EOF settles as null.
|
|
79
|
+
* Callers check the terminal first; this never opens a prompt without one.
|
|
80
|
+
* @param {string} message
|
|
81
|
+
* @returns {Promise<string|null>}
|
|
82
|
+
*/
|
|
83
|
+
export async function askTyped(message) {
|
|
29
84
|
const { default: inquirer } = await import('inquirer');
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
85
|
+
let onEnd;
|
|
86
|
+
const eof = new Promise((resolve) => {
|
|
87
|
+
onEnd = () => resolve(null);
|
|
88
|
+
process.stdin.once('end', onEnd);
|
|
89
|
+
process.stdin.once('close', onEnd);
|
|
90
|
+
});
|
|
91
|
+
try {
|
|
92
|
+
const ans = await Promise.race([inquirer.prompt([{ type: 'input', name: 'typed', message }]), eof]);
|
|
93
|
+
return ans === null ? null : String(ans.typed ?? '');
|
|
94
|
+
} finally {
|
|
95
|
+
process.stdin.off('end', onEnd);
|
|
96
|
+
process.stdin.off('close', onEnd);
|
|
97
|
+
}
|
|
37
98
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crossOrgApprovals: "N more waiting in <org>" for the orgs the user belongs to OTHER than the active one.
|
|
3
|
+
*
|
|
4
|
+
* Read-only and counts only. orgs.list is scoped to the caller, and each count is the user's own
|
|
5
|
+
* GET /api/orgs/:orgId/inbox/stats (server: active membership of THAT org, owner/admin see the whole org,
|
|
6
|
+
* members only their own items), read with the caller's OWN token: the server checks membership of the URL org
|
|
7
|
+
* in the database, not the token's org claim, so no per-org token is minted (a mint is an auth.org_switch audit
|
|
8
|
+
* event and ~2s each). No card content crosses orgs and nothing here can decide anything; deciding in another org
|
|
9
|
+
* is an explicit `--org <id>` on the verb. Counts (never tokens) are cached 60s in-process.
|
|
10
|
+
*/
|
|
11
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
12
|
+
|
|
13
|
+
const listOf = (d) => (Array.isArray(d) ? d : (d?.organizations || d?.orgs || d?.data || []));
|
|
14
|
+
|
|
15
|
+
const CACHE_MS = 60 * 1000;
|
|
16
|
+
const cache = new Map(); // `${userScope}|${currentOrgId}` -> { at, res }
|
|
17
|
+
export const _clearCrossOrgCache = () => cache.clear();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} a
|
|
21
|
+
* @param {object} a.client the caller's client (lists memberships, reads each org's counts)
|
|
22
|
+
* @param {string|null} a.currentOrgId
|
|
23
|
+
* @param {()=>number} [a.now]
|
|
24
|
+
* @returns {Promise<{ waiting: {id:string,name:string,count:number}[], failed: number, checked: boolean }>}
|
|
25
|
+
* failed counts orgs that could not be read: an unreadable org is NOT "nothing waiting".
|
|
26
|
+
*/
|
|
27
|
+
export async function otherOrgsWaiting({ client, currentOrgId, now = Date.now }) {
|
|
28
|
+
if (!client) return { waiting: [], failed: 0, checked: false };
|
|
29
|
+
const key = `${currentOrgId}`;
|
|
30
|
+
const hit = cache.get(key);
|
|
31
|
+
if (hit && now() - hit.at < CACHE_MS) return hit.res;
|
|
32
|
+
let orgs;
|
|
33
|
+
try { const ep = resolveEndpoint('orgs.list'); orgs = listOf(await client.get(ep.path, { service: ep.service })); }
|
|
34
|
+
catch { return { waiting: [], failed: 1, checked: true }; }
|
|
35
|
+
const others = orgs.filter((o) => o?.id && o.id !== currentOrgId);
|
|
36
|
+
const waiting = []; let failed = 0;
|
|
37
|
+
await Promise.all(others.map(async (o) => {
|
|
38
|
+
try {
|
|
39
|
+
const ep = resolveEndpoint('inbox.stats.list', { orgId: o.id });
|
|
40
|
+
const d = await client.get(ep.path, { service: ep.service, retries: 0 });
|
|
41
|
+
const count = Number(d?.awaiting_human ?? d?.pending_approvals ?? 0);
|
|
42
|
+
if (count > 0) waiting.push({ id: o.id, name: o.name || o.slug || o.id, count });
|
|
43
|
+
} catch { failed += 1; }
|
|
44
|
+
}));
|
|
45
|
+
waiting.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
|
46
|
+
const res = { waiting, failed, checked: true };
|
|
47
|
+
if (!failed) cache.set(key, { at: now(), res }); // a partial answer is never cached
|
|
48
|
+
return res;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One line per org (plain terminal): the exact command to run. */
|
|
52
|
+
export function crossOrgLines(res, verb = 'list') {
|
|
53
|
+
const out = (res?.waiting || []).map((w) => `${w.count} more waiting in ${w.name}: quox approvals ${verb} --org ${w.id}`);
|
|
54
|
+
if (res?.failed) out.push(`Could not check ${res.failed} other org${res.failed === 1 ? '' : 's'} for waiting approvals; that does not mean nothing is waiting there.`);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** TUI wording: switching is the TUI's own move. */
|
|
59
|
+
export function crossOrgTuiLines(res) {
|
|
60
|
+
const out = (res?.waiting || []).map((w) => `${w.count} more waiting in ${w.name}: /org switch ${w.name}`);
|
|
61
|
+
if (res?.failed) out.push(`Could not check ${res.failed} other org${res.failed === 1 ? '' : 's'} for waiting approvals; that does not mean nothing is waiting there.`);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The client for a verb: the profile client for the session org, a minted same-user token client for another. */
|
|
66
|
+
export async function clientForTarget({ client, clientForOrg, sessionOrgId, orgId }) {
|
|
67
|
+
if (!orgId || orgId === sessionOrgId || typeof clientForOrg !== 'function') return client;
|
|
68
|
+
return clientForOrg(orgId);
|
|
69
|
+
}
|
package/lib/core/errors.js
CHANGED
|
@@ -214,6 +214,14 @@ export function explainError(err, ctx = null) {
|
|
|
214
214
|
const raw = RAW_HEADLINE.test(String(err.message || '').split('\n')[0]) || err.reasonFromError === true;
|
|
215
215
|
const reason = serverReason(err);
|
|
216
216
|
|
|
217
|
+
// SKILLRIGHTS #478: require-acceptance refusal is not a generic 409 conflict.
|
|
218
|
+
if (status === 409 && (code === 'acceptance_required' || body.error === 'acceptance_required')) {
|
|
219
|
+
const pack = typeof body.packId === 'string' && body.packId ? body.packId : 'quox-core-dev-pack';
|
|
220
|
+
return {
|
|
221
|
+
headline: sentence(reason || `This org's distribution policy is require-acceptance, and nobody has accepted the current terms for pack '${pack}'`),
|
|
222
|
+
next: `accept the terms as yourself: quox dev skills accept ${pack} (add --i-accept with no terminal), then re-run the sync.`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
217
225
|
// #8 service-key fallback: the real problem is "no stored session", say it once.
|
|
218
226
|
if (err.serviceKeyFallback && (status === 401 || status === 403)) {
|
|
219
227
|
return {
|
|
@@ -268,6 +276,28 @@ export function explainError(err, ctx = null) {
|
|
|
268
276
|
if (status === 404 && /^approval not found$/i.test(reason || '')) {
|
|
269
277
|
return { headline: 'No approval with that id in this org; it may belong to another org or be past retention.', next: notFoundNext(ctx) || '`quox approvals --help` shows how to list what exists.' };
|
|
270
278
|
}
|
|
279
|
+
// DS-010 / #1335: a step-up refusal is about WHO is at the keyboard, not about org or role. The old fall-through
|
|
280
|
+
// sent people to "switch org / ask an admin", which cannot help: only the approver's own PIN does.
|
|
281
|
+
if (status === 403 && /step_up_required/.test(all)) {
|
|
282
|
+
if (body.reason === 'non_human') {
|
|
283
|
+
return { headline: 'This approval needs a person, and this login is an agent or service identity.', next: 'approve it from a person\'s own login (the dashboard, or `quox` in their terminal).' };
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
headline: 'This approval needs your step-up PIN.',
|
|
287
|
+
next: body.pin_set === false
|
|
288
|
+
? 'set a PIN in the dashboard (Settings > Account > Approval PIN), then run this again in a terminal.'
|
|
289
|
+
: 'run this command in an interactive terminal; it will ask for the PIN with hidden input. The PIN cannot be passed as a flag or environment variable.',
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
// the auth service's requireInstanceAdmin refusal (code instance_admin_required): instance admin is instance-wide,
|
|
293
|
+
// so neither switching org nor asking an org admin can help
|
|
294
|
+
if (status === 403 && /instance_admin_required|instance administrator access required/i.test(all)) {
|
|
295
|
+
return { headline: sentence(reason || 'Instance administrator access required'), next: 'this needs an instance administrator account. Run it with a profile that is one (quox -p <profile> ...), or ask your instance admin.' };
|
|
296
|
+
}
|
|
297
|
+
// an instance-admin gate: switching org cannot help, only an instance admin can
|
|
298
|
+
if (status === 403 && /instance admin required/i.test(all)) {
|
|
299
|
+
return { headline: 'This needs an instance admin, and this login is not one.', next: 'ask an instance admin of this Quox server to run it, or to make your login an instance admin.' };
|
|
300
|
+
}
|
|
271
301
|
// #5 a role refusal is not an org-context problem
|
|
272
302
|
if (status === 403 && /insufficient permissions?|lacks?[ _]capability|requires? (an? |the )?(admin|owner) role|not an? (org )?(admin|owner)/i.test(all)) {
|
|
273
303
|
return { headline: sentence(reason || 'Your role does not allow this'), next: 'your role in this org does not allow this; ask an org owner or admin to do it or to raise your role.' };
|
|
@@ -332,6 +362,7 @@ function fallbackExplain(err, ctx) {
|
|
|
332
362
|
}
|
|
333
363
|
|
|
334
364
|
// CLI-ERRORS: an instruction sentence (the step the user takes) vs the rest (what happened).
|
|
365
|
+
const TLS_HINT_CODES = ['DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'ERR_TLS_CERT_ALTNAME_INVALID', 'CERT_HAS_EXPIRED'];
|
|
335
366
|
const INSTRUCTION = /^(run|check|use|retry|try|pass|fix|set|pick|switch|ask|log ?in|re-?run|please)\b/i;
|
|
336
367
|
|
|
337
368
|
function splitInstruction(text) {
|
|
@@ -351,6 +382,10 @@ function splitInstruction(text) {
|
|
|
351
382
|
* CLI-ERRORS: errors the CLI raises itself (auth, network, validation, anything else) in the same
|
|
352
383
|
* contract as API errors: one headline, at most one Next, the rest (`more`) only with --verbose.
|
|
353
384
|
*/
|
|
385
|
+
function connectUrl(u) {
|
|
386
|
+
try { return new URL(u).origin; } catch { return '<url>'; }
|
|
387
|
+
}
|
|
388
|
+
|
|
354
389
|
export function explainLocal(err) {
|
|
355
390
|
if (!err || err instanceof QuoxApiError || err instanceof QuoxUserError) return null;
|
|
356
391
|
const message = String(err.message || '');
|
|
@@ -362,7 +397,7 @@ export function explainLocal(err) {
|
|
|
362
397
|
const more = message.split('\n').slice(1).map((l) => l.trim()).filter(Boolean);
|
|
363
398
|
if (m && code && /CERT|SELF_SIGNED|UNABLE_TO_VERIFY/.test(code)) {
|
|
364
399
|
return { headline: `Could not reach the ${svc} service at ${url}: its certificate is not trusted (${code}).`,
|
|
365
|
-
next:
|
|
400
|
+
next: `trust this server for one profile with quox connect ${connectUrl(url)} --profile <name> (it shows the certificate SHA256 and asks y/N; with no terminal add --fingerprint <sha256>); never disable TLS verification.`, more };
|
|
366
401
|
}
|
|
367
402
|
if (m && (code === 'ERR_SSL_WRONG_VERSION_NUMBER' || code === 'EPROTO')) {
|
|
368
403
|
return { headline: `The ${svc} service at ${url} speaks plain http, but this profile's URL says https://.`,
|
|
@@ -389,6 +424,20 @@ export function explainLocal(err) {
|
|
|
389
424
|
return sp;
|
|
390
425
|
}
|
|
391
426
|
if (err instanceof QuoxValidationError) return splitInstruction(message);
|
|
427
|
+
// H4: a bare fetch (login, MFA, refresh) that died on a certificate this machine does not trust. undici reports it
|
|
428
|
+
// as "fetch failed"; name the problem and the exact next command. Never auto-trust, never TLS-off.
|
|
429
|
+
const tlsCode = TLS_HINT_CODES.find((c) => c === (err.cause?.cause?.code || err.cause?.code || err.code));
|
|
430
|
+
if (tlsCode && err.quoxUrl) {
|
|
431
|
+
const profile = err.quoxProfile ? ` --profile ${err.quoxProfile}` : '';
|
|
432
|
+
const expired = tlsCode === 'CERT_HAS_EXPIRED' || tlsCode === 'ERR_TLS_CERT_ALTNAME_INVALID';
|
|
433
|
+
return { headline: expired
|
|
434
|
+
? `The server at ${err.quoxUrl} presents a certificate that ${tlsCode === 'CERT_HAS_EXPIRED' ? 'has expired' : 'does not name the address you used'} (${tlsCode}).`
|
|
435
|
+
: `The server at ${err.quoxUrl} uses a certificate this machine does not trust yet.`,
|
|
436
|
+
next: expired
|
|
437
|
+
? 'the server needs a certificate that is current and names this address; trusting it would not help. Never disable TLS verification.'
|
|
438
|
+
: `run quox connect ${err.quoxUrl}${profile} (it shows the SHA256 fingerprint and asks y/N); with no terminal use quox connect ${err.quoxUrl}${profile} --trust-cert, or --fingerprint <sha256> to pin it. Never disable TLS verification.`,
|
|
439
|
+
more: [`TLS error: ${tlsCode}`] };
|
|
440
|
+
}
|
|
392
441
|
const first = message.split('\n')[0];
|
|
393
442
|
return { headline: sentence(first || 'Something went wrong'), next: null, more: message.split('\n').slice(1), stack: true };
|
|
394
443
|
}
|
|
@@ -404,6 +453,7 @@ function renderContract(err, ex, ctx) {
|
|
|
404
453
|
}
|
|
405
454
|
if (err.detail) console.error(chalk.dim(` Detail: ${renderDetail(err.detail)}`));
|
|
406
455
|
if (isVerbose(ctx)) {
|
|
456
|
+
if (err.debug) console.error(chalk.dim(` Debug: ${renderDetail(err.debug)}`));
|
|
407
457
|
if (err.status) console.error(chalk.dim(` Status: ${err.status} ${typeof err.code === 'object' && err.code ? renderDetail(err.code) : (err.code || '')}`));
|
|
408
458
|
if (err instanceof QuoxApiError && err.message && err.message !== ex.headline) console.error(chalk.dim(` Server said: ${err.message}`));
|
|
409
459
|
for (const line of ex.more || []) if (line) console.error(chalk.dim(` More: ${line}`));
|
|
@@ -420,6 +470,7 @@ export function handleError(err, outputFormat = 'table', ctx = null) {
|
|
|
420
470
|
};
|
|
421
471
|
if (err.status) obj.status = err.status;
|
|
422
472
|
if (err.detail) obj.detail = err.detail;
|
|
473
|
+
if (err.debug) obj.debug = err.debug; // plumbing kept out of the headline, still there for scripts
|
|
423
474
|
// LL-3d: scripts parsing --json get the server's own body verbatim, not
|
|
424
475
|
// our prose rendering of it.
|
|
425
476
|
if (err.body && typeof err.body === 'object') obj.body = err.body;
|
|
@@ -12,7 +12,18 @@ import os from 'node:os';
|
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { spawnSync as realSpawnSync } from 'node:child_process';
|
|
15
|
-
import {
|
|
15
|
+
import { colorOn } from './banner.js';
|
|
16
|
+
import { qLines, qDims } from '../quoxon/qart.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The session banner's Q is quoxon's settled vector horned-Q (the face of the app, same mark as
|
|
20
|
+
* bare `quox` and cli.quox.ai), not the old half-block bitmap. 'small' for a narrow terminal, 'mid'
|
|
21
|
+
* otherwise. Returns the art rows and their visible width (used to pad the text column alignment).
|
|
22
|
+
*/
|
|
23
|
+
function introArt(color, cols) {
|
|
24
|
+
const size = cols && cols < 72 ? 'small' : 'mid';
|
|
25
|
+
return { lines: qLines(size, { color }), width: qDims(size).cols };
|
|
26
|
+
}
|
|
16
27
|
import { startHeartbeat as realStartHeartbeat } from './sessionHeartbeat.js';
|
|
17
28
|
|
|
18
29
|
const NAME_RE = /^[A-Za-z0-9._-]{1,48}$/;
|
|
@@ -160,10 +171,10 @@ export function howItWorksLines({ name, program = 'claude', bare }) {
|
|
|
160
171
|
];
|
|
161
172
|
}
|
|
162
173
|
|
|
163
|
-
export function renderIntro({ name, host, mode, tmuxSession, note, program = 'claude', preattach }, { color = false } = {}) {
|
|
174
|
+
export function renderIntro({ name, host, mode, tmuxSession, note, program = 'claude', preattach }, { color = false, cols = process.stdout?.columns } = {}) {
|
|
164
175
|
const b = (s) => (color ? `\x1b[1m${s}\x1b[0m` : s);
|
|
165
176
|
const d = (s) => (color ? `\x1b[2m${s}\x1b[0m` : s);
|
|
166
|
-
const art =
|
|
177
|
+
const { lines: art, width: artW } = introArt(color, cols);
|
|
167
178
|
const modeText = {
|
|
168
179
|
NEW: 'NEW',
|
|
169
180
|
REATTACH: 'REATTACH (same process, no context reload)',
|
|
@@ -182,7 +193,7 @@ export function renderIntro({ name, host, mode, tmuxSession, note, program = 'cl
|
|
|
182
193
|
b('How this works'),
|
|
183
194
|
...howItWorksLines({ name, program }).map(([k, v]) => `${d(k.padEnd(19))}${v}`),
|
|
184
195
|
];
|
|
185
|
-
const w = art[0] ?
|
|
196
|
+
const w = art[0] ? artW : 0;
|
|
186
197
|
const lines = [];
|
|
187
198
|
for (let i = 0; i < Math.max(art.length, text.length); i++) {
|
|
188
199
|
lines.push(` ${art[i] || ' '.repeat(w)} ${text[i] || ''}`);
|
|
@@ -254,7 +265,108 @@ export function ensureHelpBinding(d) {
|
|
|
254
265
|
return r && r.status === 0 ? 'bound' : 'failed';
|
|
255
266
|
}
|
|
256
267
|
|
|
257
|
-
|
|
268
|
+
/**
|
|
269
|
+
* SESSION-LAUNCH-DEDUPE: tmux session names are case-sensitive but conversation names are not, so
|
|
270
|
+
* `--resume quoxon` must find `quox-QUOXON`. Returns the real session name or null.
|
|
271
|
+
*/
|
|
272
|
+
export function findSessionCI(d, wanted) {
|
|
273
|
+
const r = d.spawnSync('tmux', ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' });
|
|
274
|
+
if (!r || r.error || r.status !== 0) return null;
|
|
275
|
+
const w = String(wanted).toLowerCase();
|
|
276
|
+
const names = String(r.stdout || '').split('\n').map((l) => l.split('\t')[0].trim()).filter(Boolean);
|
|
277
|
+
return names.find((n) => n.toLowerCase() === w) || null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const PS_RE = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/;
|
|
281
|
+
const base = (t) => String(t || '').split('/').pop();
|
|
282
|
+
|
|
283
|
+
/** True when argv is a claude process resuming `name` (case-insensitive), also via `node .../claude`. */
|
|
284
|
+
export function resumesName(args, name) {
|
|
285
|
+
const t = String(args).trim().split(/\s+/);
|
|
286
|
+
let i = 0;
|
|
287
|
+
if (base(t[0]) === 'node' && base(t[1]) === 'claude') i = 1;
|
|
288
|
+
else if (base(t[0]) !== 'claude') return false;
|
|
289
|
+
const want = String(name).toLowerCase();
|
|
290
|
+
for (let k = i + 1; k < t.length; k++) {
|
|
291
|
+
if ((t[k] === '--resume' || t[k] === '-r') && String(t[k + 1] || '').toLowerCase() === want) return true;
|
|
292
|
+
if (t[k].startsWith('--resume=') && t[k].slice(9).toLowerCase() === want) return true;
|
|
293
|
+
}
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Live claude processes already holding conversation `name`: [{ pid, tty, tmux }]. */
|
|
298
|
+
export function findLiveHolders(d, name) {
|
|
299
|
+
const ps = d.spawnSync('ps', ['-eo', 'pid=,ppid=,tty=,args='], { encoding: 'utf8' });
|
|
300
|
+
if (!ps || ps.error || ps.status !== 0) return [];
|
|
301
|
+
const procs = new Map();
|
|
302
|
+
for (const line of String(ps.stdout || '').split('\n')) {
|
|
303
|
+
const m = PS_RE.exec(line);
|
|
304
|
+
if (m) procs.set(Number(m[1]), { pid: Number(m[1]), ppid: Number(m[2]), tty: m[3], args: m[4] });
|
|
305
|
+
}
|
|
306
|
+
const self = d.pid ?? process.pid;
|
|
307
|
+
const hits = [...procs.values()].filter((p) => p.pid !== self && p.pid !== process.ppid && resumesName(p.args, name));
|
|
308
|
+
if (!hits.length) return [];
|
|
309
|
+
const panes = new Map();
|
|
310
|
+
const lp = d.spawnSync('tmux', ['list-panes', '-a', '-F', '#{pane_pid} #{session_name}'], { encoding: 'utf8' });
|
|
311
|
+
if (lp && !lp.error && lp.status === 0) {
|
|
312
|
+
for (const l of String(lp.stdout || '').split('\n')) {
|
|
313
|
+
const m = /^(\d+)\s+(\S+)$/.exec(l.trim());
|
|
314
|
+
if (m) panes.set(Number(m[1]), m[2]);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return hits.map((p) => {
|
|
318
|
+
let tmux = null;
|
|
319
|
+
for (let pid = p.pid, hops = 0; pid > 1 && hops < 32 && !tmux; hops++) { tmux = panes.get(pid) || null; pid = procs.get(pid)?.ppid ?? (hops === 0 ? p.ppid : 0); }
|
|
320
|
+
return { pid: p.pid, tty: p.tty, tmux };
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The conversation name a claude argv resumes (`--resume X`, `-r X`, `--resume=X`), or null. */
|
|
325
|
+
function resumedName(args) {
|
|
326
|
+
const t = String(args).trim().split(/\s+/);
|
|
327
|
+
let i = 0;
|
|
328
|
+
if (base(t[0]) === 'node' && base(t[1]) === 'claude') i = 1;
|
|
329
|
+
else if (base(t[0]) !== 'claude') return null;
|
|
330
|
+
for (let k = i + 1; k < t.length; k++) {
|
|
331
|
+
if ((t[k] === '--resume' || t[k] === '-r') && t[k + 1]) return t[k + 1];
|
|
332
|
+
if (t[k].startsWith('--resume=') && t[k].length > 9) return t[k].slice(9);
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const PS_AGE_RE = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\d+)\s+(.*)$/;
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Every live `claude --resume <name>` on this box (same argv detection as findLiveHolders):
|
|
341
|
+
* [{ name, pid, tty, tmux, ageSec }]. tmux is the enclosing tmux session or null. [] if ps fails.
|
|
342
|
+
*/
|
|
343
|
+
export function listLiveSessions(d) {
|
|
344
|
+
const ps = d.spawnSync('ps', ['-eo', 'pid=,ppid=,tty=,etimes=,args='], { encoding: 'utf8' });
|
|
345
|
+
if (!ps || ps.error || ps.status !== 0) return [];
|
|
346
|
+
const procs = new Map();
|
|
347
|
+
for (const line of String(ps.stdout || '').split('\n')) {
|
|
348
|
+
const m = PS_AGE_RE.exec(line);
|
|
349
|
+
if (m) procs.set(Number(m[1]), { pid: Number(m[1]), ppid: Number(m[2]), tty: m[3], ageSec: Number(m[4]), args: m[5] });
|
|
350
|
+
}
|
|
351
|
+
const self = d.pid ?? process.pid;
|
|
352
|
+
const hits = [...procs.values()].filter((p) => p.pid !== self && resumedName(p.args));
|
|
353
|
+
if (!hits.length) return [];
|
|
354
|
+
const panes = new Map();
|
|
355
|
+
const lp = d.spawnSync('tmux', ['list-panes', '-a', '-F', '#{pane_pid} #{session_name}'], { encoding: 'utf8' });
|
|
356
|
+
if (lp && !lp.error && lp.status === 0) {
|
|
357
|
+
for (const l of String(lp.stdout || '').split('\n')) {
|
|
358
|
+
const m = /^(\d+)\s+(\S+)$/.exec(l.trim());
|
|
359
|
+
if (m) panes.set(Number(m[1]), m[2]);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return hits.map((p) => {
|
|
363
|
+
let tmux = null;
|
|
364
|
+
for (let pid = p.pid, hops = 0; pid > 1 && hops < 32 && !tmux; hops++) { tmux = panes.get(pid) || null; pid = procs.get(pid)?.ppid ?? (hops === 0 ? p.ppid : 0); }
|
|
365
|
+
return { name: resumedName(p.args), pid: p.pid, tty: p.tty, tmux, ageSec: p.ageSec };
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function runGoverned({ program = 'claude', name, args = [], resume = false, noTmux = false, force = false, bin, preattach }, over) {
|
|
258
370
|
const d = defaults(over);
|
|
259
371
|
const n = name || autoName(d.now());
|
|
260
372
|
if (program === 'run') {
|
|
@@ -281,6 +393,24 @@ export function runGoverned({ program = 'claude', name, args = [], resume = fals
|
|
|
281
393
|
return 1;
|
|
282
394
|
}
|
|
283
395
|
alive = r.status === 0;
|
|
396
|
+
if (!alive && resume && program === 'claude') {
|
|
397
|
+
const real = findSessionCI(d, PREFIX + full);
|
|
398
|
+
if (real) {
|
|
399
|
+
// Same conversation, different case: attach, never start a second Claude.
|
|
400
|
+
const attach = `tmux attach -t ${real}`;
|
|
401
|
+
if (!(d.isTTY ?? process.stdout.isTTY)) { d.write(`${n} is already running in tmux ${real}. Attach: ${attach}\n`); return 0; }
|
|
402
|
+
const a = d.spawnSync('tmux', ['attach-session', '-t', real], { stdio: 'inherit' });
|
|
403
|
+
return a && typeof a.status === 'number' ? a.status : 0;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (!alive && resume && program === 'claude' && !force) {
|
|
408
|
+
const [h] = findLiveHolders(d, n);
|
|
409
|
+
if (h) {
|
|
410
|
+
const where = h.tmux ? `tmux ${h.tmux}` : `not in tmux on ${h.tty}`;
|
|
411
|
+
d.write(`${n} is already running (pid ${h.pid}, ${where}). ${h.tmux ? `Attach: tmux attach -t ${h.tmux}. ` : ''}To start a second copy anyway: --force.\n`);
|
|
412
|
+
return 1;
|
|
413
|
+
}
|
|
284
414
|
}
|
|
285
415
|
const plan = planLaunch({ name: n, args, env: d.env, alive, resume, noTmux, program, bin, preattach });
|
|
286
416
|
const shown = program === 'run' ? bin : program;
|
|
@@ -41,13 +41,14 @@ export function humanUtc(iso) {
|
|
|
41
41
|
export function resolutionFromItem(item, requestedAction) {
|
|
42
42
|
if (!item || !item.id) return null;
|
|
43
43
|
const response = item.response || null;
|
|
44
|
-
const outcome = item.status === 'expired' ? 'expired' : item.status === 'cancelled' ? 'cancelled' : (outcomeOf(response) || 'resolved');
|
|
44
|
+
const outcome = item.status === 'expired' ? 'expired' : item.status === 'cancelled' ? 'cancelled' : item.status === 'withdrawn' ? 'withdrawn' : (outcomeOf(response) || 'resolved');
|
|
45
45
|
const name = item.resolved_by_name || response?.responded_by_name || null;
|
|
46
46
|
const at = toIso(item.resolved_at) || toIso(response?.responded_at) || null;
|
|
47
47
|
const requested = String(requestedAction || '').trim().toLowerCase() || null;
|
|
48
|
-
const matches = Boolean(requested) && outcome !== 'expired' && outcome !== 'cancelled' && normalise(requested) === outcome;
|
|
48
|
+
const matches = Boolean(requested) && outcome !== 'expired' && outcome !== 'cancelled' && outcome !== 'withdrawn' && normalise(requested) === outcome;
|
|
49
49
|
let message;
|
|
50
50
|
if (outcome === 'expired') message = `This approval expired at ${humanUtc(at)}; nothing was approved. Ask for it again.`;
|
|
51
|
+
else if (outcome === 'withdrawn') message = `This request was withdrawn at ${humanUtc(at)}${name ? ` by ${name}` : ''}; nothing was decided and nothing ran.`;
|
|
51
52
|
else if (outcome === 'cancelled') message = `This item was cancelled at ${humanUtc(at)}${name ? ` by ${name}` : ''}; there is nothing to respond to.`;
|
|
52
53
|
else {
|
|
53
54
|
const head = `Already ${outcome}${name ? ` by ${name}` : ''} at ${humanUtc(at)}`;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login guards shared by `quox login` and the TUI /login.
|
|
3
|
+
* - prefill comes ONLY from the active profile's own last_email
|
|
4
|
+
* - a non-default profile signing in as an email another profile already holds gets a y/N warning
|
|
5
|
+
* - a 429 says how long to wait (or that retrying may extend the limit)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The active profile's own last_email, or ''. Never another profile's, never a global. */
|
|
9
|
+
export function loginPrefillEmail(config) {
|
|
10
|
+
try { return String(config.getActiveProfile?.()?.last_email || '').trim(); } catch { return ''; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A server URL reduced to scheme://host:port so https://H, https://h/ and https://h:443 compare equal. '' when not a URL. */
|
|
14
|
+
export function normalizeServerUrl(u) {
|
|
15
|
+
try { const x = new URL(String(u)); return `${x.protocol}//${x.hostname.toLowerCase()}:${x.port || (x.protocol === 'https:' ? '443' : '80')}`; } catch { return ''; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Name of ANOTHER profile currently signed in as `email` (case-insensitive), else null.
|
|
20
|
+
* H2: the same email on a DIFFERENT server is a different account. When `url` (the server this sign-in targets) is
|
|
21
|
+
* given and the other profile has its own server URL that differs, it does not count. A profile with no URL of its
|
|
22
|
+
* own is unknown, so it still counts (the safe side for an identity guard).
|
|
23
|
+
*/
|
|
24
|
+
export function profileHoldingEmail(config, email, url = null) {
|
|
25
|
+
const want = String(email || '').trim().toLowerCase();
|
|
26
|
+
if (!want) return null;
|
|
27
|
+
const me = config.getActiveProfileName?.();
|
|
28
|
+
for (const [name, p] of Object.entries(config._data?.profiles || {})) {
|
|
29
|
+
if (name === me) continue;
|
|
30
|
+
if (String(p?.auth?.email || '').trim().toLowerCase() !== want) continue;
|
|
31
|
+
const mine = normalizeServerUrl(url), theirs = normalizeServerUrl(p?.url);
|
|
32
|
+
if (mine && theirs && mine !== theirs) continue;
|
|
33
|
+
return name;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Only a non-default profile needs the warning: it is how agent-only profiles become the owner. */
|
|
39
|
+
export function isNonDefaultProfile(config) {
|
|
40
|
+
return Boolean(config._data?.activeProfile) && config.getActiveProfileName?.() !== config._data.activeProfile;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function sameIdentityWarning(email, holder, profile) {
|
|
44
|
+
return `${email} is already signed in as profile '${holder}'. Sign profile '${profile}' in as the same user? y/N`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Plain-language wait guidance for a rate-limited sign-in. */
|
|
48
|
+
export function rateLimitHint({ retryAfterMs } = {}) {
|
|
49
|
+
if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
|
|
50
|
+
const min = Math.max(1, Math.ceil(retryAfterMs / 60000));
|
|
51
|
+
return `Too many authentication attempts: try again in ~${min} min; each retry may extend the limit.`;
|
|
52
|
+
}
|
|
53
|
+
return 'Too many authentication attempts: wait a few minutes; retrying now may extend the limit.';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Retry-After (seconds or HTTP date) header and common body fields -> ms, else null. */
|
|
57
|
+
export function retryAfterMsFrom(res, data) {
|
|
58
|
+
const h = res?.headers?.get?.('retry-after');
|
|
59
|
+
if (h != null && h !== '') {
|
|
60
|
+
const n = Number(h);
|
|
61
|
+
if (Number.isFinite(n)) return n * 1000;
|
|
62
|
+
const t = Date.parse(h);
|
|
63
|
+
if (Number.isFinite(t)) return Math.max(0, t - Date.now());
|
|
64
|
+
}
|
|
65
|
+
const s = data?.retry_after ?? data?.retryAfter ?? data?.retry_after_seconds;
|
|
66
|
+
if (Number.isFinite(Number(s)) && s != null) return Number(s) * 1000;
|
|
67
|
+
const reset = data?.reset_at ?? data?.resetAt ?? data?.reset;
|
|
68
|
+
if (reset != null) {
|
|
69
|
+
const t = typeof reset === 'number' ? (reset < 1e12 ? reset * 1000 : reset) : Date.parse(reset);
|
|
70
|
+
if (Number.isFinite(t)) return Math.max(0, t - Date.now());
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|