agentlinkops 0.6.11 → 0.6.16
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/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/.mcp.json +0 -0
- package/CHANGELOG.md +35 -0
- package/LICENSE +0 -0
- package/NOTICE +0 -0
- package/PACK-ARTIFACT.json +16 -0
- package/PACKS.json +591 -0
- package/README.md +34 -48
- package/bin/agentlinkops.mjs +0 -0
- package/bin/linktrail.mjs +0 -0
- package/cli/adapters/csv.js +0 -0
- package/cli/adapters/sqlite-crm.js +0 -0
- package/cli/adapters/suppliers.js +1 -1
- package/cli/admission.js +0 -0
- package/cli/agent-setup.js +101 -17
- package/cli/agentlinkops.mjs +0 -0
- package/cli/args.js +0 -0
- package/cli/campaign-bridge.js +325 -0
- package/cli/campaign-outcomes.js +107 -0
- package/cli/catalog.js +47686 -16398
- package/cli/check.js +40 -10
- package/cli/chronology.js +39 -0
- package/cli/citation.js +0 -0
- package/cli/client.js +2 -1
- package/cli/cloud-error.js +0 -0
- package/cli/commands.js +17 -8
- package/cli/config.js +0 -0
- package/cli/connection.js +92 -18
- package/cli/contact-adoption.js +91 -0
- package/cli/context-mcp.js +0 -0
- package/cli/context.js +13 -2
- package/cli/delivery.js +0 -0
- package/cli/disavow.js +0 -0
- package/cli/doctor.js +15 -4
- package/cli/env.js +0 -0
- package/cli/fleet.js +30 -19
- package/cli/gsc-browser.js +0 -0
- package/cli/import.js +7 -1
- package/cli/index-observations.js +0 -0
- package/cli/index-submissions.js +0 -0
- package/cli/key-store.js +115 -0
- package/cli/ledger.js +1 -0
- package/cli/lifecycle.js +0 -0
- package/cli/linktrail.mjs +0 -0
- package/cli/local-result.js +41 -6
- package/cli/main.js +85 -20
- package/cli/mcp-client.js +2 -2
- package/cli/migration.js +308 -0
- package/cli/mirror.js +0 -0
- package/cli/mix.js +0 -0
- package/cli/native-lock.js +0 -0
- package/cli/oauth-credentials.js +0 -0
- package/cli/oauth-flow.js +0 -0
- package/cli/placement-projection.js +106 -0
- package/cli/platforms.js +0 -0
- package/cli/prompt.js +25 -0
- package/cli/public-http.js +168 -0
- package/cli/receipt-performance.js +0 -0
- package/cli/receipts.js +0 -0
- package/cli/report.js +51 -19
- package/cli/setup-plan.js +0 -0
- package/cli/skill.js +0 -0
- package/cli/state.js +76 -29
- package/cli/status.js +14 -18
- package/cli/sync-lock.js +0 -0
- package/cli/sync.js +23 -4
- package/package.json +7 -8
- package/references/citation-panels.md +2 -0
- package/references/cli.md +2 -2
- package/references/disavow.md +1 -1
- package/references/discovery-handoff.md +1 -1
- package/references/local-crm-draft-handoff.md +1 -1
- package/references/portable-ledger-handoff.md +11 -10
- package/references/recipes/catalog.json +263 -7
- package/references/recipes/command-notes.md +5 -3
- package/references/recipes/internal-link-improvement.md +25 -0
- package/references/recipes/placement-reconciliation.md +53 -1
- package/references/recipes/qualified-campaign-handoff.md +0 -0
- package/references/recipes/site-context-brief.md +0 -0
- package/references/recipes/sourced-linkable-asset.md +0 -0
- package/references/reports.md +0 -0
- package/scripts/agentlinkops.py +0 -0
- package/scripts/disavow.py +0 -0
- package/scripts/linktrail.py +0 -0
- package/scripts/migrations/001_crm.sql +0 -0
- package/scripts/migrations/002_cloud_events.sql +0 -0
- package/scripts/migrations/003_disavow.sql +0 -0
- package/scripts/migrations/004_placement_costs.sql +0 -0
- package/scripts/outreach-crm-draft.py +0 -0
- package/scripts/portable-handoff.py +17 -4
- package/scripts/report.py +0 -0
- package/shared/admission-contract.js +3 -2
- package/shared/admission-report-contract.js +3 -2
- package/shared/agent-clients.js +0 -0
- package/shared/analytics-history-contract.js +15 -0
- package/shared/campaign-outcome-contract.js +12 -0
- package/shared/campaign-prospect-contract.js +23 -0
- package/shared/citation-evidence-contract.js +0 -0
- package/shared/classification-contract.js +54 -0
- package/shared/contact-record-contract.js +27 -0
- package/shared/disavow-contract.js +37 -9
- package/shared/exec-grammar.js +0 -0
- package/shared/index-observation-contract.js +5 -4
- package/shared/internal-links-contract.js +143 -0
- package/shared/lifecycle-contract.js +0 -0
- package/shared/lifecycle-money.js +0 -0
- package/shared/link-category-contract.js +35 -0
- package/shared/link-category.js +138 -0
- package/shared/link-labels.js +26 -0
- package/shared/link-summary-contract.js +17 -0
- package/shared/monitoring-chronology-export.js +57 -0
- package/shared/monitoring-read-contract.js +41 -0
- package/shared/observation-event.js +1 -1
- package/shared/onboarding.js +20 -0
- package/shared/opportunity-record.js +0 -0
- package/shared/response-contract.js +41 -5
- package/shared/search-connection-contract.js +2 -1
- package/shared/table-page-contract.js +13 -0
- package/skills/agentlinkops-assets/SKILL-DEPS.json +290 -0
- package/skills/agentlinkops-assets/SKILL.md +3 -1
- package/skills/agentlinkops-assets/agents/openai.yaml +1 -1
- package/skills/agentlinkops-assets/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-assets/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-assets/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-assets/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-assets/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-assets/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-assets/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-campaigns/SKILL-DEPS.json +336 -0
- package/skills/agentlinkops-campaigns/SKILL.md +7 -5
- package/skills/agentlinkops-campaigns/agents/openai.yaml +1 -1
- package/skills/agentlinkops-campaigns/references/first-campaign.md +31 -0
- package/skills/agentlinkops-campaigns/references/mailbox-campaign.md +46 -0
- package/skills/agentlinkops-campaigns/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-campaigns/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-campaigns/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-campaigns/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-connect/SKILL-DEPS.json +104 -0
- package/skills/agentlinkops-connect/SKILL.md +2 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +1 -1
- package/skills/agentlinkops-connect/references/agentlinkops.md +164 -61
- package/skills/agentlinkops-connect/references/placement-workflows.md +19 -0
- package/skills/agentlinkops-crm/SKILL-DEPS.json +381 -0
- package/skills/agentlinkops-crm/SKILL.md +26 -9
- package/skills/agentlinkops-crm/agents/openai.yaml +1 -1
- package/skills/agentlinkops-crm/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-crm/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-crm/references/portable/references/local-crm-draft-handoff.md +76 -0
- package/skills/agentlinkops-crm/references/portable/references/portable-ledger-handoff.md +51 -0
- package/skills/agentlinkops-crm/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-crm/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-crm/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-crm/references/portable/scripts/outreach-crm-draft.py +121 -0
- package/skills/agentlinkops-crm/references/portable/scripts/portable-handoff.py +197 -0
- package/skills/agentlinkops-crm/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-crm/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-discovery/SKILL-DEPS.json +301 -0
- package/skills/agentlinkops-discovery/SKILL.md +4 -2
- package/skills/agentlinkops-discovery/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-discovery/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-discovery/references/portable/references/discovery-handoff.md +32 -0
- package/skills/agentlinkops-discovery/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-discovery/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-discovery/references/saved-backlink-workflows.md +23 -0
- package/src/citations/adapter.js +0 -0
- package/src/citations/alert.js +0 -0
- package/src/citations/browser/chatgpt-citations.js +0 -0
- package/src/citations/browser/engine.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
- package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
- package/src/citations/browser/gen/lib/browser/display.js +0 -0
- package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
- package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
- package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
- package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
- package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
- package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
- package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
- package/src/citations/browser/gen/utils/agent-constants.js +0 -0
- package/src/citations/browser/providers.js +0 -0
- package/src/citations/browser/proxy-admission.js +0 -0
- package/src/citations/browser/shims/errors.js +0 -0
- package/src/citations/browser/shims/types.js +0 -0
- package/src/citations/browser/shims/utils.js +0 -0
- package/src/citations/contract.js +0 -0
- package/src/citations/egress-receipts.js +0 -0
- package/src/citations/evidence-integrity.js +0 -0
- package/src/citations/inventory.js +0 -0
- package/src/citations/login.js +0 -0
- package/src/citations/match.js +0 -0
- package/src/citations/panel-builder.js +0 -0
- package/src/citations/report.js +0 -0
- package/src/citations/runner.js +0 -0
- package/src/citations/stats.js +0 -0
- package/src/citations/sync.js +0 -0
- package/src/citations/watch.js +0 -0
- package/src/competitors/contract.js +17 -1
- package/src/competitors/domain-mix.js +0 -0
- package/src/context/ga4.js +0 -0
- package/src/context/gsc.js +0 -0
- package/src/context/site-profile.js +34 -121
- package/src/context/util.js +0 -0
- package/src/context-tool-registry.js +0 -0
- package/src/corpus/acquire.js +68 -32
- package/src/corpus/extract.js +147 -0
- package/src/corpus/frontier.js +151 -0
- package/src/corpus/seeds.js +0 -0
- package/src/discovery/contract.js +8 -0
- package/src/discovery/import.js +13 -1
- package/src/evidence-reference.js +0 -0
- package/src/index-observation.js +0 -0
- package/src/locator.js +0 -0
- package/src/mentions.js +193 -0
- package/src/page-acquisition/acquire.js +50 -0
- package/src/page-acquisition/bytes.js +138 -0
- package/src/page-acquisition/capture.js +11 -0
- package/src/page-acquisition/contracts.js +67 -0
- package/src/page-acquisition/documents.js +166 -0
- package/src/page-acquisition/http.js +307 -0
- package/src/page-acquisition/policy.js +20 -0
- package/src/page-acquisition/recovery.js +164 -0
- package/src/page-acquisition/render-failure.js +82 -0
- package/src/page-extraction/html.js +359 -0
- package/src/page-extraction/index.js +30 -0
- package/src/page-extraction/metadata.js +16 -0
- package/src/response-shaping.js +4 -3
- package/src/tool-views.js +23 -12
- package/src/verifier/fetch.js +4 -339
- package/src/verifier/index.js +81 -23
- package/src/verifier/observation-kind.js +0 -0
- package/src/verifier/page-quality.js +14 -0
- package/src/verifier/parser.js +2 -241
- package/src/verifier/readiness.js +64 -13
- package/src/verifier/render-evidence.js +25 -2
- package/src/verifier/robots.js +0 -0
- package/src/verifier/target.js +201 -0
- package/src/verifier/transition.js +18 -3
- package/src/verifier/url.js +31 -0
- package/src/watch-crawlability.js +144 -0
- package/templates/SITE.template.md +0 -0
- package/templates/campaigns.v1.json +0 -0
- package/skills/content-defingerprinting/SKILL.md +0 -29
- package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -102
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -26
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -52
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -21
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -96
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -432
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -86
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -460
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -651
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -27
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -212
- package/skills/internal-linking-optimizer/SKILL.md +0 -56
- package/skills/internal-linking-optimizer/references/source-prompt.md +0 -76
- package/skills/pre-publish-review/SKILL.md +0 -179
- package/skills/source-cited-content-builder/SKILL.md +0 -56
- package/skills/source-cited-content-builder/references/source-prompt.md +0 -19
- package/skills/source-cited-humanizer/SKILL.md +0 -20
package/cli/check.js
CHANGED
|
@@ -12,23 +12,47 @@
|
|
|
12
12
|
import { verifyLink } from '../src/verifier/index.js';
|
|
13
13
|
import { observationRow } from './mirror.js';
|
|
14
14
|
import { cadenceSeconds, DEFAULT_CADENCE } from './ledger.js';
|
|
15
|
+
import { createNodePublicFetch } from './public-http.js';
|
|
16
|
+
import { setTimeout as waitTimer } from 'node:timers/promises';
|
|
17
|
+
import { VerificationError, withAbort } from '../src/page-acquisition/bytes.js';
|
|
15
18
|
|
|
16
|
-
const sleep = ms
|
|
19
|
+
const sleep = async (ms, signal) => {
|
|
20
|
+
// Node's timer limit must not turn a very long publisher floor into 1 ms.
|
|
21
|
+
for (let remaining = ms; remaining > 0;) {
|
|
22
|
+
const duration = Math.min(remaining, 2_147_483_647);
|
|
23
|
+
try { await waitTimer(duration, undefined, { signal }); }
|
|
24
|
+
catch (error) { if (signal?.aborted) throw new VerificationError('timeout'); throw error; }
|
|
25
|
+
remaining -= duration;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
17
28
|
const hostOf = url => { try { return new URL(url).hostname; } catch { return null; } };
|
|
18
29
|
|
|
30
|
+
/** Private diagnostic prefixes are publisher bytes, even when HTML opt-out is set. */
|
|
31
|
+
export function portableCheckObservation(result) {
|
|
32
|
+
const observed = structuredClone(result);
|
|
33
|
+
const diagnostics = [observed.robots, ...(observed.robotsHistory ?? []), observed.sourceResponse];
|
|
34
|
+
for (const item of diagnostics) if (item?.bodyDiagnostic) delete item.bodyDiagnostic.sampleText;
|
|
35
|
+
return observed;
|
|
36
|
+
}
|
|
37
|
+
|
|
19
38
|
/** A courtesy floor per host, honouring a publisher's own stated delay when it is longer. */
|
|
20
39
|
export function createPacer({ hostDelayMs = 2000, now = () => Date.now(), wait = sleep } = {}) {
|
|
21
|
-
const
|
|
40
|
+
const lastRequest = new Map();
|
|
22
41
|
return {
|
|
23
42
|
async beforeFetch(url, context = {}) {
|
|
43
|
+
if (context.signal?.aborted) throw new VerificationError('timeout');
|
|
24
44
|
const host = hostOf(url);
|
|
25
45
|
if (!host) return;
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
46
|
+
const prior = lastRequest.get(host);
|
|
47
|
+
const stated = context.crawlDelaySeconds == null ? prior?.floor ?? 0 : Number(context.crawlDelaySeconds) * 1000;
|
|
48
|
+
const floor = Math.max(hostDelayMs, Number.isFinite(stated) ? stated : prior?.floor ?? 0);
|
|
49
|
+
// Robots was just fetched before its delay became known. Apply the new
|
|
50
|
+
// floor to that preceding request, not only to the following one.
|
|
51
|
+
const allowed = prior ? prior.at + Math.max(prior.floor, floor) : 0;
|
|
29
52
|
const delay = allowed - now();
|
|
30
|
-
if (delay > 0) await wait(delay);
|
|
31
|
-
|
|
53
|
+
if (delay > 0) await withAbort(wait(delay, context.signal), context.signal);
|
|
54
|
+
if (context.signal?.aborted) throw new VerificationError('timeout');
|
|
55
|
+
lastRequest.set(host, { at: now(), floor });
|
|
32
56
|
},
|
|
33
57
|
};
|
|
34
58
|
}
|
|
@@ -65,9 +89,14 @@ export function dueEntries(entries, lastChecked, { now = Date.now() } = {}) {
|
|
|
65
89
|
*/
|
|
66
90
|
export async function runCheck(entries, {
|
|
67
91
|
concurrency = 6, timeoutMs = 20_000, hostDelayMs = 2000,
|
|
68
|
-
verify = verifyLink, pacer = null, onResult = null,
|
|
92
|
+
verify = verifyLink, pacer = null, onResult = null, fetchImpl = null,
|
|
69
93
|
} = {}) {
|
|
94
|
+
if (fetchImpl != null && typeof fetchImpl !== 'function') throw new TypeError('A local check transport must be a function.');
|
|
70
95
|
const paced = pacer ?? createPacer({ hostDelayMs });
|
|
96
|
+
// A trusted in-process caller may supply a fixture transport. Normal CLI
|
|
97
|
+
// commands always use the pinned Node connector, independently of global fetch.
|
|
98
|
+
const nativeTransport = fetchImpl == null;
|
|
99
|
+
const localFetch = fetchImpl ?? createNodePublicFetch();
|
|
71
100
|
const byHost = new Map();
|
|
72
101
|
for (const entry of entries) {
|
|
73
102
|
const host = hostOf(entry.source) ?? entry.id;
|
|
@@ -84,8 +113,9 @@ export async function runCheck(entries, {
|
|
|
84
113
|
expectedAnchor: entry.expect?.anchor, expectedRel: entry.expect?.rel },
|
|
85
114
|
// `includeHtml: false` is a contract commitment, not a size optimisation: the bytes
|
|
86
115
|
// must not reach the repository.
|
|
87
|
-
{ publicFetchSafe: true, timeoutMs, includeHtml: false, beforeFetch: paced.beforeFetch
|
|
88
|
-
|
|
116
|
+
{ publicFetchSafe: true, fetchImpl: localFetch, timeoutMs, includeHtml: false, beforeFetch: paced.beforeFetch,
|
|
117
|
+
...(verify === verifyLink && nativeTransport ? { captureTransport: { id: 'local_node_http', version: 'alo-local-node-v1' } } : {}) });
|
|
118
|
+
const row = observationRow(entry.id, portableCheckObservation(result));
|
|
89
119
|
rows.set(entry.id, row);
|
|
90
120
|
onResult?.(entry, row);
|
|
91
121
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {readFile,writeFile,mkdir,copyFile,unlink} from 'node:fs/promises';
|
|
2
|
+
import {constants} from 'node:fs';
|
|
3
|
+
import {dirname,resolve} from 'node:path';
|
|
4
|
+
import {loadConfig,ConfigError} from './config.js';
|
|
5
|
+
import {cloudConnection} from './connection.js';
|
|
6
|
+
import {createClient} from './client.js';
|
|
7
|
+
import {freezeChronology,normalizeLocalSubmissions} from '../shared/monitoring-chronology-export.js';
|
|
8
|
+
const help='agentlinkops chronology pull --watch-id ID [--limit 50] [--cursor TOKEN] [--all] [--out NEW_FILE] | export --pages FILE [--submissions FILE] [--out NEW_FILE]';
|
|
9
|
+
async function output(value,args,{cwd,out}){
|
|
10
|
+
const text=JSON.stringify(value,null,2)+'\n';
|
|
11
|
+
if(!args.out){out(text.trimEnd());return;}
|
|
12
|
+
if(typeof args.out!=='string'||!args.out.trim())throw new ConfigError('--out requires a file');
|
|
13
|
+
const path=resolve(cwd,args.out),temp=`${path}.${crypto.randomUUID()}.tmp`;
|
|
14
|
+
await mkdir(dirname(path),{recursive:true});
|
|
15
|
+
try{await writeFile(temp,text,{flag:'wx',mode:0o600});await copyFile(temp,path,constants.COPYFILE_EXCL);}finally{await unlink(temp).catch(e=>{if(e.code!=='ENOENT')throw e;});}
|
|
16
|
+
out(`wrote ${path}`);
|
|
17
|
+
}
|
|
18
|
+
export async function chronologyMain(args,{cwd=process.cwd(),env=process.env,fetchImpl=globalThis.fetch,out=console.log}={}){
|
|
19
|
+
const command=args._[1],allowed=new Set(['_','tag','watch-id','limit','cursor','all','out','pages','submissions']);
|
|
20
|
+
if(args._.length!==2||args.tag?.length||Object.keys(args).some(key=>!allowed.has(key)))throw new ConfigError(help);
|
|
21
|
+
if(command==='export'){
|
|
22
|
+
if(typeof args.pages!=='string'||args['watch-id']!==undefined||args.limit!==undefined||args.cursor!==undefined||args.all!==undefined)throw new ConfigError(help);
|
|
23
|
+
const input=JSON.parse(await readFile(resolve(cwd,args.pages),'utf8')),pages=Array.isArray(input)?input:[input];
|
|
24
|
+
const localItems=args.submissions?await normalizeLocalSubmissions(JSON.parse(await readFile(resolve(cwd,args.submissions),'utf8')),pages[0]?.identity):[];
|
|
25
|
+
await output(await freezeChronology(pages,{localItems}),args,{cwd,out});return 0;
|
|
26
|
+
}
|
|
27
|
+
if(command!=='pull'||typeof args['watch-id']!=='string'||args.pages!==undefined||args.submissions!==undefined||args.all!==undefined&&args.all!==true)throw new ConfigError(help);
|
|
28
|
+
const limit=Number(args.limit??50);if(!Number.isInteger(limit)||limit<1||limit>100||args.cursor===true)throw new ConfigError(help);
|
|
29
|
+
const config=await loadConfig({cwd}),connection=cloudConnection(config,env);
|
|
30
|
+
if(!connection.projectId)throw new ConfigError('Connect this project before reading hosted chronology.');
|
|
31
|
+
const client=createClient({...connection,fetchImpl}),pages=[];let cursor=args.cursor;
|
|
32
|
+
do{
|
|
33
|
+
const page=await client.callCommand('get_placement_chronology',{watchId:args['watch-id'],limit,...(cursor?{cursor}: {})});
|
|
34
|
+
if(page.identity?.projectId!==connection.projectId||page.identity?.watchId!==args['watch-id']||connection.workspaceId&&page.identity?.workspaceId!==connection.workspaceId)throw new ConfigError('Chronology identity does not match the connected project and selected watch.');
|
|
35
|
+
pages.push(page);cursor=page.nextCursor;
|
|
36
|
+
if(pages.length===100&&args.all&&cursor)throw new ConfigError('Chronology exceeds the 100-page read bound. Pull explicit pages and export their qualified partial dataset.');
|
|
37
|
+
}while(args.all&&cursor);
|
|
38
|
+
await output(args.all?await freezeChronology(pages):pages[0],args,{cwd,out});return 0;
|
|
39
|
+
}
|
package/cli/citation.js
CHANGED
|
File without changes
|
package/cli/client.js
CHANGED
|
@@ -5,7 +5,7 @@ export {CloudError} from './cloud-error.js';
|
|
|
5
5
|
// A small HTTP client for the cloud. Scoped keys only; no human session ever reaches here.
|
|
6
6
|
// DP-0036-T13: the CLI names itself so the usage counters can tell it from other REST callers.
|
|
7
7
|
// The version follows the plugin manifests; bump it with them.
|
|
8
|
-
export const CLI_VERSION = '0.6.
|
|
8
|
+
export const CLI_VERSION = '0.6.16';
|
|
9
9
|
export const CLI_USER_AGENT = `agentlinkops-cli/${CLI_VERSION}`;
|
|
10
10
|
const MAX_ATTEMPTS = 3;
|
|
11
11
|
const MAX_RETRY_WAIT_MS = 5_000;
|
|
@@ -113,6 +113,7 @@ export function createClient({ origin, token, workspaceId, projectId, fetchImpl
|
|
|
113
113
|
callCommand: (name, args = {}) => call('POST', `/v1/commands/${encodeURIComponent(name)}`, { body: args }),
|
|
114
114
|
getWorkspace: () => call('GET', '/v1/workspace'),
|
|
115
115
|
getProject: projectId => call('GET', `/v1/projects/${encodeURIComponent(projectId)}`),
|
|
116
|
+
listProjects: query => call('GET', '/v1/projects', { query }),
|
|
116
117
|
importWatches: (projectId, watches) => call('POST', '/v1/watches/import', { body: { projectId, watches } }),
|
|
117
118
|
updateWatch: (watchId, patch) => call('PATCH', `/v1/watches/${encodeURIComponent(watchId)}`, { body: patch }),
|
|
118
119
|
listWatches: query => call('GET', '/v1/watches', { query: { ...query, ...(projectId ? { projectId } : {}) } }),
|
package/cli/cloud-error.js
CHANGED
|
File without changes
|
package/cli/commands.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { loadConfig, ConfigError } from './config.js';
|
|
4
|
-
import { cloudConnection } from './connection.js';
|
|
4
|
+
import { cloudConnection, DEFAULT_ORIGIN } from './connection.js';
|
|
5
5
|
import { createClient, CloudError } from './client.js';
|
|
6
6
|
import { parseArgs } from './args.js';
|
|
7
7
|
import { NUDGE } from './skill.js';
|
|
8
|
-
import { CATALOG_COMMANDS, CATALOG_TOOLSETS } from './catalog.js';
|
|
8
|
+
import { CATALOG_COMMANDS, CATALOG_VERSION, CATALOG_TOOLSETS } from './catalog.js';
|
|
9
9
|
import { oneLiner } from '../src/tool-views.js';
|
|
10
10
|
import { resolveSchemaPath, listAvailablePaths } from '../shared/exec-grammar.js';
|
|
11
11
|
|
|
@@ -24,7 +24,7 @@ const NEXT = {
|
|
|
24
24
|
INSUFFICIENT_SCOPE: 'This credential lacks a scope the command needs; request it through your connection or a new project-scoped key.',
|
|
25
25
|
NO_CLOUD_ORIGIN: 'Run `agentlinkops connect` or set AGENTLINKOPS_API_URL.',
|
|
26
26
|
INVALID_CLOUD_ORIGIN: 'Cloud commands need an HTTPS origin without a path, for example https://app.agentlinkops.com.',
|
|
27
|
-
NO_TOKEN: '
|
|
27
|
+
NO_TOKEN: 'Run `npx -y agentlinkops connect` once in a terminal and paste an API key from the Agent page; scripts can set AGENTLINKOPS_API_KEY instead.',
|
|
28
28
|
PROVIDER_NOT_CONFIGURED: 'An operator must enable this supplier admission; retrying does not change it.',
|
|
29
29
|
OWNED_DISCOVERY_NOT_CONFIGURED: 'An operator must configure owned discovery; retrying does not change it.',
|
|
30
30
|
IDEMPOTENCY_CONFLICT: 'Reuse an idempotency key only with identical arguments; choose a new key for a new request.',
|
|
@@ -34,9 +34,16 @@ export const explain = code => NEXT[code] ?? 'Run `agentlinkops describe NAME` a
|
|
|
34
34
|
export const resolveName = (commands, name) => commands.find(c => c.name === name) ?? commands.find(c => c.aliases?.includes(name)) ?? null;
|
|
35
35
|
|
|
36
36
|
// The index: grouped by toolset, one line per command, core and write and admission marked.
|
|
37
|
+
// Both forms drop per-command summaries when the complete index would exceed its token estimate
|
|
38
|
+
// (2,000 for the terminal, 6,000 for JSON); one toolset keeps them, and describe has the rest.
|
|
37
39
|
export function renderIndex(commands, { toolset = null, json = false } = {}) {
|
|
38
40
|
const selected = toolset ? commands.filter(c => c.toolset === toolset) : commands;
|
|
39
|
-
if (json)
|
|
41
|
+
if (json) {
|
|
42
|
+
const index = summaries => JSON.stringify({ version:CATALOG_VERSION,count:selected.length,toolsets: toolset ? { [toolset]: CATALOG_TOOLSETS[toolset] } : CATALOG_TOOLSETS, defaults: { tier: 'deferred' }, commands: selected.map(c => ({ name: c.name, toolset: c.toolset, ...(c.tier === 'deferred' ? {} : { tier: c.tier }), readOnly: c.readOnly, ...(summaries ? { summary: oneLiner(c.description, 40) } : {}), ...(c.admissionGated ? { admission: 'gated' } : {}), ...(c.aliases?.length ? { aliases: c.aliases } : {}) })),
|
|
43
|
+
next: (summaries ? '' : 'Run `agentlinkops tools TOOLSET` for one-line summaries. ') + 'Run `agentlinkops describe NAME` for a schema, then `agentlinkops call NAME --args JSON`.' });
|
|
44
|
+
const full = index(true);
|
|
45
|
+
return Math.round(full.length / 3.6) < 6000 ? full : index(false);
|
|
46
|
+
}
|
|
40
47
|
const groups = new Map();
|
|
41
48
|
for (const c of selected) (groups.get(c.toolset) ?? groups.set(c.toolset, []).get(c.toolset)).push(c);
|
|
42
49
|
const lines = [];
|
|
@@ -46,7 +53,9 @@ export function renderIndex(commands, { toolset = null, json = false } = {}) {
|
|
|
46
53
|
lines.push('');
|
|
47
54
|
}
|
|
48
55
|
lines.push(`${selected.length} commands${toolset ? ` in ${toolset}` : ''}. describe NAME shows the schema; call NAME --args JSON runs it.`);
|
|
49
|
-
|
|
56
|
+
const full = lines.join('\n');
|
|
57
|
+
if (Math.round(full.length / 3.6) < 2000) return full;
|
|
58
|
+
return lines.map(line => line.startsWith(' ') ? line.slice(0, line.indexOf(' ', 2)) : line).join('\n');
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
// One definition: what a describe_tools call returns, printed for a terminal or as JSON.
|
|
@@ -137,12 +146,12 @@ export async function commandsMain(argv, { cwd = process.cwd(), env = process.en
|
|
|
137
146
|
// Catalog: bundled snapshot, or the live one when asked (needs a configured origin and key).
|
|
138
147
|
let commands = CATALOG_COMMANDS, client = null;
|
|
139
148
|
const connect = () => {
|
|
149
|
+
// The hosted service unless an origin is configured; the key decides the workspace.
|
|
140
150
|
const connection = cloudConnection(loadConfigSync(), env);
|
|
141
|
-
if (!connection.origin) throw new CloudError('NO_CLOUD_ORIGIN', 0);
|
|
142
151
|
let origin;
|
|
143
|
-
try { origin = new URL(connection.origin); } catch { throw new CloudError('INVALID_CLOUD_ORIGIN', 0); }
|
|
152
|
+
try { origin = new URL(connection.origin || DEFAULT_ORIGIN); } catch { throw new CloudError('INVALID_CLOUD_ORIGIN', 0); }
|
|
144
153
|
if (origin.protocol !== 'https:' || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash) throw new CloudError('INVALID_CLOUD_ORIGIN', 0);
|
|
145
|
-
return createClient({ ...connection, fetchImpl });
|
|
154
|
+
return createClient({ ...connection, origin: origin.origin, fetchImpl });
|
|
146
155
|
};
|
|
147
156
|
let loaded = null;
|
|
148
157
|
const loadConfigSync = () => loaded;
|
package/cli/config.js
CHANGED
|
File without changes
|
package/cli/connection.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { acquireSyncLock } from './sync-lock.js';
|
|
2
|
-
import { readFile, writeFile, rename, unlink, open } from 'node:fs/promises';
|
|
2
|
+
import { readFile, writeFile, rename, unlink, open, stat } from 'node:fs/promises';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { loadConfig, ConfigError } from './config.js';
|
|
5
5
|
import { createClient } from './client.js';
|
|
@@ -7,8 +7,10 @@ import { readLedger } from './ledger.js';
|
|
|
7
7
|
import { resolveEnv } from './env.js';
|
|
8
8
|
import { createOAuthCredentialStore } from './oauth-credentials.js';
|
|
9
9
|
import { discoverOAuth, receiveOAuthConsent, exchangeOAuthCode } from './oauth-flow.js';
|
|
10
|
+
import { API_KEY_PATTERN, STORED_KEY } from './key-store.js';
|
|
10
11
|
|
|
11
12
|
export const SYNC_SCOPES = ['projects:read', 'watches:read', 'watches:write', 'events:read', 'exports:create'];
|
|
13
|
+
export const DEFAULT_ORIGIN = 'https://app.agentlinkops.com';
|
|
12
14
|
|
|
13
15
|
// Both TOKEN and API_KEY have shipped in customer installations, under both the AGENTLINKOPS_
|
|
14
16
|
// and the LINKTRAIL_ prefix (env.js owns that alias rule). Reject ambiguous identities: a
|
|
@@ -22,9 +24,16 @@ export function cloudConnection(config, env = process.env) {
|
|
|
22
24
|
throw new ConfigError(`${sources.API_URL} disagrees with the saved cloud origin.`);
|
|
23
25
|
if (new Set([config.cloud?.workspaceId, config.cloud?.workspace_id].filter(Boolean)).size > 1 || new Set([config.project?.id, config.cloud?.projectId, config.cloud?.project_id].filter(Boolean)).size > 1)
|
|
24
26
|
throw new ConfigError('Saved cloud identity fields disagree.');
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
let origin = values.API_URL || config.cloud?.origin;
|
|
28
|
+
let token = values.TOKEN || values.API_KEY || config.cloud?.token;
|
|
29
|
+
let tokenSource = values.TOKEN ? sources.TOKEN : values.API_KEY ? sources.API_KEY : null, storedKey = false;
|
|
27
30
|
const auth = config.cloud?.auth;
|
|
31
|
+
// Otherwise the key `agentlinkops connect` saved for this origin, or for the default origin when
|
|
32
|
+
// none is configured. Only main() attaches the reader, so library callers and tests see none.
|
|
33
|
+
if (!token && auth === undefined && typeof env[STORED_KEY] === 'function') {
|
|
34
|
+
const saved = env[STORED_KEY](origin || DEFAULT_ORIGIN);
|
|
35
|
+
if (saved) ({ key: token, where: tokenSource } = saved), origin ||= DEFAULT_ORIGIN, storedKey = true;
|
|
36
|
+
}
|
|
28
37
|
if (auth !== undefined) {
|
|
29
38
|
if (!auth || auth.kind !== 'oauth' || token || Object.keys(auth).sort().join(',') !== 'clientId,credentialRef,issuer,kind,resource')
|
|
30
39
|
throw new ConfigError('Saved OAuth connection conflicts with an API key or has an invalid authentication mode.');
|
|
@@ -36,24 +45,58 @@ export function cloudConnection(config, env = process.env) {
|
|
|
36
45
|
throw new ConfigError('Saved OAuth binding is invalid.');
|
|
37
46
|
}
|
|
38
47
|
return { origin, ...(auth ? { auth: { ...auth } } : {}),
|
|
39
|
-
token,
|
|
40
|
-
tokenSource: values.TOKEN ? sources.TOKEN : values.API_KEY ? sources.API_KEY : null,
|
|
48
|
+
token, tokenSource, ...(storedKey ? { storedKey } : {}),
|
|
41
49
|
workspaceId: config.cloud?.workspaceId || config.cloud?.workspace_id, projectId: config.project?.id || config.cloud?.projectId || config.cloud?.project_id };
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
export function keySetup(origin) {
|
|
45
|
-
return `
|
|
53
|
+
return `Create an API key on the Agent page (${origin}/#/access, Create API key), then run \`npx -y agentlinkops connect\` in a terminal and paste it when asked; the CLI keeps it outside your repository. Scripts and CI can supply it through AGENTLINKOPS_API_KEY instead (AGENTLINKOPS_TOKEN also works; the LINKTRAIL_ spellings remain accepted during the pilot compatibility window). Ledger sync needs the scopes ${SYNC_SCOPES.join(', ')}. For CLI OAuth on Linux, use connect --oauth with an existing public client; credentials stay in the user-owned POSIX-mode store. On other platforms, use OAuth in your MCP client. MCP clients manage their own credentials.`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const pastePrompt = origin => `Paste an API key from ${origin}/#/access (Create API key), or press Enter to cancel: `;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The CLI half of connecting: a key that works, saved where cloud commands find it. Asks for one
|
|
60
|
+
* when none is available and a terminal is (ask), and again when a saved key stopped working.
|
|
61
|
+
* Verifies before saving; never prints the key. Returns null when there is no key to use.
|
|
62
|
+
*/
|
|
63
|
+
export async function connectKey({ origin, token = null, stored = false, ask = null, prompt = pastePrompt(origin), keyStore = null,
|
|
64
|
+
fetchImpl = globalThis.fetch, clientFactory = createClient, out = console.log }) {
|
|
65
|
+
let pasted = false, account;
|
|
66
|
+
for (;;) {
|
|
67
|
+
if (!token) {
|
|
68
|
+
token = ask ? String(await ask(prompt) ?? '').trim() : '';
|
|
69
|
+
if (!token) return null;
|
|
70
|
+
pasted = true;
|
|
71
|
+
}
|
|
72
|
+
if (!API_KEY_PATTERN.test(token))
|
|
73
|
+
throw new ConfigError(pasted ? 'That is not an AgentLinkOps API key (keys start with lt_). Create one on the Agent page and paste all of it.' : 'Supply a complete API key through AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY.');
|
|
74
|
+
try { account = await clientFactory({ origin, token, fetchImpl }).getWorkspace(); break; }
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (!(stored && !pasted && ask && error?.status === 401)) throw error;
|
|
77
|
+
out('The saved key no longer works (revoked or expired).');
|
|
78
|
+
token = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (typeof account?.workspace?.id !== 'string' || !account.workspace.id) throw new ConfigError('The cloud did not name the workspace this key belongs to.');
|
|
82
|
+
if (pasted) {
|
|
83
|
+
if (!keyStore) throw new ConfigError('This command cannot save a key here. Set AGENTLINKOPS_API_KEY instead.');
|
|
84
|
+
await keyStore.save(origin, token);
|
|
85
|
+
out(`Saved the key in ${keyStore.where}.`);
|
|
86
|
+
}
|
|
87
|
+
return { token, account, saved: pasted };
|
|
46
88
|
}
|
|
47
89
|
|
|
48
90
|
// Verify an existing project before saving only non-secret connection metadata.
|
|
49
91
|
export async function connectMain(args, { cwd, env = process.env, out = console.log, fetchImpl = globalThis.fetch,
|
|
50
92
|
credentialStore = createOAuthCredentialStore(), oauth = { discoverOAuth, receiveOAuthConsent, exchangeOAuthCode },
|
|
51
|
-
onAuthorization = ({ authorizationUrl }) => out(`Authorize the connection: ${authorizationUrl}`), clientFactory = createClient
|
|
93
|
+
onAuthorization = ({ authorizationUrl }) => out(`Authorize the connection: ${authorizationUrl}`), clientFactory = createClient,
|
|
94
|
+
keyStore = null, ask = null } = {}) {
|
|
52
95
|
const config = await loadConfig({ cwd });
|
|
53
96
|
const connection = cloudConnection(config, env);
|
|
54
|
-
const origin = args.origin || connection.origin ||
|
|
55
|
-
|
|
56
|
-
|
|
97
|
+
const origin = args.origin || connection.origin || DEFAULT_ORIGIN;
|
|
98
|
+
let workspaceId = args.workspace || connection.workspaceId;
|
|
99
|
+
let projectId = args['project-id'] || connection.projectId;
|
|
57
100
|
let parsed;
|
|
58
101
|
try { parsed = new URL(origin); } catch { throw new ConfigError('connect --origin requires an HTTPS origin.'); }
|
|
59
102
|
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash || parsed.pathname !== '/')
|
|
@@ -62,13 +105,30 @@ export async function connectMain(args, { cwd, env = process.env, out = console.
|
|
|
62
105
|
if (args.oauth !== undefined && args.oauth !== true) throw new ConfigError('--oauth does not take a value.');
|
|
63
106
|
if (!oauthRequested && ['issuer', 'client-id', 'credential-ref'].some(key => args[key] !== undefined))
|
|
64
107
|
throw new ConfigError('OAuth options require --oauth.');
|
|
65
|
-
if (oauthRequested && connection.token) throw new ConfigError('Remove API-key credentials before selecting OAuth.');
|
|
66
|
-
if (
|
|
67
|
-
if (!oauthRequested && !/^lt_[0-9a-f]{64}$/.test(connection.token)) throw new ConfigError('Supply a complete API key through AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY.');
|
|
68
|
-
if (typeof workspaceId !== 'string' || !workspaceId || typeof projectId !== 'string' || !projectId)
|
|
69
|
-
throw new ConfigError('connect requires --workspace ID and --project-id ID from the existing cloud project.');
|
|
70
|
-
if (config.cloud?.origin && new URL(config.cloud.origin).origin !== parsed.origin || connection.workspaceId && connection.workspaceId !== workspaceId || connection.projectId && connection.projectId !== projectId)
|
|
108
|
+
if (oauthRequested && connection.token && !connection.storedKey) throw new ConfigError('Remove API-key credentials before selecting OAuth.');
|
|
109
|
+
if (config.cloud?.origin && new URL(config.cloud.origin).origin !== parsed.origin || connection.workspaceId && workspaceId !== connection.workspaceId || connection.projectId && projectId !== connection.projectId)
|
|
71
110
|
throw new ConfigError('This ledger is connected elsewhere; use a separate ledger to preserve its cursors and mapping.');
|
|
111
|
+
if (oauthRequested && (typeof workspaceId !== 'string' || !workspaceId || typeof projectId !== 'string' || !projectId))
|
|
112
|
+
throw new ConfigError('connect --oauth requires --workspace ID and --project-id ID from the existing cloud project.');
|
|
113
|
+
// A key saved for one origin never travels to another.
|
|
114
|
+
let token = oauthRequested ? undefined : connection.storedKey && connection.origin !== parsed.origin ? env[STORED_KEY]?.(parsed.origin)?.key : connection.token;
|
|
115
|
+
const hasLedger = await stat(join(config.dir, 'config.json')).then(() => true, () => false);
|
|
116
|
+
let keyWhere = connection.storedKey ? connection.tokenSource : null;
|
|
117
|
+
if (!oauthRequested && (!token || !workspaceId)) {
|
|
118
|
+
// No IDs to look up: the key names its workspace. Without a ledger here, this connects the CLI
|
|
119
|
+
// for cloud commands in any folder; with one, it goes on to map the ledger to a website.
|
|
120
|
+
const key = await connectKey({ origin: parsed.origin, token, stored: connection.storedKey === true, ask, keyStore, fetchImpl, clientFactory, out });
|
|
121
|
+
if (!key) { out(keySetup(parsed.origin)); return 2; }
|
|
122
|
+
token = key.token;
|
|
123
|
+
if (key.saved) keyWhere = keyStore.where;
|
|
124
|
+
workspaceId ||= key.account.workspace.id;
|
|
125
|
+
if (!hasLedger && !projectId) {
|
|
126
|
+
out(`Connected the CLI to ${key.account.workspace.name || 'your workspace'}. Cloud commands work from any folder now, for example: agentlinkops call list_tasks`);
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!oauthRequested && !API_KEY_PATTERN.test(token)) throw new ConfigError('Supply a complete API key through AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY.');
|
|
131
|
+
if (!hasLedger) throw new ConfigError('No ledger here to map to a website: run `agentlinkops init` first, or omit --project-id to connect the CLI only.');
|
|
72
132
|
let ledgerIds;
|
|
73
133
|
if (args.selection !== undefined) {
|
|
74
134
|
if (typeof args.selection !== 'string') throw new ConfigError('--selection requires a JSON file of ledger IDs or mapping entries.');
|
|
@@ -106,9 +166,23 @@ export async function connectMain(args, { cwd, env = process.env, out = console.
|
|
|
106
166
|
}
|
|
107
167
|
}
|
|
108
168
|
try {
|
|
109
|
-
const client = clientFactory({ origin: parsed.origin, token
|
|
169
|
+
const client = clientFactory({ origin: parsed.origin, token, ...(auth ? {auth, credentialStore} : {}), workspaceId, projectId, fetchImpl });
|
|
110
170
|
const account = await client.getWorkspace();
|
|
111
171
|
if (account.workspace?.id !== workspaceId) throw new ConfigError('Cloud workspace does not match the selected workspace.');
|
|
172
|
+
if (!projectId) {
|
|
173
|
+
// Which website this ledger tracks: the key's only website, or the workspace's only one.
|
|
174
|
+
const allowed = account.access?.project_ids;
|
|
175
|
+
const projects = Array.isArray(allowed) && allowed.length === 1 ? null : ((await client.listProjects({ limit: 100 })).items ?? [])
|
|
176
|
+
.filter(project => !Array.isArray(allowed) || allowed.includes(project.id));
|
|
177
|
+
if (!projects) projectId = allowed[0];
|
|
178
|
+
else if (projects.length === 1) projectId = projects[0].id;
|
|
179
|
+
else {
|
|
180
|
+
out(projects.length
|
|
181
|
+
? ['Choose the website this ledger tracks, then run its command:', ...projects.map(project => ` agentlinkops connect --project-id ${project.id} # ${project.domain || project.name || project.id}`)].join('\n')
|
|
182
|
+
: `Add a website on ${parsed.origin}/#/websites first, then run agentlinkops connect again.`);
|
|
183
|
+
return 2;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
112
186
|
// MCP readback does not expose an authoritative effective write grant.
|
|
113
187
|
const missing = auth ? [] : SYNC_SCOPES.filter(scope => !account.access?.scopes?.includes(scope));
|
|
114
188
|
if (missing.length) throw new ConfigError(`Key lacks sync scopes: ${missing.join(', ')}.`);
|
|
@@ -142,7 +216,7 @@ export async function connectMain(args, { cwd, env = process.env, out = console.
|
|
|
142
216
|
}
|
|
143
217
|
out(auth
|
|
144
218
|
? `Verified workspace ${workspaceId}, project ${projectId}. Saved OAuth credential reference; write permission remains unverified. Run agentlinkops sync --pull-only to retrieve history.`
|
|
145
|
-
: `Verified workspace ${workspaceId}, project ${projectId}. Saved connection metadata; keep the key in the environment. Run agentlinkops sync --dry-run to review uploads, or agentlinkops sync --pull-only to retrieve history.`);
|
|
219
|
+
: `Verified workspace ${workspaceId}, project ${projectId}. Saved connection metadata; ${keyWhere ? `the key stays in ${keyWhere}` : 'keep the key in the environment'}. Run agentlinkops sync --dry-run to review uploads, or agentlinkops sync --pull-only to retrieve history.`);
|
|
146
220
|
return 0;
|
|
147
221
|
} catch (error) {
|
|
148
222
|
// A failed connection must not strand a newly created local secret.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto';
|
|
2
|
+
import {contactFields} from '../shared/contact-record-contract.js';
|
|
3
|
+
|
|
4
|
+
const digest=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
5
|
+
const fields=c=>({externalRef:c.externalRef,name:c.name,email:c.email,contactUrl:c.contactUrl});
|
|
6
|
+
const equal=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
|
|
7
|
+
|
|
8
|
+
/** A contact-only bridge. Inputs are frozen exports; there are no source-CRM writes. */
|
|
9
|
+
export function planContactAdoption({projectId,rows,contacts=[],links=[]}){
|
|
10
|
+
const seen=new Map(),results=[];
|
|
11
|
+
for(const [index,row]of rows.entries()){
|
|
12
|
+
const parsed=contactFields.safeParse(row.contact);
|
|
13
|
+
let reason=!parsed.success?'invalid_contact':null;
|
|
14
|
+
const contact=parsed.success?parsed.data:null;
|
|
15
|
+
const sameRef=contact?contacts.filter(c=>c.externalRef===contact.externalRef):[];
|
|
16
|
+
if(sameRef.length>1)reason='ambiguous_contact_reference';
|
|
17
|
+
if(contact&&seen.has(contact.externalRef)&&!equal(seen.get(contact.externalRef),contact))reason='conflicting_source_contact';
|
|
18
|
+
if(contact)seen.set(contact.externalRef,contact);
|
|
19
|
+
const matches=links.filter(l=>l.localReference===row.localReference);
|
|
20
|
+
if(!row.localReference||matches.length!==1)reason=matches.length>1?'ambiguous_link_reference':'link_reference_not_found';
|
|
21
|
+
const beforeContact=sameRef[0]??null,beforeLink=matches[0]??null;
|
|
22
|
+
if(beforeContact&&beforeContact.projectId!==projectId||beforeLink&&beforeLink.projectId!==projectId)reason='project_mismatch';
|
|
23
|
+
results.push({row:index+1,localReference:row.localReference??null,contact,beforeContact,beforeLink,status:reason?'conflict':'ready',reason});
|
|
24
|
+
}
|
|
25
|
+
// Reject every row in a conflicting identity group; input order cannot pick a winner.
|
|
26
|
+
const bad=new Set(results.filter(r=>r.reason==='conflicting_source_contact').map(r=>r.contact.externalRef));
|
|
27
|
+
for(const row of results)if(bad.has(row.contact?.externalRef)){row.status='conflict';row.reason='conflicting_source_contact';}
|
|
28
|
+
const byLink=new Map();
|
|
29
|
+
for(const row of results){if(!row.beforeLink)continue;const group=byLink.get(row.beforeLink.watchId)??[];group.push(row);byLink.set(row.beforeLink.watchId,group);}
|
|
30
|
+
for(const group of byLink.values())if(new Set(group.map(row=>row.contact?.externalRef)).size>1)for(const row of group){row.status='conflict';row.reason='conflicting_link_contact';}
|
|
31
|
+
const plan={schemaVersion:'agentlinkops-contact-adoption-v1',projectId,results,effects:{sourceWrites:0,emails:0,sequenceEnrollments:0,newWatches:0,checks:0}};
|
|
32
|
+
return{...plan,sha256:digest(plan)};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function verifyPlan(plan){const {sha256,...body}=plan;if(body.schemaVersion!=='agentlinkops-contact-adoption-v1'||digest(body)!==sha256)throw new Error('ADOPTION_PLAN_CHANGED');}
|
|
36
|
+
/** Caller supplies the authenticated command transport; no credentials or mailbox are read here. */
|
|
37
|
+
export async function applyContactAdoption(plan,call){
|
|
38
|
+
verifyPlan(plan);const results=[],effects={...plan.effects,confirmedContactWrites:0,confirmedAssociationWrites:0,rowsRequiringReadback:0};
|
|
39
|
+
for(const row of plan.results){
|
|
40
|
+
if(row.status!=='ready'){results.push({row:row.row,status:'conflict',reason:row.reason});continue;}
|
|
41
|
+
const base={projectId:plan.projectId};let saved=null,writeAttempted=false;
|
|
42
|
+
try{
|
|
43
|
+
const current=await call('list_contacts',{...base,externalRef:row.contact.externalRef,watchId:row.beforeLink.watchId,limit:2});
|
|
44
|
+
const contact=current.items[0]??null;
|
|
45
|
+
if(current.items.length>1)throw new Error('CONTACT_IDENTITY_CONFLICT');
|
|
46
|
+
// Exact desired content is a safe replay. A changed source preview never overwrites a human edit.
|
|
47
|
+
if(contact&&!equal(fields(contact),row.contact)&&(!row.beforeContact||contact.revision!==row.beforeContact.revision||!equal(fields(contact),fields(row.beforeContact))))throw new Error('CONTACT_CHANGED_SINCE_PREVIEW');
|
|
48
|
+
if(!contact&&row.beforeContact)throw new Error('CONTACT_REMOVED_SINCE_PREVIEW');
|
|
49
|
+
if(current.link?.contactId!==row.beforeLink.contactId&¤t.link?.contactId!==contact?.id)throw new Error('LINK_CHANGED_SINCE_PREVIEW');
|
|
50
|
+
writeAttempted=true;saved=await call('save_contact',{...base,revision:contact?.revision??0,contact:row.contact});
|
|
51
|
+
if(!saved.replayed)effects.confirmedContactWrites++;
|
|
52
|
+
const linked=await call('set_link_contact',{...base,watchId:row.beforeLink.watchId,contactId:saved.contact.id,revision:current.link?.revision??0});
|
|
53
|
+
if(linked.revision!==(current.link?.revision??0))effects.confirmedAssociationWrites++;
|
|
54
|
+
results.push({row:row.row,status:'applied',contactId:saved.contact.id,contactRevision:saved.contact.revision,link:linked,replayed:saved.replayed,rollback:{beforeContact:row.beforeContact,beforeLink:row.beforeLink,afterContact:saved.contact,afterLink:linked}});
|
|
55
|
+
}catch(error){if(writeAttempted)effects.rowsRequiringReadback++;results.push({row:row.row,status:'conflict',reason:error.code??error.message,effectState:writeAttempted?'requires_readback':'none',...(saved?{contactSaved:true,contactId:saved.contact.id,contactRevision:saved.contact.revision}:{} )});}
|
|
56
|
+
}
|
|
57
|
+
return{schemaVersion:plan.schemaVersion,planSha256:plan.sha256,projectId:plan.projectId,results,effects};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Explicit compensating rollback. Concurrent edits hold the row; sender/source data is untouched. */
|
|
61
|
+
export async function rollbackContactAdoption(receipt,call){
|
|
62
|
+
const results=[],effects={sourceWrites:0,emails:0,sequenceEnrollments:0,newWatches:0,checks:0,confirmedAssociationWrites:0,confirmedContactWrites:0,confirmedContactDeletes:0,rowsRequiringReadback:0};
|
|
63
|
+
for(const result of [...receipt.results].reverse()){
|
|
64
|
+
if(!result.rollback){results.push({row:result.row,status:'no_effect'});continue;}
|
|
65
|
+
const {beforeContact,beforeLink,afterContact,afterLink}=result.rollback,base={projectId:receipt.projectId};
|
|
66
|
+
try{
|
|
67
|
+
const current=await call('list_contacts',{...base,externalRef:afterContact.externalRef,watchId:afterLink.watchId,limit:2});
|
|
68
|
+
const contact=current.items[0];
|
|
69
|
+
if(current.link.contactId===beforeLink.contactId&&(!beforeContact&&!contact||beforeContact&&contact&&equal(fields(contact),fields(beforeContact)))){results.push({row:result.row,status:'already_restored'});continue;}
|
|
70
|
+
if(!contact||contact.revision!==afterContact.revision||!equal(fields(contact),fields(afterContact)))throw new Error('ROLLBACK_CONCURRENT_CHANGE');
|
|
71
|
+
if(current.link.contactId!==beforeLink.contactId){
|
|
72
|
+
if(current.link.contactId!==afterLink.contactId||current.link.revision!==afterLink.revision)throw new Error('ROLLBACK_CONCURRENT_CHANGE');
|
|
73
|
+
const restoredLink=await call('set_link_contact',{...base,watchId:afterLink.watchId,contactId:beforeLink.contactId,revision:afterLink.revision});
|
|
74
|
+
if(restoredLink.revision!==afterLink.revision)effects.confirmedAssociationWrites++;
|
|
75
|
+
}
|
|
76
|
+
// Newly created contacts can be shared by later rows. Restore/delete once, after all links.
|
|
77
|
+
results.push({row:result.row,status:'link_restored',contactId:afterContact.id,beforeContact,afterContact});
|
|
78
|
+
}catch(error){effects.rowsRequiringReadback++;results.push({row:result.row,status:'held',reason:error.code??error.message});}
|
|
79
|
+
}
|
|
80
|
+
const restored=new Set();
|
|
81
|
+
for(const row of results){
|
|
82
|
+
if(row.status!=='link_restored'||restored.has(row.contactId))continue;
|
|
83
|
+
// Do not erase a new contact if another row failed to detach; preserve useful partial state.
|
|
84
|
+
if(results.some(r=>r.status==='held'))continue;
|
|
85
|
+
restored.add(row.contactId);
|
|
86
|
+
try{if(row.beforeContact){const saved=await call('save_contact',{projectId:receipt.projectId,revision:row.afterContact.revision,contact:fields(row.beforeContact)});if(!saved.replayed)effects.confirmedContactWrites++;}
|
|
87
|
+
else {const removed=await call('delete_contact',{projectId:receipt.projectId,contactId:row.contactId,revision:row.afterContact.revision,onlyIfUnlinked:true});if(removed.deleted)effects.confirmedContactDeletes++;}row.status='restored';
|
|
88
|
+
}catch(error){effects.rowsRequiringReadback++;row.status='held';row.reason=error.code??error.message;}
|
|
89
|
+
}
|
|
90
|
+
return{planSha256:receipt.planSha256,results,effects};
|
|
91
|
+
}
|
package/cli/context-mcp.js
CHANGED
|
File without changes
|
package/cli/context.js
CHANGED
|
@@ -19,7 +19,9 @@ import {
|
|
|
19
19
|
readContextState, readGscRows, refreshContext, pageContext, selectFocusPages,
|
|
20
20
|
importHandoff, forgetContext, createGoogleClient, recentWindows, windowFromRange,
|
|
21
21
|
} from '../src/context/gsc.js';
|
|
22
|
-
import { buildSiteProfile, renderSiteProfile, validateProfileCitations, readSiteFacts } from '../src/context/site-profile.js';
|
|
22
|
+
import { buildSiteProfile, createSiteTransport, renderSiteProfile, validateProfileCitations, readSiteFacts } from '../src/context/site-profile.js';
|
|
23
|
+
import { createAcquisition } from '../src/corpus/acquire.js';
|
|
24
|
+
import { createNodePublicFetch } from './public-http.js';
|
|
23
25
|
import {
|
|
24
26
|
refreshGa4Context, landingPageContext, keyEventsContext, businessContextForPage,
|
|
25
27
|
importGa4Handoff, forgetGa4Context, createGa4Client, readGa4Rows, GA4_SCOPE,
|
|
@@ -72,7 +74,7 @@ async function resolvePaths(cwd) {
|
|
|
72
74
|
return { config, paths: contextPaths({ dir: config.dir, file }) };
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
export async function contextMain(argv = process.argv.slice(2), { cwd = process.cwd(), out = console.log, err = console.error, env = process.env } = {}) {
|
|
77
|
+
export async function contextMain(argv = process.argv.slice(2), { cwd = process.cwd(), out = console.log, err = console.error, env = process.env, signal } = {}) {
|
|
76
78
|
const args = parseArgs(argv);
|
|
77
79
|
const area = args._[0] ?? 'help';
|
|
78
80
|
const command = args._[1] ?? null;
|
|
@@ -273,9 +275,18 @@ export async function contextMain(argv = process.argv.slice(2), { cwd = process.
|
|
|
273
275
|
|
|
274
276
|
if (area === 'profile') {
|
|
275
277
|
if (command === 'build') {
|
|
278
|
+
// Node callers qualify their own public egress. Shared document/page
|
|
279
|
+
// primitives stay transport-injected and retain the existing budgets.
|
|
280
|
+
const fetchImpl = createNodePublicFetch();
|
|
281
|
+
const documents = createSiteTransport({ fetchImpl });
|
|
282
|
+
const transport = signal ? { fetchText: (url, options = {}) => documents.fetchText(url, { ...options,
|
|
283
|
+
signal: options.signal ? AbortSignal.any([options.signal, signal]) : signal }) } : documents;
|
|
276
284
|
const result = await buildSiteProfile({
|
|
277
285
|
site: args.site === true ? null : (args.site ?? null),
|
|
278
286
|
paths, extraPages: args.page ?? [],
|
|
287
|
+
transport,
|
|
288
|
+
acquisition: createAcquisition({ fetchImpl, publicFetchSafe: true, purpose: 'research', directMethod: 'node_http', signal,
|
|
289
|
+
captureTransport: { id: 'local_node_http', version: 'alo-local-node-v1' } }),
|
|
279
290
|
});
|
|
280
291
|
if (args.json) { out(JSON.stringify(result, null, 1)); return 0; }
|
|
281
292
|
out(`origin ${result.origin}: robots ${result.robots.outcome}${result.robots.reason ? ` (${result.robots.reason})` : ''}, ${result.sitemaps.documents} sitemap document(s), ${result.sitemaps.urls_found} URL(s)`);
|
package/cli/delivery.js
CHANGED
|
File without changes
|
package/cli/disavow.js
CHANGED
|
File without changes
|
package/cli/doctor.js
CHANGED
|
@@ -192,10 +192,19 @@ export async function doctorMain(argv = [], { cwd = process.cwd(), out = console
|
|
|
192
192
|
// a legitimate local-only install, reported as a skip rather than a failure.
|
|
193
193
|
const connection = cloudConnection(config, env);
|
|
194
194
|
const origin = connection.origin || null;
|
|
195
|
+
const savedConnectionMetadata = config.cloud && typeof config.cloud === 'object'
|
|
196
|
+
&& Object.keys(config.cloud).length > 0;
|
|
195
197
|
const record = { at: new Date().toISOString() };
|
|
196
198
|
let probed = false;
|
|
197
199
|
if (!origin) {
|
|
198
|
-
|
|
200
|
+
if (savedConnectionMetadata) {
|
|
201
|
+
checks.push({ status: 'fail', name: 'connection',
|
|
202
|
+
detail: 'saved cloud metadata is present, but the CLI API connection is not configured',
|
|
203
|
+
fix: 'supply an API key through AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY, then run `agentlinkops connect` to verify the workspace and project. MCP clients keep their own OAuth credentials' });
|
|
204
|
+
}
|
|
205
|
+
checks.push({ status: 'skip', name: 'cloud', detail: savedConnectionMetadata
|
|
206
|
+
? 'CLI API origin is missing; MCP access is separate'
|
|
207
|
+
: 'no cloud configured (local-only repository)' });
|
|
199
208
|
} else {
|
|
200
209
|
const cloud = await probeCloud({ origin, fetchImpl, timeoutMs });
|
|
201
210
|
record.cloud = { ok: cloud.ok, status: cloud.status, ms: cloud.ms };
|
|
@@ -215,13 +224,15 @@ export async function doctorMain(argv = [], { cwd = process.cwd(), out = console
|
|
|
215
224
|
// Token: presence, shape, and — only when the cloud just proved reachable — whether the
|
|
216
225
|
// cloud accepts it. The value itself is never printed, hashed or measured here.
|
|
217
226
|
const token = connection.token || null;
|
|
218
|
-
const source = connection.auth ? 'the private OAuth credential store' : connection.tokenSource ? `the environment (${connection.tokenSource})` : `${config.dirName}/config.json (cloud.token)`;
|
|
227
|
+
const source = connection.auth ? 'the private OAuth credential store' : connection.storedKey ? `${connection.tokenSource}, saved by agentlinkops connect` : connection.tokenSource ? `the environment (${connection.tokenSource})` : `${config.dirName}/config.json (cloud.token)`;
|
|
219
228
|
if (!token && !connection.auth) {
|
|
220
229
|
if (origin) {
|
|
221
230
|
checks.push({ status: 'fail', name: 'token', detail: 'no token is set; sync and receive need one',
|
|
222
|
-
fix: 'run agentlinkops connect
|
|
231
|
+
fix: 'run `npx -y agentlinkops connect` in a terminal and paste an API key from the Agent page, or supply AGENTLINKOPS_API_KEY in the environment' });
|
|
223
232
|
} else {
|
|
224
|
-
checks.push({ status: 'skip', name: 'token', detail:
|
|
233
|
+
checks.push({ status: 'skip', name: 'token', detail: savedConnectionMetadata
|
|
234
|
+
? 'no CLI API key is set; MCP credentials are managed by the MCP client'
|
|
235
|
+
: 'no token (and no cloud configured)' });
|
|
225
236
|
}
|
|
226
237
|
} else if (!connection.auth && !TOKEN_SHAPE.test(token)) {
|
|
227
238
|
checks.push({ status: 'fail', name: 'token', detail: `set in ${source} but it does not look like an AgentLinkOps API key (lt_ plus 64 hex characters)`,
|
package/cli/env.js
CHANGED
|
File without changes
|