@oxygen-agent/cli 1.1014.135 → 1.1014.618
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/README.md +1 -1
- package/dist/admin-primary-providers-render.js +27 -19
- package/dist/cli-output.d.ts +12 -0
- package/dist/cli-output.js +395 -0
- package/dist/cli-paths.d.ts +1 -0
- package/dist/cli-paths.js +3 -0
- package/dist/cli-profile-context.d.ts +3 -0
- package/dist/cli-profile-context.js +13 -0
- package/dist/command-manifest.d.ts +15 -0
- package/dist/command-manifest.js +23 -1
- package/dist/command-support.d.ts +438 -0
- package/dist/command-support.js +1610 -0
- package/dist/commands/action-column.d.ts +3 -0
- package/dist/commands/action-column.js +43 -0
- package/dist/commands/activation.d.ts +3 -0
- package/dist/commands/activation.js +24 -0
- package/dist/commands/admin.d.ts +3 -0
- package/dist/commands/admin.js +347 -0
- package/dist/commands/ads.d.ts +3 -0
- package/dist/commands/ads.js +62 -0
- package/dist/commands/agent.d.ts +3 -0
- package/dist/commands/agent.js +274 -0
- package/dist/commands/api-keys.d.ts +3 -0
- package/dist/commands/api-keys.js +77 -0
- package/dist/commands/approvals.d.ts +3 -0
- package/dist/commands/approvals.js +518 -0
- package/dist/commands/auth.d.ts +3 -0
- package/dist/commands/auth.js +230 -0
- package/dist/commands/billing.d.ts +3 -0
- package/dist/commands/billing.js +357 -0
- package/dist/commands/blueprints.d.ts +3 -0
- package/dist/commands/blueprints.js +312 -0
- package/dist/commands/budget.d.ts +3 -0
- package/dist/commands/budget.js +55 -0
- package/dist/commands/callables.d.ts +3 -0
- package/dist/commands/callables.js +111 -0
- package/dist/commands/capabilities.d.ts +3 -0
- package/dist/commands/capabilities.js +44 -0
- package/dist/commands/cells.d.ts +3 -0
- package/dist/commands/cells.js +94 -0
- package/dist/commands/columns.d.ts +3 -0
- package/dist/commands/columns.js +1628 -0
- package/dist/commands/commands.d.ts +3 -0
- package/dist/commands/commands.js +45 -0
- package/dist/commands/comments.d.ts +3 -0
- package/dist/commands/comments.js +188 -0
- package/dist/commands/companies.d.ts +3 -0
- package/dist/commands/companies.js +312 -0
- package/dist/commands/connections.d.ts +3 -0
- package/dist/commands/connections.js +39 -0
- package/dist/commands/context.d.ts +3 -0
- package/dist/commands/context.js +170 -0
- package/dist/commands/copilot.d.ts +3 -0
- package/dist/commands/copilot.js +565 -0
- package/dist/commands/crm.d.ts +3 -0
- package/dist/commands/crm.js +1669 -0
- package/dist/commands/custom-integrations.d.ts +3 -0
- package/dist/commands/custom-integrations.js +140 -0
- package/dist/commands/dashboards.d.ts +3 -0
- package/dist/commands/dashboards.js +240 -0
- package/dist/commands/db.d.ts +3 -0
- package/dist/commands/db.js +190 -0
- package/dist/commands/deliverability.d.ts +3 -0
- package/dist/commands/deliverability.js +90 -0
- package/dist/commands/directory.d.ts +3 -0
- package/dist/commands/directory.js +101 -0
- package/dist/commands/domains.d.ts +3 -0
- package/dist/commands/domains.js +604 -0
- package/dist/commands/egress.d.ts +3 -0
- package/dist/commands/egress.js +130 -0
- package/dist/commands/email.d.ts +3 -0
- package/dist/commands/email.js +49 -0
- package/dist/commands/engagement.d.ts +3 -0
- package/dist/commands/engagement.js +189 -0
- package/dist/commands/enrich-column.d.ts +3 -0
- package/dist/commands/enrich-column.js +149 -0
- package/dist/commands/enrichment.d.ts +3 -0
- package/dist/commands/enrichment.js +49 -0
- package/dist/commands/feedback.d.ts +3 -0
- package/dist/commands/feedback.js +96 -0
- package/dist/commands/feeds.d.ts +3 -0
- package/dist/commands/feeds.js +486 -0
- package/dist/commands/find.d.ts +3 -0
- package/dist/commands/find.js +159 -0
- package/dist/commands/followers.d.ts +3 -0
- package/dist/commands/followers.js +39 -0
- package/dist/commands/formulas.d.ts +3 -0
- package/dist/commands/formulas.js +43 -0
- package/dist/commands/home.d.ts +3 -0
- package/dist/commands/home.js +22 -0
- package/dist/commands/inbox.d.ts +3 -0
- package/dist/commands/inbox.js +779 -0
- package/dist/commands/integrations.d.ts +3 -0
- package/dist/commands/integrations.js +290 -0
- package/dist/commands/knowledge.d.ts +3 -0
- package/dist/commands/knowledge.js +874 -0
- package/dist/commands/lead-sourcing.d.ts +3 -0
- package/dist/commands/lead-sourcing.js +52 -0
- package/dist/commands/limits.d.ts +3 -0
- package/dist/commands/limits.js +54 -0
- package/dist/commands/linkedin.d.ts +3 -0
- package/dist/commands/linkedin.js +275 -0
- package/dist/commands/login.d.ts +3 -0
- package/dist/commands/login.js +347 -0
- package/dist/commands/logout.d.ts +3 -0
- package/dist/commands/logout.js +12 -0
- package/dist/commands/mailboxes.d.ts +3 -0
- package/dist/commands/mailboxes.js +1290 -0
- package/dist/commands/managed-inboxes.d.ts +3 -0
- package/dist/commands/managed-inboxes.js +361 -0
- package/dist/commands/messages.d.ts +3 -0
- package/dist/commands/messages.js +78 -0
- package/dist/commands/notetaker.d.ts +3 -0
- package/dist/commands/notetaker.js +79 -0
- package/dist/commands/observability.d.ts +3 -0
- package/dist/commands/observability.js +125 -0
- package/dist/commands/orgs.d.ts +3 -0
- package/dist/commands/orgs.js +237 -0
- package/dist/commands/people.d.ts +3 -0
- package/dist/commands/people.js +353 -0
- package/dist/commands/posts.d.ts +3 -0
- package/dist/commands/posts.js +139 -0
- package/dist/commands/profiles.d.ts +3 -0
- package/dist/commands/profiles.js +250 -0
- package/dist/commands/projects.d.ts +3 -0
- package/dist/commands/projects.js +94 -0
- package/dist/commands/prompts.d.ts +3 -0
- package/dist/commands/prompts.js +84 -0
- package/dist/commands/publishing.d.ts +3 -0
- package/dist/commands/publishing.js +1419 -0
- package/dist/commands/recipes.d.ts +3 -0
- package/dist/commands/recipes.js +120 -0
- package/dist/commands/reviews.d.ts +3 -0
- package/dist/commands/reviews.js +75 -0
- package/dist/commands/rows.d.ts +3 -0
- package/dist/commands/rows.js +27 -0
- package/dist/commands/runs.d.ts +3 -0
- package/dist/commands/runs.js +37 -0
- package/dist/commands/search.d.ts +3 -0
- package/dist/commands/search.js +182 -0
- package/dist/commands/senders.d.ts +3 -0
- package/dist/commands/senders.js +393 -0
- package/dist/commands/sequences.d.ts +3 -0
- package/dist/commands/sequences.js +2322 -0
- package/dist/commands/session.d.ts +3 -0
- package/dist/commands/session.js +109 -0
- package/dist/commands/signals.d.ts +3 -0
- package/dist/commands/signals.js +289 -0
- package/dist/commands/signup-leads.d.ts +3 -0
- package/dist/commands/signup-leads.js +46 -0
- package/dist/commands/skills.d.ts +3 -0
- package/dist/commands/skills.js +50 -0
- package/dist/commands/sourcing.d.ts +3 -0
- package/dist/commands/sourcing.js +27 -0
- package/dist/commands/status.d.ts +3 -0
- package/dist/commands/status.js +51 -0
- package/dist/commands/supabase.d.ts +3 -0
- package/dist/commands/supabase.js +128 -0
- package/dist/commands/support.d.ts +3 -0
- package/dist/commands/support.js +651 -0
- package/dist/commands/suppressions.d.ts +3 -0
- package/dist/commands/suppressions.js +577 -0
- package/dist/commands/table-ingestions.d.ts +3 -0
- package/dist/commands/table-ingestions.js +145 -0
- package/dist/commands/table-runs.d.ts +3 -0
- package/dist/commands/table-runs.js +277 -0
- package/dist/commands/tables.d.ts +3 -0
- package/dist/commands/tables.js +3492 -0
- package/dist/commands/tags.d.ts +3 -0
- package/dist/commands/tags.js +147 -0
- package/dist/commands/tools.d.ts +3 -0
- package/dist/commands/tools.js +246 -0
- package/dist/commands/update.d.ts +3 -0
- package/dist/commands/update.js +106 -0
- package/dist/commands/verify.d.ts +3 -0
- package/dist/commands/verify.js +27 -0
- package/dist/commands/viewers.d.ts +3 -0
- package/dist/commands/viewers.js +72 -0
- package/dist/commands/voice.d.ts +3 -0
- package/dist/commands/voice.js +351 -0
- package/dist/commands/whatsapp.d.ts +3 -0
- package/dist/commands/whatsapp.js +226 -0
- package/dist/commands/whoami.d.ts +3 -0
- package/dist/commands/whoami.js +110 -0
- package/dist/commands/worker.d.ts +3 -0
- package/dist/commands/worker.js +60 -0
- package/dist/commands/workflows.d.ts +3 -0
- package/dist/commands/workflows.js +1752 -0
- package/dist/commands/workspace.d.ts +3 -0
- package/dist/commands/workspace.js +94 -0
- package/dist/compact-list-flags.d.ts +7 -0
- package/dist/compact-list-flags.js +12 -0
- package/dist/crm-support-flags.js +14 -5
- package/dist/customer-operation-commands.js +8 -6
- package/dist/diagnostic-uploader.d.ts +3 -1
- package/dist/diagnostic-uploader.js +29 -8
- package/dist/help.d.ts +5 -0
- package/dist/help.js +22 -0
- package/dist/index.d.ts +98 -1
- package/dist/index.js +331 -30719
- package/dist/local-module-loader.d.ts +4 -0
- package/dist/local-module-loader.js +156 -0
- package/dist/visual-commands.js +9 -5
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +2 -2
- package/node_modules/@oxygen/shared/dist/agent-session-schedule.d.ts +36 -0
- package/node_modules/@oxygen/shared/dist/agent-session-schedule.js +69 -0
- package/node_modules/@oxygen/shared/dist/agent-sessions.d.ts +14 -5
- package/node_modules/@oxygen/shared/dist/agent-sessions.js +14 -3
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +175 -14
- package/node_modules/@oxygen/shared/dist/compact-list-result.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/compact-list-result.js +27 -0
- package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.d.ts +12 -1
- package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.js +5 -2
- package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +4 -2
- package/node_modules/@oxygen/shared/dist/contracts/compact-list-reads.d.ts +57 -0
- package/node_modules/@oxygen/shared/dist/contracts/compact-list-reads.js +23 -0
- package/node_modules/@oxygen/shared/dist/contracts/crm-support.d.ts +52 -0
- package/node_modules/@oxygen/shared/dist/contracts/crm-support.js +43 -2
- package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +17 -14
- package/node_modules/@oxygen/shared/dist/contracts/knowledge-content-reads.js +1 -1
- package/node_modules/@oxygen/shared/dist/contracts/knowledge-proposal-reads.d.ts +49 -0
- package/node_modules/@oxygen/shared/dist/contracts/knowledge-proposal-reads.js +14 -0
- package/node_modules/@oxygen/shared/dist/contracts/knowledge-sync.js +1 -1
- package/node_modules/@oxygen/shared/dist/contracts/limits-read.js +60 -7
- package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +9 -1
- package/node_modules/@oxygen/shared/dist/contracts/operations.js +13 -5
- package/node_modules/@oxygen/shared/dist/contracts/publishing-reads.js +1 -1
- package/node_modules/@oxygen/shared/dist/contracts/teammates.d.ts +16 -0
- package/node_modules/@oxygen/shared/dist/contracts/teammates.js +115 -0
- package/node_modules/@oxygen/shared/dist/cost-estimate-view.js +21 -3
- package/node_modules/@oxygen/shared/dist/cost-estimate.d.ts +31 -1
- package/node_modules/@oxygen/shared/dist/cost-estimate.js +56 -15
- package/node_modules/@oxygen/shared/dist/diagnostic-credentials.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-credentials.js +2 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-lock.js +71 -44
- package/node_modules/@oxygen/shared/dist/diagnostic-spool.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-spool.js +348 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage-failure.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage-failure.js +42 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/diagnostic-storage.js +59 -213
- package/node_modules/@oxygen/shared/dist/diagnostics.js +3 -2
- package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +16 -5
- package/node_modules/@oxygen/shared/dist/email-dsn.js +97 -1
- package/node_modules/@oxygen/shared/dist/emailguard-placement-probe.js +5 -0
- package/node_modules/@oxygen/shared/dist/enrichment-intents.js +4 -5
- package/node_modules/@oxygen/shared/dist/file-import.js +5 -2
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +30 -0
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +20 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +6 -2
- package/node_modules/@oxygen/shared/dist/knowledge-seed-content.d.ts +17 -7
- package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +81 -17
- package/node_modules/@oxygen/shared/dist/langfuse.js +8 -4
- package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/mailbox-import.d.ts +6 -3
- package/node_modules/@oxygen/shared/dist/mailbox-import.js +9 -9
- package/node_modules/@oxygen/shared/dist/mcp-server-info.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/mcp-server-info.js +10 -0
- package/node_modules/@oxygen/shared/dist/object-storage.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +14 -0
- package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/plan-limits-view.js +17 -1
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +1 -1
- package/node_modules/@oxygen/shared/dist/provider-selection.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/provider-selection.js +3 -0
- package/node_modules/@oxygen/shared/dist/repricing.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/repricing.js +2 -2
- package/node_modules/@oxygen/shared/dist/schedule-label.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/schedule-label.js +12 -0
- package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/sequence-failures.js +20 -6
- package/node_modules/@oxygen/shared/dist/teammate-groups.d.ts +55 -0
- package/node_modules/@oxygen/shared/dist/teammate-groups.js +74 -0
- package/node_modules/@oxygen/shared/dist/teammate-messages.d.ts +52 -0
- package/node_modules/@oxygen/shared/dist/teammate-messages.js +88 -0
- package/node_modules/@oxygen/shared/dist/teammate-notes.d.ts +58 -0
- package/node_modules/@oxygen/shared/dist/teammate-notes.js +73 -0
- package/node_modules/@oxygen/shared/dist/teammate-routines.d.ts +95 -0
- package/node_modules/@oxygen/shared/dist/teammate-routines.js +266 -0
- package/node_modules/@oxygen/shared/dist/teammate-tasks.d.ts +56 -0
- package/node_modules/@oxygen/shared/dist/teammate-tasks.js +114 -0
- package/node_modules/@oxygen/shared/dist/teammates.d.ts +78 -0
- package/node_modules/@oxygen/shared/dist/teammates.js +140 -0
- package/node_modules/@oxygen/shared/dist/user-capability-routing.js +120 -0
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/visual-render.d.ts +9 -1
- package/node_modules/@oxygen/shared/dist/visual-render.js +12 -5
- package/node_modules/@oxygen/shared/dist/warmup-metrics.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/warmup-metrics.js +44 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1610 @@
|
|
|
1
|
+
import { emitCliFailure, writeJson } from "./cli-output.js";
|
|
2
|
+
import { cliProfileSource } from "./cli-profile-context.js";
|
|
3
|
+
import { parseJsonObject, readNonNegativeInt, readPositiveInt, readRecordString } from "./cli-values.js";
|
|
4
|
+
import { clearCredentials, listCredentialProfiles, loadCredentials, pickProfileNameForIdentity, pickProfileNameForUserSession, resolveActiveProfile, updateActiveOrganizationForProfile } from "./credentials.js";
|
|
5
|
+
import { requestOxygen } from "./http-client.js";
|
|
6
|
+
import { acquireMirrorLock, deletePageFile, emptyMirrorState, findMirrorSlugByPageId, isFileDirty, purgeMirror, resetMirrorForFullResync, quarantineDirtyFile, readMirrorState, releaseMirrorLock, resolveDefaultConfigDir, resolveMirrorDir, writeGeneratedIndexFile, writeGeneratedLogFile, writeMirrorState, writePageFile } from "./knowledge-mirror.js";
|
|
7
|
+
import { waitForCliRun } from "./run-wait.js";
|
|
8
|
+
import { redactTranscriptSecrets } from "./transcript.js";
|
|
9
|
+
import { isRecord, readOption } from "./util.js";
|
|
10
|
+
import { formatCellForDisplay } from "@oxygen/shared/cell-format";
|
|
11
|
+
import { OxygenError, success } from "@oxygen/shared/cli-result";
|
|
12
|
+
import { COLLAB_SUBJECT_KINDS, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, parseSubjectPath, parseSubjectRef } from "@oxygen/shared/collab";
|
|
13
|
+
import { tableRunSummary } from "@oxygen/shared/table-run-summary";
|
|
14
|
+
import { describeWorkflowStatusChange, parseWorkflowStatusChange } from "@oxygen/shared/workflow-status-change";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { basename, resolve } from "node:path";
|
|
18
|
+
import { stdout as output } from "node:process";
|
|
19
|
+
// --- Collaboration: comments + approvals (Control layer) ---------------------
|
|
20
|
+
//
|
|
21
|
+
// EVERY enumeration below is mapped over a shared total map in
|
|
22
|
+
// @oxygen/shared/collab.ts — never typed out here. A blind-user eval found
|
|
23
|
+
// `tags --help` advertising 7 of the 13 taggable kinds because the prose sat
|
|
24
|
+
// beside a hand-maintained marker map and drifted; six kinds were invisible
|
|
25
|
+
// from the one place a user looks. The collab maps are total over their key
|
|
26
|
+
// type, so adding a subject kind or a gate is a compile error until it is
|
|
27
|
+
// glossed, and the moment it compiles it is already in this help text.
|
|
28
|
+
/** `--on` / `--path` help, shared by every command that addresses a subject. */
|
|
29
|
+
export const COLLAB_SUBJECT_REF_HELP = `Subject as "<kind>:<id>" — e.g. sequence:9f1c2b7e. Kinds: ${COLLAB_SUBJECT_KINDS.join(", ")}.`;
|
|
30
|
+
export const COLLAB_SUBJECT_PATH_HELP = `Narrow to a sub-object: ${SUBJECT_PATH_FORMS_PROSE}.`;
|
|
31
|
+
/**
|
|
32
|
+
* Both group descriptions end with this, because a surface that is one release
|
|
33
|
+
* old should say so where a user meets it. Beta here means exactly what it means
|
|
34
|
+
* for Agents, the Knowledge Graph, and Recipes: WEB NAV VISIBILITY. Nothing
|
|
35
|
+
* about the gate, the client role, or credit rules is softened by the toggle,
|
|
36
|
+
* and this command group is reachable at either setting — which is what keeps a
|
|
37
|
+
* gate armed from the terminal answerable from the terminal.
|
|
38
|
+
*/
|
|
39
|
+
export const COLLAB_BETA_NOTE = "BETA: the /collab entry appears in the web sidebar once a workspace enables Settings -> Beta features; "
|
|
40
|
+
+ "these commands, the gate, and the client role behave identically either way.";
|
|
41
|
+
/**
|
|
42
|
+
* Parse `--on <kind>:<id>`. Client-side so a typo costs no round trip and the
|
|
43
|
+
* error can list every legal kind — `parseSubjectRef` already throws a typed
|
|
44
|
+
* `invalid_subject_ref` (exit 2) carrying `details.valid_kinds`.
|
|
45
|
+
*/
|
|
46
|
+
export function requireCollabSubject(value) {
|
|
47
|
+
const raw = readOption(value);
|
|
48
|
+
if (!raw) {
|
|
49
|
+
throw new OxygenError("invalid_subject_ref", `--on is required. ${COLLAB_SUBJECT_REF_HELP}`, {
|
|
50
|
+
details: { valid_kinds: [...COLLAB_SUBJECT_KINDS] },
|
|
51
|
+
exitCode: 2,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return parseSubjectRef(raw);
|
|
55
|
+
}
|
|
56
|
+
/** Validate and canonicalize `--path`; undefined when the flag was not passed. */
|
|
57
|
+
export function readCollabPath(value) {
|
|
58
|
+
const raw = readOption(value);
|
|
59
|
+
return raw ? formatSubjectPath(parseSubjectPath(raw)) : undefined;
|
|
60
|
+
}
|
|
61
|
+
// One cadence vocabulary across every scheduled surface (column refresh, table
|
|
62
|
+
// feed): `translateScheduleEvery` in apps/web resolves all four forms, so this
|
|
63
|
+
// string is the single place the CLI spells them out.
|
|
64
|
+
export const CADENCE_FLAG_DESCRIPTION = "Shorthand schedule: hourly, every_6h, daily@<hour>, or weekly@<day><hour> (e.g. weekly@mon9). Hours 0-23 UTC.";
|
|
65
|
+
const OXYGEN_WORDMARK = [
|
|
66
|
+
" ___ __ __ __ __ ___ _____ _ _",
|
|
67
|
+
" / _ \\ \\ \\/ / \\ \\ / / / __|| ____| \\ | |",
|
|
68
|
+
" | | | | \\ / \\ V / | | _| _| | \\| |",
|
|
69
|
+
" | |_| | / \\ | | | |_| | |___| |\\ |",
|
|
70
|
+
" \\___/ /_/\\_\\ |_| \\____|_____|_| \\_|",
|
|
71
|
+
].join("\n");
|
|
72
|
+
const TABLE_ACTION_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS = 600;
|
|
73
|
+
const TABLE_ACTION_RUN_WAIT_DEFAULT_INTERVAL_SECONDS = 5;
|
|
74
|
+
export const TABLE_INGESTION_WAIT_DEFAULT_TIMEOUT_SECONDS = 600;
|
|
75
|
+
export const TABLE_INGESTION_WAIT_DEFAULT_INTERVAL_SECONDS = 5;
|
|
76
|
+
// A disabled workflow is a customer's automation at zero, and `status:
|
|
77
|
+
// "disabled"` alone never said who did that or why (OXY-4124). Mirror the
|
|
78
|
+
// recorded transition as one stderr line per disabled workflow, so `workflows
|
|
79
|
+
// list` / `workflows get` answer "who turned this off?" in the terminal without
|
|
80
|
+
// polluting the machine-read stdout envelope (which carries `statusChange` in
|
|
81
|
+
// full). Sourced from the same shared sentence the web banner renders.
|
|
82
|
+
export function formatDisabledWorkflowNotices(data) {
|
|
83
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
84
|
+
return [];
|
|
85
|
+
const block = data;
|
|
86
|
+
const candidates = Array.isArray(block.workflows)
|
|
87
|
+
? block.workflows
|
|
88
|
+
: [block.workflow];
|
|
89
|
+
const notices = [];
|
|
90
|
+
for (const candidate of candidates) {
|
|
91
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
|
|
92
|
+
continue;
|
|
93
|
+
const workflow = candidate;
|
|
94
|
+
if (workflow.status !== "disabled")
|
|
95
|
+
continue;
|
|
96
|
+
const slug = typeof workflow.slug === "string" ? workflow.slug : "workflow";
|
|
97
|
+
const change = parseWorkflowStatusChange(workflow.statusChange);
|
|
98
|
+
// A workflow disabled before the record shipped has no actor to name. Say
|
|
99
|
+
// that plainly rather than inventing one.
|
|
100
|
+
notices.push(change
|
|
101
|
+
? `${slug}: ${describeWorkflowStatusChange(change)}`
|
|
102
|
+
: `${slug}: disabled — no actor was recorded (disabled before Oxygen tracked this).`);
|
|
103
|
+
}
|
|
104
|
+
return notices;
|
|
105
|
+
}
|
|
106
|
+
// Discovery lists are windowed by default (200 rows, max 1000) so the cost of
|
|
107
|
+
// asking "what is in this workspace?" does not scale with how much the customer
|
|
108
|
+
// has built. A window nobody can see is indistinguishable from a complete
|
|
109
|
+
// answer, so say it on stderr — same treatment the observability console's
|
|
110
|
+
// per-source cap already gets. Machine-read stdout carries `capped`, `returned`
|
|
111
|
+
// and `limit` either way.
|
|
112
|
+
export function writeListCapNotice(data, noun, hint) {
|
|
113
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
114
|
+
return;
|
|
115
|
+
const record = data;
|
|
116
|
+
if (record.capped !== true)
|
|
117
|
+
return;
|
|
118
|
+
const returned = typeof record.returned === "number" ? record.returned : null;
|
|
119
|
+
const total = typeof record.total === "number" ? ` of ${record.total}` : "";
|
|
120
|
+
process.stderr.write(`note: showing ${returned === null ? "a window of" : returned}${total} ${noun}; more exist — raise --limit (max 1000) or pass --all${hint ? `; ${hint}` : ""}.\n`);
|
|
121
|
+
}
|
|
122
|
+
export function parseJsonArray(value) {
|
|
123
|
+
let parsed;
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(value);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
throw new OxygenError("invalid_json", "Input must be valid JSON.", {
|
|
129
|
+
details: { reason: error instanceof Error ? error.message : "unknown" },
|
|
130
|
+
exitCode: 1,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (!Array.isArray(parsed)) {
|
|
134
|
+
throw new OxygenError("invalid_json", "Input JSON must be an array.", {
|
|
135
|
+
exitCode: 1,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return parsed;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Parse a JSON command-input value, mapping `JSON.parse` syntax failures to a
|
|
142
|
+
* typed `invalid_json` envelope. A raw `JSON.parse` throw is an ordinary `Error`,
|
|
143
|
+
* which `@oxygen/shared`'s `toFailure` flattens to a machine-hostile
|
|
144
|
+
* `unexpected_error`; routing through here keeps malformed user JSON actionable.
|
|
145
|
+
* Shape validation, where a caller needs it, stays with the caller. `inputName`
|
|
146
|
+
* is the user-facing flag or source label, e.g. "--steps-file".
|
|
147
|
+
*/
|
|
148
|
+
export function parseJsonValue(value, inputName) {
|
|
149
|
+
try {
|
|
150
|
+
return JSON.parse(value);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
throw new OxygenError("invalid_json", `${inputName} must be valid JSON.`, {
|
|
154
|
+
details: { reason: error instanceof Error ? error.message : "unknown" },
|
|
155
|
+
exitCode: 1,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Read a local file and parse it as JSON command input, surfacing syntax
|
|
161
|
+
* failures as a typed `invalid_json` envelope tagged with `inputName`. File-read
|
|
162
|
+
* errors (missing/unreadable path) propagate unchanged — they are not JSON.
|
|
163
|
+
*/
|
|
164
|
+
export function readJsonFileValue(path, inputName) {
|
|
165
|
+
return parseJsonValue(readFileSync(path, "utf8"), inputName);
|
|
166
|
+
}
|
|
167
|
+
export function readFileIfPresent(value) {
|
|
168
|
+
// Help text across these options promises "JSON or @file/path"; a leading
|
|
169
|
+
// `@` names a file too (2026-09-25 blind eval: `--filters-json @f.json` was
|
|
170
|
+
// parsed as JSON). An `@…` value that names no file stays literal text.
|
|
171
|
+
if (value.startsWith("@") && value.length > 1) {
|
|
172
|
+
const fromAt = readFileIfPresent(value.slice(1));
|
|
173
|
+
if (fromAt !== value.slice(1))
|
|
174
|
+
return fromAt;
|
|
175
|
+
}
|
|
176
|
+
const candidate = resolve(value);
|
|
177
|
+
try {
|
|
178
|
+
return readFileSync(candidate, "utf8");
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
const code = error && typeof error === "object" && "code" in error
|
|
182
|
+
? String(error.code)
|
|
183
|
+
: "";
|
|
184
|
+
if (code === "ENOENT" || code === "ENAMETOOLONG" || code === "ENOTDIR")
|
|
185
|
+
return value;
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export function readPublishingPostText(options, required) {
|
|
190
|
+
const inlineText = readOption(options.text);
|
|
191
|
+
const textFile = readOption(options.textFile);
|
|
192
|
+
if (inlineText && textFile) {
|
|
193
|
+
throw new OxygenError("conflicting_flags", "Pass either --text or --text-file, not both.", { exitCode: 1 });
|
|
194
|
+
}
|
|
195
|
+
const text = textFile ? readPublishingTextFile(textFile) : inlineText;
|
|
196
|
+
if (text?.trim())
|
|
197
|
+
return text;
|
|
198
|
+
if (!required)
|
|
199
|
+
return null;
|
|
200
|
+
throw new OxygenError("invalid_request", "Pass --text or --text-file.", { exitCode: 1 });
|
|
201
|
+
}
|
|
202
|
+
// Read a file-backed flag, sanitizing the OS error so a missing/unreadable file
|
|
203
|
+
// reports only the basename the user passed — never the resolved absolute filesystem
|
|
204
|
+
// path Node's ENOENT would otherwise leak into the CLI error output. `flag` names the
|
|
205
|
+
// option in the error so --source-file doesn't report itself as --text-file.
|
|
206
|
+
export function readPublishingTextFile(textFile, flag = "--text-file") {
|
|
207
|
+
try {
|
|
208
|
+
return readFileSync(resolve(textFile), "utf8").trimEnd();
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
throw new OxygenError("text_file_unreadable", `Couldn't read ${flag} '${basename(textFile)}'. Check the path exists and is readable.`, { exitCode: 1 });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
export function splitCommaList(value) {
|
|
215
|
+
const raw = readOption(value);
|
|
216
|
+
if (!raw)
|
|
217
|
+
return [];
|
|
218
|
+
return raw.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
219
|
+
}
|
|
220
|
+
export function buildCrmSignalRecordBody(options) {
|
|
221
|
+
const event = readOption(options.event);
|
|
222
|
+
if (!event) {
|
|
223
|
+
throw new OxygenError("invalid_request", "--event is required.", { exitCode: 1 });
|
|
224
|
+
}
|
|
225
|
+
const sessionId = readOption(options.sessionId);
|
|
226
|
+
// The redelivery/idempotency guard: an explicit id, else derived from the
|
|
227
|
+
// provider session id so a repeat reveal still dedupes.
|
|
228
|
+
const externalEventId = readOption(options.externalEventId) ?? (sessionId ? `signal:${sessionId}` : null);
|
|
229
|
+
if (!externalEventId) {
|
|
230
|
+
throw new OxygenError("invalid_request", "--external-event-id is required (or pass --session-id to derive it).", { exitCode: 1 });
|
|
231
|
+
}
|
|
232
|
+
const payload = {};
|
|
233
|
+
const email = readOption(options.email);
|
|
234
|
+
if (email)
|
|
235
|
+
payload.email = email;
|
|
236
|
+
const linkedinUrl = readOption(options.linkedinUrl);
|
|
237
|
+
if (linkedinUrl)
|
|
238
|
+
payload.linkedin_url = linkedinUrl;
|
|
239
|
+
const companyDomain = readOption(options.companyDomain);
|
|
240
|
+
if (companyDomain)
|
|
241
|
+
payload.company_domain = companyDomain;
|
|
242
|
+
const companyName = readOption(options.companyName);
|
|
243
|
+
if (companyName)
|
|
244
|
+
payload.company_name = companyName;
|
|
245
|
+
const firstName = readOption(options.firstName);
|
|
246
|
+
if (firstName)
|
|
247
|
+
payload.first_name = firstName;
|
|
248
|
+
const lastName = readOption(options.lastName);
|
|
249
|
+
if (lastName)
|
|
250
|
+
payload.last_name = lastName;
|
|
251
|
+
const pageUrl = readOption(options.pageUrl);
|
|
252
|
+
if (pageUrl)
|
|
253
|
+
payload.page_url = pageUrl;
|
|
254
|
+
if (sessionId)
|
|
255
|
+
payload.session_id = sessionId;
|
|
256
|
+
// An explicit --payload-json wins over the convenience flags above.
|
|
257
|
+
if (options.payloadJson)
|
|
258
|
+
Object.assign(payload, parseJsonObject(options.payloadJson));
|
|
259
|
+
return { event, external_event_id: externalEventId, payload };
|
|
260
|
+
}
|
|
261
|
+
export function buildCrmLeadsTodayPath(options) {
|
|
262
|
+
const params = new URLSearchParams();
|
|
263
|
+
if (options.limit)
|
|
264
|
+
params.set("limit", options.limit);
|
|
265
|
+
if (options.withinDays)
|
|
266
|
+
params.set("within_days", options.withinDays);
|
|
267
|
+
const query = params.toString();
|
|
268
|
+
return query ? `/api/cli/crm/signals/leads-today?${query}` : "/api/cli/crm/signals/leads-today";
|
|
269
|
+
}
|
|
270
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
271
|
+
export function isUuid(value) {
|
|
272
|
+
return UUID_PATTERN.test(value);
|
|
273
|
+
}
|
|
274
|
+
export function tableWebhookDeliveriesPath(options) {
|
|
275
|
+
const params = new URLSearchParams();
|
|
276
|
+
const endpointId = readOption(options.endpointId);
|
|
277
|
+
const table = readOption(options.table);
|
|
278
|
+
const status = readOption(options.status);
|
|
279
|
+
const autoRunStatus = readOption(options.autoRunStatus);
|
|
280
|
+
const limit = readPositiveInt(options.limit);
|
|
281
|
+
if (endpointId)
|
|
282
|
+
params.set("endpoint_id", endpointId);
|
|
283
|
+
if (table)
|
|
284
|
+
params.set("table", table);
|
|
285
|
+
if (status)
|
|
286
|
+
params.set("status", status);
|
|
287
|
+
if (autoRunStatus)
|
|
288
|
+
params.set("auto_run_status", autoRunStatus);
|
|
289
|
+
if (limit)
|
|
290
|
+
params.set("limit", String(limit));
|
|
291
|
+
const query = params.toString();
|
|
292
|
+
return `/api/cli/tables/webhooks/deliveries${query ? `?${query}` : ""}`;
|
|
293
|
+
}
|
|
294
|
+
export function tableRunsListPath(options) {
|
|
295
|
+
const table = readOption(options.table);
|
|
296
|
+
if (!table) {
|
|
297
|
+
throw new OxygenError("invalid_table_run", "Pass --table <table>.", {
|
|
298
|
+
exitCode: 1,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
const query = new URLSearchParams({ table });
|
|
302
|
+
const status = readOption(options.status);
|
|
303
|
+
const limit = readPositiveInt(options.limit);
|
|
304
|
+
if (status)
|
|
305
|
+
query.set("status", status);
|
|
306
|
+
if (limit)
|
|
307
|
+
query.set("limit", String(limit));
|
|
308
|
+
if (options.traceId)
|
|
309
|
+
query.set("trace_id", options.traceId);
|
|
310
|
+
return `/api/cli/table-action-runs?${query.toString()}`;
|
|
311
|
+
}
|
|
312
|
+
// Range flags accept "20-200" -> { min, max }, "500+" -> { min }, "-50" -> { max },
|
|
313
|
+
// and a bare "200" -> { min, max } (exact value).
|
|
314
|
+
export function readRangeOption(value, flag) {
|
|
315
|
+
const raw = readOption(value);
|
|
316
|
+
if (!raw)
|
|
317
|
+
return null;
|
|
318
|
+
if (/^\d+\+$/.test(raw)) {
|
|
319
|
+
return { min: readRangeNumber(raw.slice(0, -1), flag, raw) };
|
|
320
|
+
}
|
|
321
|
+
if (/^-\d+$/.test(raw)) {
|
|
322
|
+
return { max: readRangeNumber(raw.slice(1), flag, raw) };
|
|
323
|
+
}
|
|
324
|
+
if (/^\d+-\d+$/.test(raw)) {
|
|
325
|
+
const [minRaw, maxRaw] = raw.split("-");
|
|
326
|
+
const min = readRangeNumber(minRaw, flag, raw);
|
|
327
|
+
const max = readRangeNumber(maxRaw, flag, raw);
|
|
328
|
+
if (min > max) {
|
|
329
|
+
throw new OxygenError("invalid_range", `${flag} min must not exceed max.`, {
|
|
330
|
+
details: { flag, value: raw },
|
|
331
|
+
exitCode: 1,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return { min, max };
|
|
335
|
+
}
|
|
336
|
+
if (/^\d+$/.test(raw)) {
|
|
337
|
+
const bound = readRangeNumber(raw, flag, raw);
|
|
338
|
+
return { min: bound, max: bound };
|
|
339
|
+
}
|
|
340
|
+
throw new OxygenError("invalid_range", `${flag} expects a range like 20-200, 500+, or -50.`, {
|
|
341
|
+
details: { flag, value: raw },
|
|
342
|
+
exitCode: 1,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
function readRangeNumber(value, flag, raw) {
|
|
346
|
+
const parsed = Number(value);
|
|
347
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
348
|
+
throw new OxygenError("invalid_range", `${flag} bounds must be non-negative integers.`, {
|
|
349
|
+
details: { flag, value: raw },
|
|
350
|
+
exitCode: 1,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return parsed;
|
|
354
|
+
}
|
|
355
|
+
// The id a `search plan` envelope prints (search_plan_…, company_search_plan_…,
|
|
356
|
+
// signal_search_plan_…). `--plan` takes the plan itself, not its id; a blind
|
|
357
|
+
// acceptance run on 2026-10-04 passed the id and got only "Input must be valid
|
|
358
|
+
// JSON" with no way forward.
|
|
359
|
+
const SEARCH_PLAN_ID_PATTERN = /^[a-z_]*search_plan_[A-Za-z0-9]+$/;
|
|
360
|
+
export function readSearchPlanJson(value) {
|
|
361
|
+
const raw = readFileIfPresent(value);
|
|
362
|
+
if (raw === value && SEARCH_PLAN_ID_PATTERN.test(value.trim())) {
|
|
363
|
+
throw new OxygenError("invalid_json", `--plan takes the plan JSON returned by the plan command, not its id ${value.trim()}.`, {
|
|
364
|
+
details: {
|
|
365
|
+
plan_id: value.trim(),
|
|
366
|
+
next_step: "Re-run the plan command with --json and save its output to a file (for example plan.json), then pass --plan plan.json.",
|
|
367
|
+
},
|
|
368
|
+
exitCode: 1,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
const parsed = parseJsonObject(raw);
|
|
372
|
+
const data = parsed.data;
|
|
373
|
+
return data && typeof data === "object" && !Array.isArray(data)
|
|
374
|
+
? data
|
|
375
|
+
: parsed;
|
|
376
|
+
}
|
|
377
|
+
export function readFilterSelectionOption(value) {
|
|
378
|
+
const filters = readFilterJsonOption(value);
|
|
379
|
+
return filters ? { mode: "filter", filters } : undefined;
|
|
380
|
+
}
|
|
381
|
+
export function readFilterJsonOption(value) {
|
|
382
|
+
const raw = readOption(value);
|
|
383
|
+
if (!raw)
|
|
384
|
+
return undefined;
|
|
385
|
+
let parsed;
|
|
386
|
+
try {
|
|
387
|
+
parsed = JSON.parse(raw);
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
throw new OxygenError("invalid_filter", "--filter-json must be valid JSON.", {
|
|
391
|
+
details: { reason: error instanceof Error ? error.message : String(error) },
|
|
392
|
+
exitCode: 1,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
const filters = Array.isArray(parsed) ? parsed : [parsed];
|
|
396
|
+
if (filters.length === 0
|
|
397
|
+
|| filters.some((entry) => !entry || typeof entry !== "object" || Array.isArray(entry))) {
|
|
398
|
+
throw new OxygenError("invalid_filter", "--filter-json must be a filter object or non-empty array of filter objects.", {
|
|
399
|
+
exitCode: 1,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
return filters;
|
|
403
|
+
}
|
|
404
|
+
export function readFormulaValuesOption(value) {
|
|
405
|
+
const raw = readOption(value);
|
|
406
|
+
if (raw === null)
|
|
407
|
+
return null;
|
|
408
|
+
if (raw !== "materialized") {
|
|
409
|
+
throw new OxygenError("invalid_filter", "--formula-values must be 'materialized' when provided.", { details: { formula_values: raw }, exitCode: 1 });
|
|
410
|
+
}
|
|
411
|
+
return raw;
|
|
412
|
+
}
|
|
413
|
+
export function buildGetSurfaceRunWaitConfig(params) {
|
|
414
|
+
return {
|
|
415
|
+
runId: params.runId,
|
|
416
|
+
requestedTimeoutSeconds: params.requestedTimeoutSeconds,
|
|
417
|
+
requestedIntervalSeconds: params.requestedIntervalSeconds,
|
|
418
|
+
defaultTimeoutSeconds: params.defaultTimeoutSeconds,
|
|
419
|
+
defaultIntervalSeconds: params.defaultIntervalSeconds,
|
|
420
|
+
retryTransientNetworkErrors: params.retryTransientNetworkErrors === true,
|
|
421
|
+
fetchRun: (signal) => requestOxygen(`${params.endpoint}/${encodeURIComponent(params.runId)}${params.view ? `?view=${params.view}` : ""}`, signal ? { signal } : {}),
|
|
422
|
+
isTerminal: params.isTerminal,
|
|
423
|
+
shapeTerminal: (run, status, polls, elapsedMs) => {
|
|
424
|
+
const webUrl = readRecordString(run, "web_url");
|
|
425
|
+
return {
|
|
426
|
+
[params.runKey]: params.view === "summary" ? tableRunSummary(run) : run,
|
|
427
|
+
[params.runIdKey]: readRecordString(run, "id") ?? params.runId,
|
|
428
|
+
status,
|
|
429
|
+
terminal: true,
|
|
430
|
+
polls,
|
|
431
|
+
elapsedMs,
|
|
432
|
+
...(webUrl ? { web_url: webUrl } : {}),
|
|
433
|
+
};
|
|
434
|
+
},
|
|
435
|
+
timeoutCode: params.timeoutCode,
|
|
436
|
+
timeoutMessage: params.timeoutMessage,
|
|
437
|
+
timeoutDetailIdKey: params.timeoutDetailIdKey,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
export function waitForTableActionRun(runId, options) {
|
|
441
|
+
return waitForCliRun(buildGetSurfaceRunWaitConfig({
|
|
442
|
+
runId,
|
|
443
|
+
endpoint: "/api/cli/table-action-runs",
|
|
444
|
+
view: options.full ? "full" : "summary",
|
|
445
|
+
requestedTimeoutSeconds: options.timeoutSeconds,
|
|
446
|
+
requestedIntervalSeconds: options.intervalSeconds,
|
|
447
|
+
defaultTimeoutSeconds: TABLE_ACTION_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS,
|
|
448
|
+
defaultIntervalSeconds: TABLE_ACTION_RUN_WAIT_DEFAULT_INTERVAL_SECONDS,
|
|
449
|
+
isTerminal: isTerminalTableActionRunStatus,
|
|
450
|
+
runKey: "actionRun",
|
|
451
|
+
runIdKey: "actionRunId",
|
|
452
|
+
timeoutCode: "table_action_run_wait_timeout",
|
|
453
|
+
timeoutMessage: "Timed out waiting for table action run to finish.",
|
|
454
|
+
timeoutDetailIdKey: "action_run_id",
|
|
455
|
+
}));
|
|
456
|
+
}
|
|
457
|
+
export function normalizeExportRowsFormat(value) {
|
|
458
|
+
const normalized = value?.trim().toLowerCase() || "json";
|
|
459
|
+
if (normalized === "json"
|
|
460
|
+
|| normalized === "jsonl"
|
|
461
|
+
|| normalized === "csv"
|
|
462
|
+
|| normalized === "table")
|
|
463
|
+
return normalized;
|
|
464
|
+
throw new OxygenError("invalid_format", "Export format must be json, jsonl, csv, or table.", {
|
|
465
|
+
details: { format: value },
|
|
466
|
+
exitCode: 1,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
export function formatRows(rows, format, columns) {
|
|
470
|
+
if (format === "json") {
|
|
471
|
+
return { content: `${JSON.stringify(rows, null, 2)}\n`, rescuedCount: 0 };
|
|
472
|
+
}
|
|
473
|
+
if (format === "jsonl") {
|
|
474
|
+
return {
|
|
475
|
+
content: `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`,
|
|
476
|
+
rescuedCount: 0,
|
|
477
|
+
};
|
|
478
|
+
} // skipcq: JS-0246
|
|
479
|
+
const keys = [
|
|
480
|
+
"_row_id",
|
|
481
|
+
"_created_at",
|
|
482
|
+
"_updated_at",
|
|
483
|
+
...(columns?.map((column) => column.key) ?? []),
|
|
484
|
+
...rows.flatMap((row) => Object.keys(row)),
|
|
485
|
+
].filter((key, index, all) => all.indexOf(key) === index && rows.some((row) => key in row));
|
|
486
|
+
if (format === "csv") {
|
|
487
|
+
const csvBody = [
|
|
488
|
+
keys.map(escapeCsvField).join(","),
|
|
489
|
+
...rows.map((row) => keys.map((key) => escapeCsvField(row[key])).join(",")),
|
|
490
|
+
].join("\n");
|
|
491
|
+
return {
|
|
492
|
+
content: `${csvBody}\n`,
|
|
493
|
+
rescuedCount: 0,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
// "table": render with type-aware formatting and a Markdown-style frame.
|
|
497
|
+
const columnByKey = new Map(columns?.map((column) => [column.key, column]) ?? []);
|
|
498
|
+
let rescuedCount = 0;
|
|
499
|
+
const headers = keys.map((key) => columnByKey.get(key)?.label ?? key);
|
|
500
|
+
const formattedRows = rows.map((row) => keys.map((key) => {
|
|
501
|
+
const column = columnByKey.get(key) ?? null;
|
|
502
|
+
const formatted = formatCellForDisplay(row[key], column, {
|
|
503
|
+
surface: "cli",
|
|
504
|
+
onRescued: () => {
|
|
505
|
+
rescuedCount += 1;
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
// Pipe and any line-break char are the row/cell delimiters of the
|
|
509
|
+
// Markdown frame — raw values containing them would corrupt the layout.
|
|
510
|
+
// Match on the line-break class (not just `\r?\n`) so a standalone `\r`
|
|
511
|
+
// doesn't slip through and split the row visually.
|
|
512
|
+
return formatted.replace(/[\r\n]+/g, " ↵ ").replace(/\|/g, "\\|");
|
|
513
|
+
}));
|
|
514
|
+
const widths = headers.map((header, columnIndex) => {
|
|
515
|
+
let max = header.length;
|
|
516
|
+
for (const row of formattedRows) {
|
|
517
|
+
const cell = row[columnIndex] ?? "";
|
|
518
|
+
if (cell.length > max)
|
|
519
|
+
max = cell.length;
|
|
520
|
+
}
|
|
521
|
+
return Math.min(max, 60);
|
|
522
|
+
});
|
|
523
|
+
const renderRow = (cells) => {
|
|
524
|
+
const padded = cells.map((cell, i) => {
|
|
525
|
+
const width = widths[i] ?? 0;
|
|
526
|
+
return clipCell(cell, width).padEnd(width);
|
|
527
|
+
});
|
|
528
|
+
return `| ${padded.join(" | ")} |`;
|
|
529
|
+
};
|
|
530
|
+
const separator = `|${widths.map((width) => "-".repeat(width + 2)).join("|")}|`;
|
|
531
|
+
const lines = [renderRow(headers), separator, ...formattedRows.map(renderRow)];
|
|
532
|
+
if (rescuedCount > 0) {
|
|
533
|
+
lines.push("");
|
|
534
|
+
lines.push(`# Note: reformatted ${rescuedCount} cell${rescuedCount === 1 ? "" : "s"} that look numeric in text columns. Run \`oxygen columns retype <table> <column> --data-type numeric\` to make this permanent.`);
|
|
535
|
+
}
|
|
536
|
+
return { content: `${lines.join("\n")}\n`, rescuedCount };
|
|
537
|
+
}
|
|
538
|
+
export function clipCell(value, width) {
|
|
539
|
+
if (value.length <= width)
|
|
540
|
+
return value;
|
|
541
|
+
if (width <= 1)
|
|
542
|
+
return value.slice(0, width);
|
|
543
|
+
return `${value.slice(0, width - 1)}…`;
|
|
544
|
+
}
|
|
545
|
+
function escapeCsvField(value) {
|
|
546
|
+
const text = value === null || value === undefined
|
|
547
|
+
? ""
|
|
548
|
+
: typeof value === "object"
|
|
549
|
+
? JSON.stringify(value)
|
|
550
|
+
: String(value);
|
|
551
|
+
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, "\"\"")}"` : text;
|
|
552
|
+
}
|
|
553
|
+
export function readCount(value) {
|
|
554
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Content type from the file extension — a hint only. It picks the presigned
|
|
558
|
+
* URL's type and the stored key's extension; the server sniffs the real bytes
|
|
559
|
+
* before it will publish a URL, so a wrong guess here fails loudly rather than
|
|
560
|
+
* hosting something mislabelled.
|
|
561
|
+
*/
|
|
562
|
+
function imageContentTypeForPath(path) {
|
|
563
|
+
const lower = path.toLowerCase();
|
|
564
|
+
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg"))
|
|
565
|
+
return "image/jpeg";
|
|
566
|
+
if (lower.endsWith(".webp"))
|
|
567
|
+
return "image/webp";
|
|
568
|
+
if (lower.endsWith(".png"))
|
|
569
|
+
return "image/png";
|
|
570
|
+
throw new OxygenError("unsupported_image", "Profile pictures must be a .png, .jpg, or .webp file.", { details: { path }, exitCode: 2 });
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Hops one and two of the avatar upload: presign, then PUT the raw bytes straight
|
|
574
|
+
* to Oxygen object storage. Returns the storage key the caller confirms with.
|
|
575
|
+
*
|
|
576
|
+
* Shared because two commands stage the same bytes in the same bucket and then
|
|
577
|
+
* diverge: `managed-inboxes upload-avatar` confirms against the avatars route to
|
|
578
|
+
* get a public URL to paste into an order, while `senders profiles set-photo`
|
|
579
|
+
* hands the key to the profile writer as `avatar_upload_key`, which re-checks org
|
|
580
|
+
* ownership and re-sniffs the bytes itself. Copying the two hops into the second
|
|
581
|
+
* caller would have duplicated the abort timeout and the failure taxonomy, and
|
|
582
|
+
* the copy is exactly where they drift.
|
|
583
|
+
*/
|
|
584
|
+
export async function putAvatarBytesToStorage(path) {
|
|
585
|
+
const resolved = resolve(path);
|
|
586
|
+
const bytes = readFileSync(resolved);
|
|
587
|
+
const contentType = imageContentTypeForPath(resolved);
|
|
588
|
+
const ticket = await requestOxygen("/api/cli/managed-inboxes/avatars", {
|
|
589
|
+
method: "POST",
|
|
590
|
+
body: { content_type: contentType, content_length: bytes.byteLength },
|
|
591
|
+
});
|
|
592
|
+
const upload = readRecord(ticket, "upload");
|
|
593
|
+
const uploadUrl = readRecordString(upload, "url");
|
|
594
|
+
const storageKey = readRecordString(ticket, "storage_key");
|
|
595
|
+
if (!uploadUrl || !storageKey) {
|
|
596
|
+
throw new OxygenError("invalid_response", "Oxygen API response is missing the avatar upload URL.", { exitCode: 1 });
|
|
597
|
+
}
|
|
598
|
+
const controller = new AbortController();
|
|
599
|
+
const timer = setTimeout(() => controller.abort(), 120_000);
|
|
600
|
+
let putResponse;
|
|
601
|
+
try {
|
|
602
|
+
putResponse = await fetch(uploadUrl, {
|
|
603
|
+
method: "PUT",
|
|
604
|
+
body: new Uint8Array(bytes),
|
|
605
|
+
headers: { "content-type": contentType },
|
|
606
|
+
signal: controller.signal,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
finally {
|
|
610
|
+
clearTimeout(timer);
|
|
611
|
+
}
|
|
612
|
+
if (!putResponse.ok) {
|
|
613
|
+
throw new OxygenError("avatar_upload_failed", `Uploading the photo to object storage failed (HTTP ${putResponse.status}).`, { details: { status: putResponse.status }, exitCode: 1 });
|
|
614
|
+
}
|
|
615
|
+
return storageKey;
|
|
616
|
+
}
|
|
617
|
+
export function readRecord(value, key) {
|
|
618
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
619
|
+
return null;
|
|
620
|
+
const entry = value[key];
|
|
621
|
+
return isRecord(entry) ? entry : null;
|
|
622
|
+
}
|
|
623
|
+
export function isTerminalTableIngestionStatus(status) {
|
|
624
|
+
return status === "completed"
|
|
625
|
+
|| status === "completed_with_errors"
|
|
626
|
+
|| status === "failed"
|
|
627
|
+
|| status === "canceled";
|
|
628
|
+
}
|
|
629
|
+
function isTerminalTableActionRunStatus(status) {
|
|
630
|
+
return status === "completed"
|
|
631
|
+
|| status === "completed_with_errors"
|
|
632
|
+
|| status === "failed"
|
|
633
|
+
|| status === "canceled";
|
|
634
|
+
}
|
|
635
|
+
export async function resolveActiveProfileWithSource() {
|
|
636
|
+
const resolution = await resolveActiveProfile();
|
|
637
|
+
return { resolution, source: cliProfileSource(resolution.source) };
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Write a completed org selection back onto the stored profile.
|
|
641
|
+
*
|
|
642
|
+
* Shared by `orgs use` and `oxygen login --org` so the two-step (log in, then
|
|
643
|
+
* switch) and the one-step collapse cannot drift apart.
|
|
644
|
+
*
|
|
645
|
+
* Gated on the credential being one that can actually carry a selection: an
|
|
646
|
+
* org-bound key's active workspace is a property of the key, not of the profile,
|
|
647
|
+
* so persisting a different one would only produce a profile that lies about
|
|
648
|
+
* where its commands land.
|
|
649
|
+
*/
|
|
650
|
+
export async function persistOrganizationSelection(input) {
|
|
651
|
+
const { profile, credentials, organization } = input;
|
|
652
|
+
if (!profile || !credentials || !organization)
|
|
653
|
+
return false;
|
|
654
|
+
if (credentials.authKind !== "user_session" && !credentials.token.startsWith("oxy_user_")) {
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
await updateActiveOrganizationForProfile(profile, storedOrganizationFromOption(organization));
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
export function shellQuote(value) {
|
|
661
|
+
if (/^[A-Za-z0-9_.\-:/@%+=]+$/.test(value))
|
|
662
|
+
return value;
|
|
663
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
664
|
+
}
|
|
665
|
+
export async function handleLogoutAction(options) {
|
|
666
|
+
try {
|
|
667
|
+
const clearOptions = {};
|
|
668
|
+
if (options.profile !== undefined)
|
|
669
|
+
clearOptions.profile = options.profile;
|
|
670
|
+
if (options.all !== undefined)
|
|
671
|
+
clearOptions.all = options.all;
|
|
672
|
+
const result = await clearCredentials(process.env, clearOptions);
|
|
673
|
+
if (options.json) {
|
|
674
|
+
writeJson(success("logout", {
|
|
675
|
+
logged_out: true,
|
|
676
|
+
removed_profile: result.removedProfile,
|
|
677
|
+
remaining_profiles: result.remainingProfiles,
|
|
678
|
+
}));
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
process.stdout.write(formatLogoutSuccess(result));
|
|
682
|
+
}
|
|
683
|
+
catch (error) {
|
|
684
|
+
emitCliFailure("logout", error);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
export async function resolveLoginIdentity(token, apiUrl) {
|
|
688
|
+
if (isUserSessionToken(token)) {
|
|
689
|
+
const credentials = {
|
|
690
|
+
token,
|
|
691
|
+
apiUrl,
|
|
692
|
+
authKind: "user_session",
|
|
693
|
+
};
|
|
694
|
+
const orgs = await requestOxygen("/api/cli/orgs", { credentials });
|
|
695
|
+
const activeOrganization = orgs.currentOrganization
|
|
696
|
+
? storedOrganizationFromOption(orgs.currentOrganization)
|
|
697
|
+
: null;
|
|
698
|
+
credentials.activeOrganization = activeOrganization;
|
|
699
|
+
credentials.identity = identityFromUserAndOrganization(orgs.user, activeOrganization);
|
|
700
|
+
return {
|
|
701
|
+
credentials,
|
|
702
|
+
whoami: null,
|
|
703
|
+
user: orgs.user,
|
|
704
|
+
activeOrganization,
|
|
705
|
+
organizations: orgs.organizations,
|
|
706
|
+
selectionRequired: Boolean(orgs.selectionRequired),
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
const credentials = {
|
|
710
|
+
token,
|
|
711
|
+
apiUrl,
|
|
712
|
+
};
|
|
713
|
+
if (token.startsWith("oxy_live_"))
|
|
714
|
+
credentials.authKind = "org_api_key";
|
|
715
|
+
const identity = await requestOxygen("/api/cli/whoami", {
|
|
716
|
+
credentials,
|
|
717
|
+
});
|
|
718
|
+
const activeOrganization = storedOrganizationFromOption(identity.organization);
|
|
719
|
+
credentials.authKind = identity.authType === "user_session" ? "user_session" : "org_api_key";
|
|
720
|
+
credentials.activeOrganization = activeOrganization;
|
|
721
|
+
credentials.identity = identityFromWhoami(identity);
|
|
722
|
+
return {
|
|
723
|
+
credentials,
|
|
724
|
+
whoami: identity,
|
|
725
|
+
user: identity.user,
|
|
726
|
+
activeOrganization,
|
|
727
|
+
organizations: [{ ...identity.organization, selected: true }],
|
|
728
|
+
selectionRequired: false,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
export function pickProfileForLogin(loginIdentity) {
|
|
732
|
+
const apiUrl = loginIdentity.credentials.apiUrl;
|
|
733
|
+
if (loginIdentity.credentials.authKind === "user_session") {
|
|
734
|
+
const seed = loginIdentity.activeOrganization?.slug
|
|
735
|
+
?? loginIdentity.user.email
|
|
736
|
+
?? loginIdentity.user.id;
|
|
737
|
+
return pickProfileNameForUserSession(loginIdentity.user.id, seed, apiUrl);
|
|
738
|
+
}
|
|
739
|
+
const organization = loginIdentity.activeOrganization;
|
|
740
|
+
if (!organization) {
|
|
741
|
+
return pickProfileNameForUserSession(loginIdentity.user.id, loginIdentity.user.email ?? loginIdentity.user.id, apiUrl);
|
|
742
|
+
}
|
|
743
|
+
const seed = organization.slug?.trim() || organization.id;
|
|
744
|
+
return pickProfileNameForIdentity(organization.id, seed, apiUrl);
|
|
745
|
+
}
|
|
746
|
+
export function readEnvProfileName() {
|
|
747
|
+
const value = process.env.OXYGEN_PROFILE?.trim();
|
|
748
|
+
return value ? value : null;
|
|
749
|
+
}
|
|
750
|
+
export function identityFromWhoami(response) {
|
|
751
|
+
return {
|
|
752
|
+
organization: storedOrganizationFromOption(response.organization),
|
|
753
|
+
user: {
|
|
754
|
+
id: response.user.id,
|
|
755
|
+
email: response.user.email ?? null,
|
|
756
|
+
},
|
|
757
|
+
capturedAt: new Date().toISOString(),
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
function identityFromUserAndOrganization(user, organization) {
|
|
761
|
+
return {
|
|
762
|
+
organization,
|
|
763
|
+
user: {
|
|
764
|
+
id: user.id,
|
|
765
|
+
email: user.email ?? null,
|
|
766
|
+
},
|
|
767
|
+
capturedAt: new Date().toISOString(),
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
export function storedOrganizationFromOption(organization) {
|
|
771
|
+
return {
|
|
772
|
+
id: organization.id,
|
|
773
|
+
name: organization.name,
|
|
774
|
+
slug: organization.slug ?? null,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
function isUserSessionToken(token) {
|
|
778
|
+
return token.startsWith("oxy_user_");
|
|
779
|
+
}
|
|
780
|
+
export async function buildPostLoginHint(activeProfile) {
|
|
781
|
+
const plain = output.isTTY !== true || Boolean(process.env.NO_COLOR);
|
|
782
|
+
const styles = ansi(!plain);
|
|
783
|
+
const label = (text) => (plain ? text : styles.dim(text));
|
|
784
|
+
const command = (text) => (plain ? text : styles.bold(text));
|
|
785
|
+
const state = await listCredentialProfiles().catch(() => null);
|
|
786
|
+
const lines = [];
|
|
787
|
+
// The pin hint only makes sense with more than one stored profile.
|
|
788
|
+
if (state && state.profiles.length >= 2) {
|
|
789
|
+
lines.push(` ${label("Pin this terminal:")} ${command(`eval "$(oxygen profiles env ${activeProfile})"`)}`);
|
|
790
|
+
}
|
|
791
|
+
// Mirror every reachable workspace into local markdown so agents can read/grep
|
|
792
|
+
// the wiki offline; --if-stale keeps a large multi-workspace loop cheap.
|
|
793
|
+
lines.push(` ${label("Mirror your wikis:")} ${command("oxygen knowledge sync --all --if-stale")}`);
|
|
794
|
+
return `\n${lines.join("\n")}\n\n`;
|
|
795
|
+
}
|
|
796
|
+
export function formatLoginSuccessForResolved(loginIdentity, profile, options = {}) {
|
|
797
|
+
if (loginIdentity.whoami) {
|
|
798
|
+
return formatLoginSuccess(loginIdentity.whoami, loginIdentity.credentials, profile, options);
|
|
799
|
+
}
|
|
800
|
+
if (loginIdentity.activeOrganization) {
|
|
801
|
+
return formatLoginSuccess({
|
|
802
|
+
user: loginIdentity.user,
|
|
803
|
+
organization: loginIdentity.activeOrganization,
|
|
804
|
+
apiKey: {
|
|
805
|
+
id: "user_session",
|
|
806
|
+
name: "CLI user session",
|
|
807
|
+
tokenPrefix: loginIdentity.credentials.token.slice(0, 17),
|
|
808
|
+
tokenSuffix: loginIdentity.credentials.token.slice(-4),
|
|
809
|
+
kind: "user_session",
|
|
810
|
+
},
|
|
811
|
+
authType: "user_session",
|
|
812
|
+
}, loginIdentity.credentials, profile, options);
|
|
813
|
+
}
|
|
814
|
+
const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
|
|
815
|
+
const email = loginIdentity.user.email ?? loginIdentity.user.id;
|
|
816
|
+
const fingerprint = createHash("sha256")
|
|
817
|
+
.update(`oxygen-cli:${loginIdentity.credentials.token}`)
|
|
818
|
+
.digest("hex");
|
|
819
|
+
const profileLabel = options.renamed
|
|
820
|
+
? `${profile} ${styles.dim("(auto-renamed to avoid collision)")}`
|
|
821
|
+
: profile;
|
|
822
|
+
const rows = [
|
|
823
|
+
["Account", email],
|
|
824
|
+
["Organization", "not selected"],
|
|
825
|
+
["Profile", profileLabel],
|
|
826
|
+
["API", loginIdentity.credentials.apiUrl],
|
|
827
|
+
["Token", `${loginIdentity.credentials.token.slice(0, 17)}...${loginIdentity.credentials.token.slice(-4)}`],
|
|
828
|
+
["Fingerprint", formatFingerprint(fingerprint)],
|
|
829
|
+
];
|
|
830
|
+
const labelWidth = Math.max(...rows.map(([label]) => label.length));
|
|
831
|
+
const wordmark = renderBox([
|
|
832
|
+
"",
|
|
833
|
+
...OXYGEN_WORDMARK.split("\n").map(line => styles.green(line)),
|
|
834
|
+
"",
|
|
835
|
+
]);
|
|
836
|
+
const orgLines = loginIdentity.organizations.length > 0
|
|
837
|
+
? [
|
|
838
|
+
"",
|
|
839
|
+
"Available organizations:",
|
|
840
|
+
...loginIdentity.organizations.map((organization) => ` - ${organization.name}${organization.slug ? ` (${organization.slug})` : ""}`),
|
|
841
|
+
"",
|
|
842
|
+
`Select one: ${styles.bold("oxygen orgs use <organization>")}`,
|
|
843
|
+
]
|
|
844
|
+
: [];
|
|
845
|
+
return [
|
|
846
|
+
"",
|
|
847
|
+
wordmark,
|
|
848
|
+
"",
|
|
849
|
+
`${styles.green("[OK]")} ${styles.bold("CLI connected")}`,
|
|
850
|
+
"",
|
|
851
|
+
...rows.map(([label, value]) => ` ${styles.dim(label.padEnd(labelWidth))} ${value}`),
|
|
852
|
+
...orgLines,
|
|
853
|
+
"",
|
|
854
|
+
...formatAutomaticSkillsInstallStatusLines(options.skillsInstall),
|
|
855
|
+
].join("\n");
|
|
856
|
+
}
|
|
857
|
+
function formatLoginSuccess(identity, credentials, profile, options = {}) {
|
|
858
|
+
const email = identity.user.email ?? identity.user.id;
|
|
859
|
+
const orgName = identity.organization.name || identity.organization.slug || identity.organization.id;
|
|
860
|
+
const orgSlug = identity.organization.slug ?? null;
|
|
861
|
+
const org = orgSlug && orgSlug !== orgName ? `${orgName} (${orgSlug})` : orgName;
|
|
862
|
+
const key = identity.apiKey
|
|
863
|
+
? `${identity.apiKey.tokenPrefix}...${identity.apiKey.tokenSuffix}`
|
|
864
|
+
: "stored";
|
|
865
|
+
const fingerprint = createHash("sha256")
|
|
866
|
+
.update(`oxygen-cli:${credentials.token}`)
|
|
867
|
+
.digest("hex");
|
|
868
|
+
const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
|
|
869
|
+
const profileLabel = options.renamed
|
|
870
|
+
? `${profile} ${styles.dim("(auto-renamed to avoid collision)")}`
|
|
871
|
+
: profile;
|
|
872
|
+
// skipcq: JS-0820 — not a React component; rule misfire on array of tuples
|
|
873
|
+
const rows = [
|
|
874
|
+
["Account", email],
|
|
875
|
+
["Organization", org],
|
|
876
|
+
["Profile", profileLabel],
|
|
877
|
+
["API", credentials.apiUrl],
|
|
878
|
+
["Token", key],
|
|
879
|
+
["Fingerprint", formatFingerprint(fingerprint)],
|
|
880
|
+
];
|
|
881
|
+
const labelWidth = Math.max(...rows.map(([label]) => label.length));
|
|
882
|
+
const wordmark = renderBox([
|
|
883
|
+
"",
|
|
884
|
+
...OXYGEN_WORDMARK.split("\n").map(line => styles.green(line)),
|
|
885
|
+
"",
|
|
886
|
+
]);
|
|
887
|
+
return [
|
|
888
|
+
"",
|
|
889
|
+
wordmark,
|
|
890
|
+
"",
|
|
891
|
+
`${styles.green("[OK]")} ${styles.bold("CLI connected")}`,
|
|
892
|
+
"",
|
|
893
|
+
...rows.map(([label, value]) => ` ${styles.dim(label.padEnd(labelWidth))} ${value}`),
|
|
894
|
+
"",
|
|
895
|
+
...formatAutomaticSkillsInstallStatusLines(options.skillsInstall),
|
|
896
|
+
].join("\n");
|
|
897
|
+
}
|
|
898
|
+
export function formatFingerprint(hex) {
|
|
899
|
+
return hex.slice(0, 20).match(/.{1,4}/g)?.join(":") ?? hex.slice(0, 20);
|
|
900
|
+
}
|
|
901
|
+
export function createCredentialFingerprint(token) {
|
|
902
|
+
return createHash("sha256")
|
|
903
|
+
.update(`oxygen-cli:${token}`)
|
|
904
|
+
.digest("hex");
|
|
905
|
+
}
|
|
906
|
+
/** Column-aligned plain-text table, two-space indented. Shared by the sequence-variant lenses and the collaboration lists. */
|
|
907
|
+
export function renderTextTable(headers, rows) {
|
|
908
|
+
const widths = headers.map((header, columnIndex) => {
|
|
909
|
+
let max = header.length;
|
|
910
|
+
for (const row of rows) {
|
|
911
|
+
const cell = row[columnIndex] ?? "";
|
|
912
|
+
if (cell.length > max)
|
|
913
|
+
max = cell.length;
|
|
914
|
+
}
|
|
915
|
+
return max;
|
|
916
|
+
});
|
|
917
|
+
const renderRow = (cells) => ` ${cells.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" ")}`.replace(/\s+$/, "");
|
|
918
|
+
const separator = ` ${widths.map((width) => "-".repeat(width)).join(" ")}`;
|
|
919
|
+
return [renderRow(headers), separator, ...rows.map(renderRow)];
|
|
920
|
+
}
|
|
921
|
+
/** A read command that prints a readable block instead of the raw envelope.
|
|
922
|
+
* `--json` still emits the untouched API envelope, so the lens is presentation
|
|
923
|
+
* only and never becomes a second contract. Used by the sequencer lenses and by
|
|
924
|
+
* `crm objects`. */
|
|
925
|
+
export async function handleReadActionWithLens(command, options, action, render) {
|
|
926
|
+
try {
|
|
927
|
+
const data = await action();
|
|
928
|
+
if (options.json) {
|
|
929
|
+
writeJson(success(command, data));
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
process.stdout.write(isRecord(data) ? render(data) : `${JSON.stringify(data, null, 2)}\n`);
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
emitCliFailure(command, error);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
// --- Collaboration output ----------------------------------------------------
|
|
939
|
+
//
|
|
940
|
+
// Comments and approvals are read by two very different audiences: the agency's
|
|
941
|
+
// operator in a terminal, and an agent parsing --json. So these commands render
|
|
942
|
+
// a compact human view by default and the stable envelope under --json — the
|
|
943
|
+
// same split `sequences variants` uses. Every renderer ends with the deep-link
|
|
944
|
+
// the API returned, because the client who has to decide is usually the one
|
|
945
|
+
// person NOT in the terminal.
|
|
946
|
+
export async function handleCollabAction(command, options, action, render) {
|
|
947
|
+
try {
|
|
948
|
+
const data = await action();
|
|
949
|
+
if (options.json) {
|
|
950
|
+
writeJson(success(command, data));
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
process.stdout.write(isRecord(data) ? render(data) : `${JSON.stringify(data, null, 2)}\n`);
|
|
954
|
+
}
|
|
955
|
+
catch (error) {
|
|
956
|
+
emitCliFailure(command, error);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
export function collabStyles() {
|
|
960
|
+
return ansi(output.isTTY === true && !process.env.NO_COLOR);
|
|
961
|
+
}
|
|
962
|
+
export function collabText(value, fallback = "—") {
|
|
963
|
+
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
964
|
+
}
|
|
965
|
+
export function collabLink(data) {
|
|
966
|
+
if (typeof data.web_url === "string")
|
|
967
|
+
return data.web_url;
|
|
968
|
+
return typeof data.deepLink === "string" ? data.deepLink : null;
|
|
969
|
+
}
|
|
970
|
+
/** Coarse age for a list column: seconds, minutes, hours, then days. */
|
|
971
|
+
export function formatCollabAge(value) {
|
|
972
|
+
if (typeof value !== "string")
|
|
973
|
+
return "—";
|
|
974
|
+
const at = Date.parse(value);
|
|
975
|
+
if (!Number.isFinite(at))
|
|
976
|
+
return "—";
|
|
977
|
+
const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
|
|
978
|
+
if (seconds < 60)
|
|
979
|
+
return `${seconds}s`;
|
|
980
|
+
const minutes = Math.floor(seconds / 60);
|
|
981
|
+
if (minutes < 60)
|
|
982
|
+
return `${minutes}m`;
|
|
983
|
+
const hours = Math.floor(minutes / 60);
|
|
984
|
+
if (hours < 48)
|
|
985
|
+
return `${hours}h`;
|
|
986
|
+
return `${Math.floor(hours / 24)}d`;
|
|
987
|
+
}
|
|
988
|
+
/** First line of a body, clipped — a thread's subject line in one cell. */
|
|
989
|
+
export function collabSnippet(value, width = 56) {
|
|
990
|
+
if (typeof value !== "string")
|
|
991
|
+
return "";
|
|
992
|
+
const firstLine = value.split("\n").find((line) => line.trim()) ?? "";
|
|
993
|
+
return clipCell(firstLine.trim(), width);
|
|
994
|
+
}
|
|
995
|
+
/** Trailer every collab render shares: what to do next, then the deep-link. */
|
|
996
|
+
export function collabTrailer(data) {
|
|
997
|
+
const styles = collabStyles();
|
|
998
|
+
const lines = [];
|
|
999
|
+
const nextStep = typeof data.next_step === "string" ? data.next_step.trim() : "";
|
|
1000
|
+
if (nextStep)
|
|
1001
|
+
lines.push("", ` ${styles.dim("Next")} ${nextStep}`);
|
|
1002
|
+
const link = collabLink(data);
|
|
1003
|
+
if (link)
|
|
1004
|
+
lines.push(` ${styles.dim("Link")} ${link}`);
|
|
1005
|
+
return lines;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Generic confirmation for a write whose payload is one object. The API already
|
|
1009
|
+
* writes a `next_step` sentence for each of these, so the renderer's only job is
|
|
1010
|
+
* to say what changed and keep the deep-link visible.
|
|
1011
|
+
*/
|
|
1012
|
+
export function formatCollabWriteResult(data) {
|
|
1013
|
+
const styles = collabStyles();
|
|
1014
|
+
const lines = [""];
|
|
1015
|
+
const headline = collabWriteHeadline(data);
|
|
1016
|
+
lines.push(` ${styles.green("[OK]")} ${headline}`);
|
|
1017
|
+
if (data.already === true) {
|
|
1018
|
+
lines.push(` ${styles.dim("Already in that state; nothing changed.")}`);
|
|
1019
|
+
}
|
|
1020
|
+
const notified = Array.isArray(data.notified_emails)
|
|
1021
|
+
? data.notified_emails.filter((value) => typeof value === "string")
|
|
1022
|
+
: [];
|
|
1023
|
+
if (notified.length > 0)
|
|
1024
|
+
lines.push(` ${styles.dim("Notified")} ${notified.join(", ")}`);
|
|
1025
|
+
const unresolved = Array.isArray(data.unresolved_mentions)
|
|
1026
|
+
? data.unresolved_mentions.filter((value) => typeof value === "string")
|
|
1027
|
+
: [];
|
|
1028
|
+
if (unresolved.length > 0) {
|
|
1029
|
+
lines.push(` ${styles.yellow("Not a workspace member, so not notified:")} ${unresolved.join(", ")}`);
|
|
1030
|
+
}
|
|
1031
|
+
const note = collabText(data.note, "");
|
|
1032
|
+
if (note)
|
|
1033
|
+
lines.push(` ${styles.dim(note)}`);
|
|
1034
|
+
lines.push(...collabTrailer(data));
|
|
1035
|
+
lines.push("");
|
|
1036
|
+
return lines.join("\n");
|
|
1037
|
+
}
|
|
1038
|
+
/** One line naming what changed, matched to each write endpoint's payload shape. */
|
|
1039
|
+
function collabWriteHeadline(data) {
|
|
1040
|
+
// A thread write (`comments add`, `comments resolve`) serializes the thread
|
|
1041
|
+
// itself at the top level.
|
|
1042
|
+
if (typeof data.status === "string" && typeof data.subject === "string") {
|
|
1043
|
+
return `${collabText(data.subject)} — thread ${collabText(data.id)} is ${data.status}.`;
|
|
1044
|
+
}
|
|
1045
|
+
if (isRecord(data.comment)) {
|
|
1046
|
+
return data.comment.deleted === true ? "Comment retracted." : "Comment updated.";
|
|
1047
|
+
}
|
|
1048
|
+
if (isRecord(data.request)) {
|
|
1049
|
+
return `Approval request ${collabText(data.request.id)} is ${collabText(data.request.status)}.`;
|
|
1050
|
+
}
|
|
1051
|
+
// `orgs member-role` — the Clerk workspace role, not the Oxygen overlay. It
|
|
1052
|
+
// must be checked BEFORE the overlay branch below: both write payloads are
|
|
1053
|
+
// flat and both carry `email`, so falling through would report a promotion as
|
|
1054
|
+
// "back to full workspace access".
|
|
1055
|
+
if (typeof data.email === "string" && typeof data.workspace_role_choice === "string") {
|
|
1056
|
+
return data.workspace_role_choice === "admin"
|
|
1057
|
+
? `${data.email} is now a workspace admin.`
|
|
1058
|
+
: `${data.email} is now a workspace member.`;
|
|
1059
|
+
}
|
|
1060
|
+
// The member-role write returns the membership fields flat, not nested.
|
|
1061
|
+
if (typeof data.email === "string") {
|
|
1062
|
+
// Granting on a PENDING INVITATION changes nobody's access yet. Saying "is
|
|
1063
|
+
// now a client-role member" would tell an agency the client is restricted
|
|
1064
|
+
// while that client has not accepted and holds no membership at all.
|
|
1065
|
+
if (data.pending_invitation === true) {
|
|
1066
|
+
return data.granted === true
|
|
1067
|
+
? `${data.email} has not joined yet — they will land restricted to the client role.`
|
|
1068
|
+
: `${data.email} has not joined yet — they will land with full workspace access.`;
|
|
1069
|
+
}
|
|
1070
|
+
return data.granted === true
|
|
1071
|
+
? `${data.email} is now a client-role member of this workspace.`
|
|
1072
|
+
: `${data.email} is back to full workspace access.`;
|
|
1073
|
+
}
|
|
1074
|
+
return "Done.";
|
|
1075
|
+
}
|
|
1076
|
+
export function describeProfileSource(source) {
|
|
1077
|
+
switch (source) {
|
|
1078
|
+
case "flag": return "from --profile flag";
|
|
1079
|
+
case "env": return "from OXYGEN_PROFILE";
|
|
1080
|
+
case "file": return "from stored active profile";
|
|
1081
|
+
default: return "default fallback";
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
function formatLogoutSuccess(result) {
|
|
1085
|
+
const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
|
|
1086
|
+
const removed = result.removedProfile
|
|
1087
|
+
? `profile "${result.removedProfile}" removed`
|
|
1088
|
+
: "removed";
|
|
1089
|
+
return [
|
|
1090
|
+
"",
|
|
1091
|
+
`${styles.green("[OK]")} ${styles.bold("CLI logged out")}`,
|
|
1092
|
+
"",
|
|
1093
|
+
` ${styles.dim("Credentials")} ${removed}`,
|
|
1094
|
+
` ${styles.dim("Profiles left")} ${String(result.remainingProfiles)}`, // skipcq: JS-C1002
|
|
1095
|
+
"",
|
|
1096
|
+
].join("\n");
|
|
1097
|
+
}
|
|
1098
|
+
export function formatAutomaticSkillsInstallStatusLines(result) {
|
|
1099
|
+
if (!result)
|
|
1100
|
+
return [];
|
|
1101
|
+
const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
|
|
1102
|
+
if (!result.attempted) {
|
|
1103
|
+
if (result.reason === "dry_run")
|
|
1104
|
+
return [];
|
|
1105
|
+
return [
|
|
1106
|
+
`${styles.dim("Skills")} skipped (${result.reason ?? "not attempted"})`,
|
|
1107
|
+
"",
|
|
1108
|
+
];
|
|
1109
|
+
}
|
|
1110
|
+
if (result.ok) {
|
|
1111
|
+
return [
|
|
1112
|
+
`${styles.green("[OK]")} ${styles.bold("Oxygen skills installed")}`,
|
|
1113
|
+
"",
|
|
1114
|
+
];
|
|
1115
|
+
}
|
|
1116
|
+
return [
|
|
1117
|
+
`${styles.yellow("[WARN]")} Oxygen skills could not be installed; the CLI command still completed.`,
|
|
1118
|
+
` ${styles.dim("Check")} ${"oxygen skills doctor --json"}`,
|
|
1119
|
+
` ${styles.dim("Retry")} ${result.command}`,
|
|
1120
|
+
"",
|
|
1121
|
+
];
|
|
1122
|
+
}
|
|
1123
|
+
function renderBox(lines) {
|
|
1124
|
+
const width = Math.max(...lines.map(visibleLength), 0);
|
|
1125
|
+
const border = `+${"-".repeat(width + 2)}+`;
|
|
1126
|
+
const body = lines.map((line) => `| ${line}${" ".repeat(width - visibleLength(line))} |`);
|
|
1127
|
+
return [border, ...body, border].join("\n");
|
|
1128
|
+
}
|
|
1129
|
+
function visibleLength(value) {
|
|
1130
|
+
// skipcq: JS-0004, JS-W1035 — ESC (\x1b) is the ANSI CSI introducer; the literal escape is required to strip color codes
|
|
1131
|
+
return value.replace(/\x1b\[[0-9;]*m/gu, "").length;
|
|
1132
|
+
}
|
|
1133
|
+
export function ansi(enabled) {
|
|
1134
|
+
const wrap = (open, close) => enabled
|
|
1135
|
+
? (text) => `\x1b[${open}m${text}\x1b[${close}m`
|
|
1136
|
+
: (text) => text;
|
|
1137
|
+
return {
|
|
1138
|
+
bold: wrap(1, 22), // skipcq: JS-0117 // skipcq: JS-W1035
|
|
1139
|
+
dim: wrap(2, 22),
|
|
1140
|
+
green: wrap(32, 39),
|
|
1141
|
+
yellow: wrap(33, 39),
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
// Commander collector for a repeatable option (e.g. --attach <url> --attach <url>):
|
|
1145
|
+
// accumulates each value into an array.
|
|
1146
|
+
export function collectRepeatable(value, previous) {
|
|
1147
|
+
return [...previous, value];
|
|
1148
|
+
}
|
|
1149
|
+
export function readSelectedTranscriptExcerpt(text) {
|
|
1150
|
+
const excerpt = text.trim();
|
|
1151
|
+
if (!excerpt || Buffer.byteLength(excerpt, "utf8") > 200_000) {
|
|
1152
|
+
throw new OxygenError("invalid_transcript", "Transcript excerpt must be between 1 and 200,000 bytes.", { exitCode: 1 });
|
|
1153
|
+
}
|
|
1154
|
+
return redactTranscriptSecrets(excerpt);
|
|
1155
|
+
}
|
|
1156
|
+
export function readCsvOption(value) {
|
|
1157
|
+
const option = readOption(value);
|
|
1158
|
+
if (!option)
|
|
1159
|
+
return [];
|
|
1160
|
+
return option
|
|
1161
|
+
.split(",")
|
|
1162
|
+
.map((entry) => entry.trim())
|
|
1163
|
+
.filter(Boolean);
|
|
1164
|
+
}
|
|
1165
|
+
// Assemble the directory listing profile payload shared by `directory update`
|
|
1166
|
+
// and `admin directory enable`. --profile-json provides the base object (and
|
|
1167
|
+
// the only way to null-clear fields); explicit flags override it.
|
|
1168
|
+
export async function buildDirectoryProfileBody(options) {
|
|
1169
|
+
const profile = options.profileJson
|
|
1170
|
+
? parseJsonObject(options.profileJson)
|
|
1171
|
+
: {};
|
|
1172
|
+
const stringFlags = [
|
|
1173
|
+
[options.name, "name"],
|
|
1174
|
+
[options.slug, "slug"],
|
|
1175
|
+
[options.logoUrl, "logo_url"],
|
|
1176
|
+
[options.tagline, "tagline"],
|
|
1177
|
+
[options.description, "description"],
|
|
1178
|
+
[options.website, "website_url"],
|
|
1179
|
+
[options.companyLinkedin, "linkedin_company_url"],
|
|
1180
|
+
[options.bookingUrl, "booking_url"],
|
|
1181
|
+
[options.contactEmail, "contact_email"],
|
|
1182
|
+
];
|
|
1183
|
+
for (const [value, key] of stringFlags) {
|
|
1184
|
+
const normalized = readOption(value);
|
|
1185
|
+
if (normalized)
|
|
1186
|
+
profile[key] = normalized;
|
|
1187
|
+
}
|
|
1188
|
+
if (!profile.description) {
|
|
1189
|
+
const path = readOption(options.descriptionFile);
|
|
1190
|
+
if (path) {
|
|
1191
|
+
const fs = await import("node:fs/promises");
|
|
1192
|
+
profile.description = await fs.readFile(path, "utf8");
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
if (readOption(options.foundersJson)) {
|
|
1196
|
+
const founders = parseJsonValue(options.foundersJson ?? "[]", "founders-json");
|
|
1197
|
+
if (!Array.isArray(founders)) {
|
|
1198
|
+
throw new OxygenError("invalid_request", "--founders-json must be a JSON array of {name, title, linkedin_url} objects.", { exitCode: 1 });
|
|
1199
|
+
}
|
|
1200
|
+
profile.founders = founders;
|
|
1201
|
+
}
|
|
1202
|
+
const csvFlags = [
|
|
1203
|
+
[options.services, "services"],
|
|
1204
|
+
[options.regions, "regions"],
|
|
1205
|
+
];
|
|
1206
|
+
for (const [value, key] of csvFlags) {
|
|
1207
|
+
if (readOption(value))
|
|
1208
|
+
profile[key] = readCsvOption(value);
|
|
1209
|
+
}
|
|
1210
|
+
return profile;
|
|
1211
|
+
}
|
|
1212
|
+
export function collectMultiple(value, previous) {
|
|
1213
|
+
return [...previous, value];
|
|
1214
|
+
}
|
|
1215
|
+
export function buildContextResolveBody(options) {
|
|
1216
|
+
const assetTypes = readCsvOption(options.assetType);
|
|
1217
|
+
const tags = readCsvOption(options.tags);
|
|
1218
|
+
const maxAssets = readNonNegativeInt(options.maxAssets);
|
|
1219
|
+
return {
|
|
1220
|
+
...(readOption(options.purpose) ? { purpose: readOption(options.purpose) } : {}),
|
|
1221
|
+
...(assetTypes.length > 0 ? { asset_types: assetTypes } : {}),
|
|
1222
|
+
...(readOption(options.assetStatus) ? { asset_status: readOption(options.assetStatus) } : {}),
|
|
1223
|
+
...(tags.length > 0 ? { tags } : {}),
|
|
1224
|
+
...(options.includeArchived ? { include_archived: true } : {}),
|
|
1225
|
+
...(maxAssets !== undefined ? { max_assets: maxAssets } : {}),
|
|
1226
|
+
...(options.requireReady ? { require_ready: true } : {}),
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
// ── Knowledge mirror (local wiki clone) ─────────────────────────────────────────
|
|
1230
|
+
// `oxygen knowledge sync` maintains <configDir>/knowledge/<api-host>/<org-id>/ with
|
|
1231
|
+
// SERVER-rendered page markdown written verbatim — the server hashes those exact
|
|
1232
|
+
// bytes (content_sha256) and the client never re-renders, so renderer drift can't
|
|
1233
|
+
// mass-quarantine mirrors. Pure directory mechanics live in knowledge-mirror.ts.
|
|
1234
|
+
export const KNOWLEDGE_SYNC_DEFAULT_TTL_MINUTES = 15;
|
|
1235
|
+
// 500 batches × the server's 100-page default is far above any v1 wiki; the cap
|
|
1236
|
+
// only exists so a misbehaving server can never wedge the CLI in a loop.
|
|
1237
|
+
const KNOWLEDGE_SYNC_MAX_BATCHES = 500;
|
|
1238
|
+
const KNOWLEDGE_SYNC_CATALOG_MAX_BATCHES = 500;
|
|
1239
|
+
// Envelope error codes meaning this org can no longer be read with the stored
|
|
1240
|
+
// token (revoked membership, expired key, org mismatch). The mirror is a local
|
|
1241
|
+
// cache of tenant data and must not outlive access to it.
|
|
1242
|
+
const KNOWLEDGE_SYNC_ACCESS_REVOKED_CODES = new Set([
|
|
1243
|
+
"unauthorized",
|
|
1244
|
+
"forbidden",
|
|
1245
|
+
"forbidden_origin",
|
|
1246
|
+
"organization_not_found",
|
|
1247
|
+
"organization_mismatch",
|
|
1248
|
+
]);
|
|
1249
|
+
export function knowledgeMirrorTargetFor(apiUrl, orgId) {
|
|
1250
|
+
const apiHost = new URL(apiUrl).host;
|
|
1251
|
+
return {
|
|
1252
|
+
dir: resolveMirrorDir({ configDir: resolveDefaultConfigDir(), apiHost, orgId }),
|
|
1253
|
+
apiHost,
|
|
1254
|
+
orgId,
|
|
1255
|
+
webUrl: `${apiUrl.replace(/\/$/, "")}/knowledge`,
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
// The org id every other command resolves implicitly (X-Oxygen-Organization from
|
|
1259
|
+
// OXYGEN_ORG / the stored profile) must be pinned explicitly here because it names
|
|
1260
|
+
// the mirror directory. Null when the cached identity can't answer for the
|
|
1261
|
+
// selected org — e.g. env-token logins or an OXYGEN_ORG/--org override.
|
|
1262
|
+
function knowledgeMirrorTargetFromCredentials(credentials) {
|
|
1263
|
+
const cached = credentials.activeOrganization ?? credentials.identity?.organization ?? null;
|
|
1264
|
+
if (!cached)
|
|
1265
|
+
return null;
|
|
1266
|
+
const selected = process.env.OXYGEN_ORG?.trim();
|
|
1267
|
+
if (selected && selected !== cached.id && selected !== cached.slug)
|
|
1268
|
+
return null;
|
|
1269
|
+
return knowledgeMirrorTargetFor(credentials.apiUrl, cached.id);
|
|
1270
|
+
}
|
|
1271
|
+
export async function resolveKnowledgeMirrorTarget() {
|
|
1272
|
+
const credentials = await loadCredentials();
|
|
1273
|
+
if (!credentials) {
|
|
1274
|
+
throw new OxygenError("not_logged_in", "Run `oxygen login` before using CLI commands.", {
|
|
1275
|
+
exitCode: 1,
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
const local = knowledgeMirrorTargetFromCredentials(credentials);
|
|
1279
|
+
if (local)
|
|
1280
|
+
return local;
|
|
1281
|
+
const identity = await requestOxygen("/api/cli/whoami", {
|
|
1282
|
+
enforceMinimumCliVersion: false,
|
|
1283
|
+
});
|
|
1284
|
+
return knowledgeMirrorTargetFor(credentials.apiUrl, identity.organization.id);
|
|
1285
|
+
}
|
|
1286
|
+
function knowledgeSyncSummary(target, state, extra) {
|
|
1287
|
+
return {
|
|
1288
|
+
synced: 0,
|
|
1289
|
+
deleted: 0,
|
|
1290
|
+
quarantined: 0,
|
|
1291
|
+
pages_total: state ? Object.keys(state.pages).length : 0,
|
|
1292
|
+
cursor: state?.cursor ?? null,
|
|
1293
|
+
last_sync_at: state?.last_sync_at ?? null,
|
|
1294
|
+
mirror_path: target.dir,
|
|
1295
|
+
web_url: target.webUrl,
|
|
1296
|
+
...extra,
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
function removeOwnedKnowledgeMirrorPage(dir, state, slug, counters) {
|
|
1300
|
+
// Archive and rename remove a server-owned path, but must preserve edits we
|
|
1301
|
+
// did not write. Check against its recorded bytes before losing the identity.
|
|
1302
|
+
const recordedSha = state.pages[slug]?.content_sha256;
|
|
1303
|
+
if (recordedSha !== undefined && isFileDirty(dir, slug, recordedSha)) {
|
|
1304
|
+
if (quarantineDirtyFile(dir, slug))
|
|
1305
|
+
counters.quarantined += 1;
|
|
1306
|
+
}
|
|
1307
|
+
deletePageFile(dir, slug);
|
|
1308
|
+
delete state.pages[slug];
|
|
1309
|
+
counters.deleted += 1;
|
|
1310
|
+
}
|
|
1311
|
+
function applyKnowledgeSyncPage(dir, state, page, counters) {
|
|
1312
|
+
if (page.rendered_markdown === undefined || page.content_sha256 === undefined || !page.slug) {
|
|
1313
|
+
// Tombstone (archived or slug-less): delete the file this page owns locally,
|
|
1314
|
+
// matching by slug first and falling back to the page id (slug re-use).
|
|
1315
|
+
const localSlug = page.slug && state.pages[page.slug]?.id === page.id
|
|
1316
|
+
? page.slug
|
|
1317
|
+
: findMirrorSlugByPageId(state, page.id);
|
|
1318
|
+
if (localSlug) {
|
|
1319
|
+
removeOwnedKnowledgeMirrorPage(dir, state, localSlug, counters);
|
|
1320
|
+
}
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
// Slug renames: drop the file this page previously owned under its old slug.
|
|
1324
|
+
const previousSlug = findMirrorSlugByPageId(state, page.id);
|
|
1325
|
+
if (previousSlug && previousSlug !== page.slug) {
|
|
1326
|
+
removeOwnedKnowledgeMirrorPage(dir, state, previousSlug, counters);
|
|
1327
|
+
}
|
|
1328
|
+
// Quarantine only bytes WE didn't write (differ from the manifest's recorded
|
|
1329
|
+
// sha) that this write would destroy (differ from the incoming server bytes).
|
|
1330
|
+
const recordedSha = state.pages[page.slug]?.content_sha256;
|
|
1331
|
+
if (isFileDirty(dir, page.slug, recordedSha ?? page.content_sha256)
|
|
1332
|
+
&& isFileDirty(dir, page.slug, page.content_sha256)) {
|
|
1333
|
+
quarantineDirtyFile(dir, page.slug);
|
|
1334
|
+
counters.quarantined += 1;
|
|
1335
|
+
}
|
|
1336
|
+
writePageFile(dir, page.slug, page.rendered_markdown);
|
|
1337
|
+
state.pages[page.slug] = {
|
|
1338
|
+
id: page.id,
|
|
1339
|
+
revision: page.revision,
|
|
1340
|
+
content_sha256: page.content_sha256,
|
|
1341
|
+
};
|
|
1342
|
+
counters.synced += 1;
|
|
1343
|
+
}
|
|
1344
|
+
async function readKnowledgeMirrorCatalog(requestOrg, mirrorPath) {
|
|
1345
|
+
let cursor = null;
|
|
1346
|
+
const seenCursors = new Set();
|
|
1347
|
+
const pages = new Map();
|
|
1348
|
+
for (let batch = 0; batch < KNOWLEDGE_SYNC_CATALOG_MAX_BATCHES; batch += 1) {
|
|
1349
|
+
const query = new URLSearchParams({ max_chars: "48000", limit: "1000", ...(cursor ? { cursor } : {}) });
|
|
1350
|
+
const window = await requestOxygen(`/api/cli/knowledge/index?${query}`, requestOrg);
|
|
1351
|
+
if (typeof window.has_more !== "boolean" || !Number.isInteger(window.max_chars) || window.max_chars < 2000 || window.max_chars > 48000 ||
|
|
1352
|
+
!Array.isArray(window.index?.pages) || window.index.capped !== window.has_more ||
|
|
1353
|
+
(window.next_cursor !== null && typeof window.next_cursor !== "string") || (!window.has_more && window.next_cursor !== null)) {
|
|
1354
|
+
throw new OxygenError("knowledge_sync_catalog_invalid", "The knowledge catalog did not return a complete bounded continuation receipt. The generated index was left unchanged.", {
|
|
1355
|
+
details: { catalog_cursor: cursor, mirror_path: mirrorPath }, exitCode: 1,
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
for (const page of window.index.pages) {
|
|
1359
|
+
if (!page || typeof page.slug !== "string" || !page.slug || pages.has(page.slug)) {
|
|
1360
|
+
throw new OxygenError("knowledge_sync_catalog_changed", "The catalog repeated or omitted a page slug during live traversal. Retry sync to restart the catalog; the generated index was left unchanged.", {
|
|
1361
|
+
details: { catalog_cursor: cursor, mirror_path: mirrorPath }, exitCode: 1,
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
pages.set(page.slug, page);
|
|
1365
|
+
}
|
|
1366
|
+
if (!window.has_more) {
|
|
1367
|
+
return { ...window, index: { ...window.index, pages: [...pages.values()] } };
|
|
1368
|
+
}
|
|
1369
|
+
if (!window.next_cursor || seenCursors.has(window.next_cursor)) {
|
|
1370
|
+
throw new OxygenError("knowledge_sync_catalog_stalled", "The catalog cursor did not advance; the generated index was left unchanged. Retry sync to restart the catalog from the preserved page watermark.", {
|
|
1371
|
+
details: { catalog_cursor: cursor, next_cursor: window.next_cursor, mirror_path: mirrorPath }, exitCode: 1,
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
cursor = window.next_cursor;
|
|
1375
|
+
seenCursors.add(cursor);
|
|
1376
|
+
}
|
|
1377
|
+
throw new OxygenError("knowledge_sync_catalog_incomplete", "The catalog batch cap was reached before its terminal receipt. The generated index was left unchanged; retry sync to restart the catalog from the preserved page watermark.", {
|
|
1378
|
+
details: { catalog_cursor: cursor, mirror_path: mirrorPath, batch_cap: KNOWLEDGE_SYNC_CATALOG_MAX_BATCHES }, exitCode: 1,
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
// `orgOverride` targets a specific org (used by `sync --all`): it names the mirror
|
|
1382
|
+
// directory explicitly and pins X-Oxygen-Organization on every request in the
|
|
1383
|
+
// loop, so each workspace clones into its own mirror regardless of the cached
|
|
1384
|
+
// active org. Absent (the default single-org path), behavior is unchanged.
|
|
1385
|
+
export async function runKnowledgeMirrorSync(// skipcq: JS-R1005 -- one linear sync pipeline: staleness gate, lock, delta loop, generated files.
|
|
1386
|
+
options, orgOverride) {
|
|
1387
|
+
const requestOrg = orgOverride ? { selectedOrganization: orgOverride.selector } : {};
|
|
1388
|
+
let target;
|
|
1389
|
+
if (orgOverride) {
|
|
1390
|
+
const credentials = await loadCredentials();
|
|
1391
|
+
if (!credentials) {
|
|
1392
|
+
throw new OxygenError("not_logged_in", "Run `oxygen login` before using CLI commands.", { exitCode: 1 });
|
|
1393
|
+
}
|
|
1394
|
+
target = knowledgeMirrorTargetFor(credentials.apiUrl, orgOverride.orgId);
|
|
1395
|
+
}
|
|
1396
|
+
else {
|
|
1397
|
+
target = await resolveKnowledgeMirrorTarget();
|
|
1398
|
+
}
|
|
1399
|
+
const ttlMinutes = readPositiveInt(options.ttl) ?? KNOWLEDGE_SYNC_DEFAULT_TTL_MINUTES;
|
|
1400
|
+
if (options.ifStale && !options.full) {
|
|
1401
|
+
const state = readMirrorState(target.dir);
|
|
1402
|
+
const lastSyncMs = state?.last_sync_at ? Date.parse(state.last_sync_at) : Number.NaN;
|
|
1403
|
+
if (Number.isFinite(lastSyncMs) && Date.now() - lastSyncMs < ttlMinutes * 60_000) {
|
|
1404
|
+
return knowledgeSyncSummary(target, state, { skipped: true, reason: "fresh" });
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
if (!acquireMirrorLock(target.dir)) {
|
|
1408
|
+
if (options.ifStale) {
|
|
1409
|
+
return knowledgeSyncSummary(target, readMirrorState(target.dir), { skipped: true, reason: "locked" });
|
|
1410
|
+
}
|
|
1411
|
+
throw new OxygenError("knowledge_sync_locked", "Another knowledge sync is already running for this mirror. Retry shortly; a crashed sync's lock self-clears after 10 minutes.", {
|
|
1412
|
+
details: { mirror_path: target.dir },
|
|
1413
|
+
exitCode: 1,
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
try {
|
|
1417
|
+
// --full re-clones from scratch but keeps the never-silently-clobber promise:
|
|
1418
|
+
// dirty local files are quarantined into .oxygen/conflicts/ (which survives)
|
|
1419
|
+
// first. Done AFTER acquiring the mirror lock above so a concurrent --full
|
|
1420
|
+
// can't delete files out from under another running sync before locking.
|
|
1421
|
+
const fullResetQuarantined = options.full
|
|
1422
|
+
? resetMirrorForFullResync(target.dir).quarantined
|
|
1423
|
+
: 0;
|
|
1424
|
+
const state = readMirrorState(target.dir)
|
|
1425
|
+
?? emptyMirrorState({ apiHost: target.apiHost, orgId: target.orgId });
|
|
1426
|
+
const counters = { synced: 0, deleted: 0, quarantined: fullResetQuarantined };
|
|
1427
|
+
let webUrl = target.webUrl;
|
|
1428
|
+
let cursor = state.cursor;
|
|
1429
|
+
let lastSeen = null;
|
|
1430
|
+
let completed = false;
|
|
1431
|
+
for (let batch = 0; batch < KNOWLEDGE_SYNC_MAX_BATCHES; batch += 1) {
|
|
1432
|
+
const delta = await requestOxygen("/api/cli/knowledge/sync/delta", {
|
|
1433
|
+
method: "POST",
|
|
1434
|
+
body: cursor ? { cursor } : {},
|
|
1435
|
+
...requestOrg,
|
|
1436
|
+
});
|
|
1437
|
+
if (delta.web_url)
|
|
1438
|
+
webUrl = delta.web_url;
|
|
1439
|
+
for (const page of delta.pages) {
|
|
1440
|
+
applyKnowledgeSyncPage(target.dir, state, page, counters);
|
|
1441
|
+
lastSeen = { updated_at: page.updated_at, id: page.id };
|
|
1442
|
+
}
|
|
1443
|
+
if (!delta.has_more) {
|
|
1444
|
+
// The native SQL watermark retains timestamp precision that the public
|
|
1445
|
+
// updated_at Date can lose. Older servers lack last_cursor; retain their
|
|
1446
|
+
// compatibility fallback without claiming it preserves SQL precision.
|
|
1447
|
+
if (delta.last_cursor)
|
|
1448
|
+
state.cursor = delta.last_cursor;
|
|
1449
|
+
else if (lastSeen)
|
|
1450
|
+
state.cursor = `${lastSeen.updated_at}|${lastSeen.id}`;
|
|
1451
|
+
completed = true;
|
|
1452
|
+
break;
|
|
1453
|
+
}
|
|
1454
|
+
if (!delta.next_cursor || delta.next_cursor === cursor) {
|
|
1455
|
+
throw new OxygenError("knowledge_sync_stalled", "The sync cursor did not advance between batches; aborting to avoid a loop.", {
|
|
1456
|
+
details: { cursor, mirror_path: target.dir },
|
|
1457
|
+
exitCode: 1,
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
cursor = delta.next_cursor;
|
|
1461
|
+
// Persist per batch so an interrupted clone resumes exactly where it stopped.
|
|
1462
|
+
state.cursor = cursor;
|
|
1463
|
+
writeMirrorState(target.dir, state);
|
|
1464
|
+
}
|
|
1465
|
+
if (!completed) {
|
|
1466
|
+
throw new OxygenError("knowledge_sync_incomplete", "The sync batch cap was reached before the wiki finished; re-run `knowledge sync` to resume from the stored cursor.", {
|
|
1467
|
+
details: { cursor: state.cursor, mirror_path: target.dir, batch_cap: KNOWLEDGE_SYNC_MAX_BATCHES },
|
|
1468
|
+
exitCode: 1,
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
// Save page progress before summary reads so a failed catalog can resume its
|
|
1472
|
+
// delta without declaring a fresh, fully completed mirror.
|
|
1473
|
+
state.last_sync_at = null;
|
|
1474
|
+
writeMirrorState(target.dir, state);
|
|
1475
|
+
// Traverse every native catalog window, including shared-only continuations.
|
|
1476
|
+
// Its scope excludes archived/proposal pages; it is a live keyset, not a
|
|
1477
|
+
// snapshot of every cloned page. Real `index`/`log` page bytes take priority.
|
|
1478
|
+
const index = state.pages.index ? null : await readKnowledgeMirrorCatalog(requestOrg, target.dir);
|
|
1479
|
+
const logPage = state.pages.log ? null : await requestOxygen("/api/cli/knowledge/log", requestOrg);
|
|
1480
|
+
if (index)
|
|
1481
|
+
writeGeneratedIndexFile(target.dir, index.index);
|
|
1482
|
+
if (logPage)
|
|
1483
|
+
writeGeneratedLogFile(target.dir, logPage.entries);
|
|
1484
|
+
state.last_sync_at = new Date().toISOString();
|
|
1485
|
+
writeMirrorState(target.dir, state);
|
|
1486
|
+
return knowledgeSyncSummary(target, state, { ...counters, ...(options.full ? { full: true } : {}), web_url: webUrl });
|
|
1487
|
+
}
|
|
1488
|
+
catch (error) {
|
|
1489
|
+
if (error instanceof OxygenError && KNOWLEDGE_SYNC_ACCESS_REVOKED_CODES.has(error.code)) {
|
|
1490
|
+
purgeMirror(target.dir);
|
|
1491
|
+
throw new OxygenError("knowledge_mirror_access_revoked", "This organization can no longer be read with the stored credentials (membership revoked or token expired). The local knowledge mirror was purged.", {
|
|
1492
|
+
details: { org_id: target.orgId, mirror_path: target.dir, cause: error.code },
|
|
1493
|
+
exitCode: 1,
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
throw error;
|
|
1497
|
+
}
|
|
1498
|
+
finally {
|
|
1499
|
+
releaseMirrorLock(target.dir);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
// `knowledge sync --all` — mirror every workspace the credential can reach, each
|
|
1503
|
+
// into its own org-id-keyed mirror directory. Loops the same `/api/cli/orgs` list
|
|
1504
|
+
// the `orgs` command reads and syncs each org via `runKnowledgeMirrorSync` with an
|
|
1505
|
+
// explicit org override. One org's failure (including a revoked membership, which
|
|
1506
|
+
// purges just that org's mirror) is recorded and never aborts the rest. Pair with
|
|
1507
|
+
// `--if-stale` so a large fleet loop stays cheap (each org honors its own TTL).
|
|
1508
|
+
export async function runKnowledgeMirrorSyncAll(options) {
|
|
1509
|
+
const credentials = await loadCredentials();
|
|
1510
|
+
if (!credentials) {
|
|
1511
|
+
throw new OxygenError("not_logged_in", "Run `oxygen login` before using CLI commands.", { exitCode: 1 });
|
|
1512
|
+
}
|
|
1513
|
+
const orgs = await requestOxygen("/api/cli/orgs", { credentials });
|
|
1514
|
+
const results = [];
|
|
1515
|
+
for (const org of orgs.organizations) {
|
|
1516
|
+
const selector = org.slug ?? org.id;
|
|
1517
|
+
try {
|
|
1518
|
+
const summary = await runKnowledgeMirrorSync({ ...options, all: false }, { orgId: org.id, selector });
|
|
1519
|
+
results.push({
|
|
1520
|
+
org_id: org.id,
|
|
1521
|
+
slug: org.slug ?? null,
|
|
1522
|
+
status: summary.skipped ? "skipped" : "synced",
|
|
1523
|
+
...summary,
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
catch (error) {
|
|
1527
|
+
results.push({
|
|
1528
|
+
org_id: org.id,
|
|
1529
|
+
slug: org.slug ?? null,
|
|
1530
|
+
status: "failed",
|
|
1531
|
+
error: error instanceof OxygenError
|
|
1532
|
+
? { code: error.code, message: error.message }
|
|
1533
|
+
: { code: "unknown", message: error instanceof Error ? error.message : "Unknown error." },
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
return {
|
|
1538
|
+
orgs: results,
|
|
1539
|
+
count: results.length,
|
|
1540
|
+
synced: results.filter((result) => result.status === "synced").length,
|
|
1541
|
+
skipped: results.filter((result) => result.status === "skipped").length,
|
|
1542
|
+
failed: results.filter((result) => result.status === "failed").length,
|
|
1543
|
+
web_url: `${credentials.apiUrl.replace(/\/$/, "")}/knowledge`,
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
export function readPositiveNumber(value, nextStep) {
|
|
1547
|
+
const trimmed = value?.trim();
|
|
1548
|
+
if (!trimmed)
|
|
1549
|
+
return undefined;
|
|
1550
|
+
const parsed = Number(trimmed);
|
|
1551
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
1552
|
+
throw new OxygenError("invalid_number", "Expected a positive number.", {
|
|
1553
|
+
details: { value, ...(nextStep ? { next_step: nextStep } : {}) },
|
|
1554
|
+
exitCode: 1,
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
return parsed;
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* A spend ceiling where ZERO IS VALID and meaningful: `find company
|
|
1561
|
+
* --max-credits 0` asks for the lanes that cost nothing (crustdata's identify
|
|
1562
|
+
* lane resolves domain↔LinkedIn for 0 credits) and skips every priced one.
|
|
1563
|
+
* readPositiveNumber rejected it client-side, so the free lanes were
|
|
1564
|
+
* unreachable from the CLI at all (Plain T-111). Fractional ceilings stay legal
|
|
1565
|
+
* — lane estimates are fractional — so this is not the whole-number reader.
|
|
1566
|
+
*/
|
|
1567
|
+
export function readCreditCeilingOrZero(value) {
|
|
1568
|
+
const trimmed = value?.trim();
|
|
1569
|
+
if (!trimmed)
|
|
1570
|
+
return undefined;
|
|
1571
|
+
const parsed = Number(trimmed);
|
|
1572
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
1573
|
+
throw new OxygenError("invalid_number", "Expected a number of 0 or more.", {
|
|
1574
|
+
details: { value },
|
|
1575
|
+
exitCode: 1,
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
return parsed;
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* A whole count (days, rows, windows). Its positive-number sibling accepts 2.5,
|
|
1582
|
+
* which for a scan bound is always a typo — rejected here so it costs no round trip,
|
|
1583
|
+
* and rejected rather than rounded so a bound nobody asked for is never scanned.
|
|
1584
|
+
*/
|
|
1585
|
+
export function readPositiveInteger(value) {
|
|
1586
|
+
const trimmed = value?.trim();
|
|
1587
|
+
if (!trimmed)
|
|
1588
|
+
return undefined;
|
|
1589
|
+
const parsed = Number(trimmed);
|
|
1590
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
1591
|
+
throw new OxygenError("invalid_number", "Expected a positive whole number.", {
|
|
1592
|
+
details: { value },
|
|
1593
|
+
exitCode: 1,
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
return parsed;
|
|
1597
|
+
}
|
|
1598
|
+
export function readNonNegativeNumber(value) {
|
|
1599
|
+
const trimmed = value?.trim();
|
|
1600
|
+
if (!trimmed)
|
|
1601
|
+
return undefined;
|
|
1602
|
+
const parsed = Number(trimmed);
|
|
1603
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
1604
|
+
throw new OxygenError("invalid_number", "Expected a non-negative number.", {
|
|
1605
|
+
details: { value },
|
|
1606
|
+
exitCode: 1,
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
return parsed;
|
|
1610
|
+
}
|