@lobu/cli 6.1.1 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/_lib/apply/apply-cmd.d.ts +36 -0
- package/dist/commands/_lib/apply/apply-cmd.d.ts.map +1 -1
- package/dist/commands/_lib/apply/apply-cmd.js +696 -40
- package/dist/commands/_lib/apply/apply-cmd.js.map +1 -1
- package/dist/commands/_lib/apply/client.d.ts +285 -0
- package/dist/commands/_lib/apply/client.d.ts.map +1 -1
- package/dist/commands/_lib/apply/client.js +469 -28
- package/dist/commands/_lib/apply/client.js.map +1 -1
- package/dist/commands/_lib/apply/desired-state.d.ts +187 -3
- package/dist/commands/_lib/apply/desired-state.d.ts.map +1 -1
- package/dist/commands/_lib/apply/desired-state.js +879 -88
- package/dist/commands/_lib/apply/desired-state.js.map +1 -1
- package/dist/commands/_lib/apply/diff.d.ts +72 -3
- package/dist/commands/_lib/apply/diff.d.ts.map +1 -1
- package/dist/commands/_lib/apply/diff.js +473 -84
- package/dist/commands/_lib/apply/diff.js.map +1 -1
- package/dist/commands/_lib/apply/prompt.d.ts +6 -0
- package/dist/commands/_lib/apply/prompt.d.ts.map +1 -1
- package/dist/commands/_lib/apply/prompt.js +16 -0
- package/dist/commands/_lib/apply/prompt.js.map +1 -1
- package/dist/commands/_lib/apply/render.d.ts +9 -0
- package/dist/commands/_lib/apply/render.d.ts.map +1 -1
- package/dist/commands/_lib/apply/render.js +80 -3
- package/dist/commands/_lib/apply/render.js.map +1 -1
- package/dist/commands/_lib/connector-loader.d.ts +3 -0
- package/dist/commands/_lib/connector-loader.d.ts.map +1 -0
- package/dist/commands/_lib/connector-loader.js +129 -0
- package/dist/commands/_lib/connector-loader.js.map +1 -0
- package/dist/commands/_lib/connector-run-cmd.d.ts +35 -0
- package/dist/commands/_lib/connector-run-cmd.d.ts.map +1 -0
- package/dist/commands/_lib/connector-run-cmd.js +351 -0
- package/dist/commands/_lib/connector-run-cmd.js.map +1 -0
- package/dist/commands/_lib/export/export-cmd.d.ts +35 -0
- package/dist/commands/_lib/export/export-cmd.d.ts.map +1 -0
- package/dist/commands/_lib/export/export-cmd.js +329 -0
- package/dist/commands/_lib/export/export-cmd.js.map +1 -0
- package/dist/commands/agent.d.ts.map +1 -1
- package/dist/commands/agent.js +11 -14
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/chat.d.ts.map +1 -1
- package/dist/commands/chat.js +28 -7
- package/dist/commands/chat.js.map +1 -1
- package/dist/commands/connector.d.ts +3 -0
- package/dist/commands/connector.d.ts.map +1 -0
- package/dist/commands/connector.js +5 -0
- package/dist/commands/connector.js.map +1 -0
- package/dist/commands/dev.d.ts +23 -0
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +273 -8
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +2 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/eval.d.ts.map +1 -1
- package/dist/commands/eval.js +28 -18
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +29 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +22 -16
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/memory/_lib/browser-auth-cmd.d.ts.map +1 -1
- package/dist/commands/memory/_lib/browser-auth-cmd.js +15 -144
- package/dist/commands/memory/_lib/browser-auth-cmd.js.map +1 -1
- package/dist/commands/memory/_lib/schema.d.ts +28 -1
- package/dist/commands/memory/_lib/schema.d.ts.map +1 -1
- package/dist/commands/memory/_lib/schema.js +120 -4
- package/dist/commands/memory/_lib/schema.js.map +1 -1
- package/dist/commands/memory/_lib/seed-cmd.d.ts.map +1 -1
- package/dist/commands/memory/_lib/seed-cmd.js +41 -18
- package/dist/commands/memory/_lib/seed-cmd.js.map +1 -1
- package/dist/commands/org.d.ts +4 -0
- package/dist/commands/org.d.ts.map +1 -1
- package/dist/commands/org.js +10 -0
- package/dist/commands/org.js.map +1 -1
- package/dist/commands/token.d.ts +9 -0
- package/dist/commands/token.d.ts.map +1 -1
- package/dist/commands/token.js +54 -3
- package/dist/commands/token.js.map +1 -1
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +4 -13
- package/dist/commands/validate.js.map +1 -1
- package/dist/config/loader.js +2 -2
- package/dist/config/loader.js.map +1 -1
- package/dist/connectors/README.md +2 -3
- package/dist/connectors/apple_health.ts +138 -0
- package/dist/connectors/apple_photos.ts +178 -0
- package/dist/connectors/apple_screen_time.ts +82 -0
- package/dist/connectors/browser/evaluate.ts +120 -0
- package/dist/connectors/browser/fill_form.ts +107 -0
- package/dist/connectors/browser/page_text.ts +108 -0
- package/dist/connectors/browser-scraper-utils.ts +111 -3
- package/dist/connectors/capterra.ts +5 -1
- package/dist/connectors/chrome_tabs.ts +74 -0
- package/dist/connectors/g2.ts +5 -1
- package/dist/connectors/github.ts +16 -38
- package/dist/connectors/glassdoor.ts +5 -1
- package/dist/connectors/google_calendar.ts +28 -6
- package/dist/connectors/google_gmail.ts +6 -3
- package/dist/connectors/google_play.ts +32 -5
- package/dist/connectors/hackernews.ts +37 -2
- package/dist/connectors/index.ts +14 -1
- package/dist/connectors/linkedin.ts +32 -9
- package/dist/connectors/local_directory.ts +91 -0
- package/dist/connectors/reddit.ts +1 -0
- package/dist/connectors/revolut.ts +569 -0
- package/dist/connectors/rss.ts +33 -8
- package/dist/connectors/trustpilot.ts +36 -21
- package/dist/connectors/website.ts +8 -69
- package/dist/connectors/whatsapp.ts +21 -22
- package/dist/connectors/whatsapp_local.ts +125 -0
- package/dist/connectors/x.ts +17 -7
- package/dist/db/migrations/20260510220000_connector_required_capability.sql +47 -0
- package/dist/db/migrations/20260512000000_device_worker_connection_binding.sql +113 -0
- package/dist/db/migrations/20260512131703_connections_slug.sql +131 -0
- package/dist/db/migrations/20260513000000_chat_user_identities.sql +24 -0
- package/dist/db/migrations/20260513120000_auth_profiles_device_binding.sql +50 -0
- package/dist/db/migrations/20260513150000_auth_profiles_cdp_url.sql +43 -0
- package/dist/db/migrations/20260513200000_notifications_as_events.sql +86 -0
- package/dist/db/migrations/20260514000000_scheduled_jobs.sql +97 -0
- package/dist/db/migrations/20260514120000_auth_profiles_connector_key_nullable.sql +42 -0
- package/dist/db/migrations/20260514130000_connection_action_modes.sql +103 -0
- package/dist/db/migrations/20260514160000_auth_profiles_mirror_mode.sql +32 -0
- package/dist/db/migrations/20260515120000_agents_per_org_pk.sql +66 -0
- package/dist/db/migrations/20260515150000_geo_enrichment.sql +208 -0
- package/dist/db/migrations/20260515160000_drop_agents_org_id_unique.sql +24 -0
- package/dist/db/migrations/20260515170000_auth_profiles_default_for_connector.sql +23 -0
- package/dist/db/migrations/20260516120000_agents_per_org_pk_swap.sql +125 -0
- package/dist/db/migrations/20260516200000_events_search_tsv.sql +134 -0
- package/dist/db/migrations/20260516200100_events_lifecycle_changes_index.sql +25 -0
- package/dist/db/migrations/20260517010000_drop_unused_indexes.sql +49 -0
- package/dist/db/migrations/20260517020000_softdelete_orphan_feeds.sql +56 -0
- package/dist/db/migrations/20260517030000_pat_worker_id_binding.sql +27 -0
- package/dist/db/migrations/20260517040000_archive_orphan_watchers.sql +30 -0
- package/dist/db/migrations/20260517050000_watcher_agent_id_not_null.sql +34 -0
- package/dist/db/migrations/20260517060000_watcher_schema_additions.sql +78 -0
- package/dist/db/migrations/20260517150000_goals_primitive.sql +55 -0
- package/dist/db/migrations/20260517160000_drop_goals_primitive.sql +45 -0
- package/dist/db/migrations/20260518000000_pending_interactions.sql +49 -0
- package/dist/db/migrations/20260518010000_runs_heartbeat_reaper_index.sql +22 -0
- package/dist/eval/client.d.ts.map +1 -1
- package/dist/eval/client.js +11 -0
- package/dist/eval/client.js.map +1 -1
- package/dist/eval/grader.js +2 -1
- package/dist/eval/grader.js.map +1 -1
- package/dist/eval/types.d.ts +2 -0
- package/dist/eval/types.d.ts.map +1 -1
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +115 -114
- package/dist/index.js.map +1 -1
- package/dist/internal/context.d.ts +9 -0
- package/dist/internal/context.d.ts.map +1 -1
- package/dist/internal/context.js +41 -6
- package/dist/internal/context.js.map +1 -1
- package/dist/internal/credentials.d.ts +5 -0
- package/dist/internal/credentials.d.ts.map +1 -1
- package/dist/internal/credentials.js +75 -1
- package/dist/internal/credentials.js.map +1 -1
- package/dist/internal/gateway-url.d.ts +14 -0
- package/dist/internal/gateway-url.d.ts.map +1 -1
- package/dist/internal/gateway-url.js +19 -0
- package/dist/internal/gateway-url.js.map +1 -1
- package/dist/internal/index.d.ts +1 -1
- package/dist/internal/index.d.ts.map +1 -1
- package/dist/internal/index.js +1 -1
- package/dist/internal/index.js.map +1 -1
- package/dist/internal/local-env.d.ts.map +1 -1
- package/dist/internal/local-env.js +9 -2
- package/dist/internal/local-env.js.map +1 -1
- package/dist/server.bundle.mjs +42251 -36931
- package/dist/start-local.bundle.mjs +16437 -9882
- package/dist/templates/TESTING.md.tmpl +9 -9
- package/package.json +8 -6
- package/dist/connectors/google_photos.ts +0 -776
|
@@ -1,27 +1,187 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
import chalk from "chalk";
|
|
2
4
|
import { resolveContext } from "../../../internal/context.js";
|
|
5
|
+
import { parseEnvContent } from "../../../internal/env-file.js";
|
|
3
6
|
import { loadProjectLink } from "../../../internal/project-link.js";
|
|
7
|
+
import { CONFIG_FILENAME } from "../../../config/loader.js";
|
|
4
8
|
import { ApiError, ValidationError } from "../../memory/_lib/errors.js";
|
|
5
9
|
import { printError, printText } from "../../memory/_lib/output.js";
|
|
6
10
|
import { resolveApplyClient, } from "./client.js";
|
|
7
11
|
import { computeDiff, } from "./diff.js";
|
|
8
|
-
import { loadDesiredState } from "./desired-state.js";
|
|
9
|
-
import { confirmPlan } from "./prompt.js";
|
|
10
|
-
import { renderMissingSecrets, renderPlan, renderProgress } from "./render.js";
|
|
11
|
-
// ── Required-secrets check ─────────────────────────────────────────────────
|
|
12
|
+
import { loadDesiredState, resolveConnectorSchemas, validateAuthProfileAgainstConnector, validateConnectionAgainstConnector, } from "./desired-state.js";
|
|
13
|
+
import { confirmCustomConnectors, confirmPlan } from "./prompt.js";
|
|
14
|
+
import { renderMissingSecrets, renderPlan, renderPostApplyPunchList, renderProgress, } from "./render.js";
|
|
12
15
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* must satisfy at runtime — surfacing it pre-mutation gives the operator
|
|
17
|
-
* a cleaner failure than a silent empty-string config push.
|
|
18
|
-
*
|
|
19
|
-
* Plan §7 reserves cloud-side secret-list cross-checks for v3.
|
|
16
|
+
* Write `organization_id = "<id>"` into the `[memory]` section of lobu.toml —
|
|
17
|
+
* replacing an existing value or inserting it just under the `[memory]` header.
|
|
18
|
+
* Surgical text edit; preserves comments and the rest of the file.
|
|
20
19
|
*/
|
|
20
|
+
async function writeMemoryOrganizationId(cwd, organizationId) {
|
|
21
|
+
const path = join(cwd, CONFIG_FILENAME);
|
|
22
|
+
const raw = await readFile(path, "utf-8");
|
|
23
|
+
const line = `organization_id = "${organizationId}"`;
|
|
24
|
+
if (/^\s*organization_id\s*=.*$/m.test(raw)) {
|
|
25
|
+
const next = raw.replace(/^\s*organization_id\s*=.*$/m, line);
|
|
26
|
+
if (next !== raw)
|
|
27
|
+
await writeFile(path, next);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const header = raw.match(/^\[memory\][^\n]*$/m);
|
|
31
|
+
if (!header || header.index === undefined)
|
|
32
|
+
return;
|
|
33
|
+
const at = header.index + header[0].length;
|
|
34
|
+
await writeFile(path, `${raw.slice(0, at)}\n${line}${raw.slice(at)}`);
|
|
35
|
+
}
|
|
36
|
+
// ── Required-secrets check ─────────────────────────────────────────────────
|
|
21
37
|
function checkRequiredSecrets(state) {
|
|
22
38
|
const missing = state.requiredSecrets.filter((name) => process.env[name] === undefined || process.env[name] === "");
|
|
23
39
|
return { missing };
|
|
24
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Merge `.env` values from the project dir into `process.env` (without
|
|
43
|
+
* overriding values already set in the shell). Quietly noop if the file
|
|
44
|
+
* doesn't exist or can't be parsed — `checkRequiredSecrets` will surface a
|
|
45
|
+
* clear "Missing required secret" error downstream.
|
|
46
|
+
*/
|
|
47
|
+
async function loadProjectEnvFile(cwd) {
|
|
48
|
+
const envPath = join(cwd, ".env");
|
|
49
|
+
let raw;
|
|
50
|
+
try {
|
|
51
|
+
raw = await readFile(envPath, "utf-8");
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const vars = parseEnvContent(raw);
|
|
57
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
58
|
+
if (process.env[key] === undefined)
|
|
59
|
+
process.env[key] = value;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// ── source_url: confirmed-before-fetch, https-only, bounded fetch ──────────
|
|
63
|
+
const CONNECTOR_SOURCE_MAX_BYTES = 2 * 1024 * 1024; // 2 MiB
|
|
64
|
+
const CONNECTOR_SOURCE_FETCH_TIMEOUT_MS = 15_000;
|
|
65
|
+
/**
|
|
66
|
+
* Read a response body as a stream, counting *bytes* and aborting as soon as
|
|
67
|
+
* the running total exceeds `maxBytes` — before buffering the rest. Decodes to
|
|
68
|
+
* UTF-8 text only after the (bounded) body is in hand. Exported for testing.
|
|
69
|
+
*/
|
|
70
|
+
export async function readBoundedBody(res, maxBytes, onOverflow) {
|
|
71
|
+
const reader = res.body?.getReader();
|
|
72
|
+
if (!reader) {
|
|
73
|
+
// No streaming body (rare; e.g. a mock). Fall back to text() + a byte check.
|
|
74
|
+
const text = await res.text();
|
|
75
|
+
if (Buffer.byteLength(text, "utf8") > maxBytes)
|
|
76
|
+
onOverflow();
|
|
77
|
+
return text;
|
|
78
|
+
}
|
|
79
|
+
const chunks = [];
|
|
80
|
+
let total = 0;
|
|
81
|
+
try {
|
|
82
|
+
for (;;) {
|
|
83
|
+
const { done, value } = await reader.read();
|
|
84
|
+
if (done)
|
|
85
|
+
break;
|
|
86
|
+
if (value) {
|
|
87
|
+
total += value.byteLength;
|
|
88
|
+
if (total > maxBytes) {
|
|
89
|
+
await reader.cancel().catch(() => undefined);
|
|
90
|
+
onOverflow();
|
|
91
|
+
}
|
|
92
|
+
chunks.push(value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
try {
|
|
98
|
+
reader.releaseLock?.();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// already released by cancel() — ignore
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8");
|
|
105
|
+
}
|
|
106
|
+
async function materializeConnectorSource(defs, fetchImpl) {
|
|
107
|
+
for (const def of defs) {
|
|
108
|
+
if (def.sourceCode !== undefined || !def.sourceUrl)
|
|
109
|
+
continue;
|
|
110
|
+
let url;
|
|
111
|
+
try {
|
|
112
|
+
url = new URL(def.sourceUrl);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
throw new ValidationError(`${def.sourceFile}: connector source_url is not a valid URL: ${def.sourceUrl}`);
|
|
116
|
+
}
|
|
117
|
+
if (url.protocol !== "https:") {
|
|
118
|
+
throw new ValidationError(`${def.sourceFile}: connector source_url must use https (got ${url.protocol}//): ${def.sourceUrl}`);
|
|
119
|
+
}
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
// Single timer covering the whole exchange — connect AND body consumption.
|
|
122
|
+
const timer = setTimeout(() => controller.abort(), CONNECTOR_SOURCE_FETCH_TIMEOUT_MS);
|
|
123
|
+
let body;
|
|
124
|
+
try {
|
|
125
|
+
let res;
|
|
126
|
+
try {
|
|
127
|
+
res = await fetchImpl(def.sourceUrl, { signal: controller.signal });
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
throw new ValidationError(`${def.sourceFile}: failed to fetch connector source_url ${def.sourceUrl} — ${err instanceof Error ? err.message : String(err)}`);
|
|
131
|
+
}
|
|
132
|
+
if (!res.ok) {
|
|
133
|
+
throw new ValidationError(`${def.sourceFile}: connector source_url ${def.sourceUrl} returned HTTP ${res.status} ${res.statusText}`);
|
|
134
|
+
}
|
|
135
|
+
const contentType = (res.headers.get("content-type") ?? "").toLowerCase();
|
|
136
|
+
if (contentType &&
|
|
137
|
+
!/(text\/|application\/(typescript|javascript|x-typescript|octet-stream))/.test(contentType)) {
|
|
138
|
+
throw new ValidationError(`${def.sourceFile}: connector source_url ${def.sourceUrl} returned unexpected content-type "${contentType}" — expected text/*, application/typescript, or application/javascript`);
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
body = await readBoundedBody(res, CONNECTOR_SOURCE_MAX_BYTES, () => {
|
|
142
|
+
throw new ValidationError(`${def.sourceFile}: connector source_url ${def.sourceUrl} body exceeds the ${CONNECTOR_SOURCE_MAX_BYTES}-byte cap`);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
if (err instanceof ValidationError)
|
|
147
|
+
throw err;
|
|
148
|
+
throw new ValidationError(`${def.sourceFile}: failed to read connector source_url ${def.sourceUrl} — ${err instanceof Error ? err.message : String(err)}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
}
|
|
154
|
+
if (!body.trim()) {
|
|
155
|
+
throw new ValidationError(`${def.sourceFile}: connector source_url ${def.sourceUrl} returned an empty body`);
|
|
156
|
+
}
|
|
157
|
+
def.sourceCode = body;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Warn + require confirmation BEFORE the CLI fetches any `source_url` or
|
|
162
|
+
* uploads any custom connector source for compilation on the gateway.
|
|
163
|
+
*
|
|
164
|
+
* SECURITY: `install_connector` compiles + imports + instantiates the connector
|
|
165
|
+
* runtime class on the gateway. The server-side compiler currently runs with
|
|
166
|
+
* full gateway env/fs/network and only blocks relative imports — this consent
|
|
167
|
+
* gate is the operator's last line of defence. (TODO(security): sandbox the
|
|
168
|
+
* server-side connector compiler — tracked separately, out of scope here.)
|
|
169
|
+
*/
|
|
170
|
+
async function confirmCustomConnectorSource(defs, yes) {
|
|
171
|
+
if (defs.length === 0)
|
|
172
|
+
return;
|
|
173
|
+
printText(chalk.yellow(`\n ⚠ This project ships ${defs.length} custom connector source ${defs.length === 1 ? "definition" : "definitions"}:`));
|
|
174
|
+
for (const def of defs) {
|
|
175
|
+
printText(chalk.yellow(def.sourceUrl
|
|
176
|
+
? ` - ${def.sourceFile} → fetches ${def.sourceUrl}`
|
|
177
|
+
: ` - ${def.sourceFile}`));
|
|
178
|
+
}
|
|
179
|
+
printText(chalk.yellow(" `lobu apply` will fetch (https) and UPLOAD this source; the gateway will COMPILE and EXECUTE it.\n Only proceed if you trust this code."));
|
|
180
|
+
const ok = await confirmCustomConnectors(yes);
|
|
181
|
+
if (!ok) {
|
|
182
|
+
throw new ValidationError("Cancelled — custom connectors not confirmed.");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
25
185
|
// ── Snapshot ───────────────────────────────────────────────────────────────
|
|
26
186
|
async function fetchRemoteSnapshot(client, state, only) {
|
|
27
187
|
const agents = only === "memory" ? [] : await client.listAgents();
|
|
@@ -30,8 +190,6 @@ async function fetchRemoteSnapshot(client, state, only) {
|
|
|
30
190
|
if (only !== "memory") {
|
|
31
191
|
const desiredAgentIds = state.agents.map((a) => a.metadata.agentId);
|
|
32
192
|
const remoteAgentIds = new Set(agents.map((a) => a.agentId));
|
|
33
|
-
// Only GET settings for agents that exist; new agents have no remote
|
|
34
|
-
// settings to compare against.
|
|
35
193
|
const targetAgentIds = desiredAgentIds.filter((id) => remoteAgentIds.has(id));
|
|
36
194
|
for (const agentId of targetAgentIds) {
|
|
37
195
|
agentSettings.set(agentId, await client.getAgentSettings(agentId));
|
|
@@ -40,21 +198,155 @@ async function fetchRemoteSnapshot(client, state, only) {
|
|
|
40
198
|
}
|
|
41
199
|
const entityTypes = only === "agents" ? [] : await client.listEntityTypes();
|
|
42
200
|
const relationshipTypes = only === "agents" ? [] : await client.listRelationshipTypes();
|
|
201
|
+
const watchers = only === "agents" ? [] : await client.listWatchers();
|
|
202
|
+
// Connectors run only on a full apply (`--only` skips them).
|
|
203
|
+
const hasConnectors = state.connectors.definitions.length > 0 ||
|
|
204
|
+
state.connectors.authProfiles.length > 0 ||
|
|
205
|
+
state.connectors.connections.length > 0;
|
|
206
|
+
const connectorDefinitions = only || !hasConnectors ? [] : await client.listConnectorDefinitions(true);
|
|
207
|
+
const authProfiles = only || !hasConnectors ? [] : await client.listAuthProfiles();
|
|
208
|
+
const connections = only || !hasConnectors ? [] : await client.listConnections();
|
|
209
|
+
const feedsByConnectionId = new Map();
|
|
210
|
+
if (!only && hasConnectors) {
|
|
211
|
+
const desiredConnSlugs = new Set(state.connectors.connections.map((c) => c.slug));
|
|
212
|
+
for (const conn of connections) {
|
|
213
|
+
if (!desiredConnSlugs.has(conn.slug))
|
|
214
|
+
continue;
|
|
215
|
+
feedsByConnectionId.set(conn.id, await client.listFeeds(conn.id));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
43
218
|
return {
|
|
44
219
|
agents,
|
|
45
220
|
agentSettings,
|
|
46
221
|
platformsByAgent,
|
|
47
222
|
entityTypes,
|
|
48
223
|
relationshipTypes,
|
|
224
|
+
watchers,
|
|
225
|
+
connectorDefinitions,
|
|
226
|
+
authProfiles,
|
|
227
|
+
connections,
|
|
228
|
+
feedsByConnectionId,
|
|
49
229
|
};
|
|
50
230
|
}
|
|
231
|
+
// ── Connector definition install (runs INSIDE executePlan, after confirm) ──
|
|
51
232
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
233
|
+
* Install/update the project's custom connector definitions, then any *bundled*
|
|
234
|
+
* connectors referenced by an auth-profile / connection (the server only
|
|
235
|
+
* resolves *installed* defs in `create_auth_profile` / `create_feed`, not the
|
|
236
|
+
* catalog). Returns the fresh connector-definition catalog.
|
|
55
237
|
*/
|
|
56
|
-
async function
|
|
238
|
+
async function installConnectorDefinitions(client, state, catalog, plan) {
|
|
239
|
+
const installedKeys = new Set(catalog.filter((d) => d.installed).map((d) => d.key));
|
|
240
|
+
// Connector keys this project supplies its own source for — these must NEVER
|
|
241
|
+
// be replaced by a bundled `source_uri` install, even if a bundled connector
|
|
242
|
+
// shares the key. (`null` keys — auto-discovered `*.connector.ts` whose key
|
|
243
|
+
// the server resolves at compile time — are added to this set below as soon
|
|
244
|
+
// as `install_connector` returns the resolved key, so the bundled loop can't
|
|
245
|
+
// race them either.)
|
|
246
|
+
const locallySuppliedKeys = new Set(state.connectors.definitions
|
|
247
|
+
.map((d) => d.key)
|
|
248
|
+
.filter((k) => !!k));
|
|
249
|
+
let mutated = false;
|
|
250
|
+
// Iterate the plan's connector-definition rows so progress mirrors the plan.
|
|
251
|
+
for (const row of plan.rows) {
|
|
252
|
+
if (row.kind !== "connector-definition")
|
|
253
|
+
continue;
|
|
254
|
+
if (row.verb === "noop" || row.verb === "drift")
|
|
255
|
+
continue;
|
|
256
|
+
const def = row.desired;
|
|
257
|
+
if (!def)
|
|
258
|
+
continue;
|
|
259
|
+
const result = def.sourceCode !== undefined
|
|
260
|
+
? await client.installConnector({ sourceCode: def.sourceCode })
|
|
261
|
+
: await client.installConnector({ sourceUrl: def.sourceUrl });
|
|
262
|
+
if (result.connectorKey) {
|
|
263
|
+
locallySuppliedKeys.add(result.connectorKey);
|
|
264
|
+
installedKeys.add(result.connectorKey);
|
|
265
|
+
}
|
|
266
|
+
mutated = true;
|
|
267
|
+
printText(renderProgress(row.verb, "connector-definition", result.connectorKey || def.key || def.sourceFile, result.updated ? "(installed)" : "(unchanged)"));
|
|
268
|
+
}
|
|
269
|
+
// Bundled connectors referenced by an auth-profile / connection — installed
|
|
270
|
+
// ONLY if the org doesn't already have that key (installed in a prior apply
|
|
271
|
+
// or just installed from local source above). A locally-supplied key is never
|
|
272
|
+
// overwritten by the bundled `source_uri`.
|
|
273
|
+
const catalogByKey = new Map(catalog.filter((d) => d.installable && d.source_uri).map((d) => [d.key, d]));
|
|
274
|
+
const referenced = new Set([
|
|
275
|
+
...state.connectors.authProfiles.map((p) => p.connector),
|
|
276
|
+
...state.connectors.connections.map((c) => c.connector),
|
|
277
|
+
]);
|
|
278
|
+
for (const key of [...referenced].sort()) {
|
|
279
|
+
if (installedKeys.has(key) || locallySuppliedKeys.has(key))
|
|
280
|
+
continue;
|
|
281
|
+
const entry = catalogByKey.get(key);
|
|
282
|
+
if (!entry?.source_uri)
|
|
283
|
+
continue; // custom local-only — handled above
|
|
284
|
+
const result = await client.installConnector({
|
|
285
|
+
sourceUri: entry.source_uri,
|
|
286
|
+
});
|
|
287
|
+
mutated = true;
|
|
288
|
+
printText(renderProgress("create", "connector-definition", result.connectorKey || key, result.updated ? "(installed bundled)" : "(bundled — unchanged)"));
|
|
289
|
+
}
|
|
290
|
+
return mutated ? await client.listConnectorDefinitions(true) : catalog;
|
|
291
|
+
}
|
|
292
|
+
export function validateConnectorState(state, connectorDefinitions, opts = {}) {
|
|
293
|
+
const defByKey = new Map(connectorDefinitions.map((d) => [d.key, d]));
|
|
294
|
+
const authProfilesBySlug = new Map(state.connectors.authProfiles.map((p) => [p.slug, p]));
|
|
295
|
+
if (opts.requireInstalled) {
|
|
296
|
+
const refs = [
|
|
297
|
+
...state.connectors.authProfiles.map((p) => ({
|
|
298
|
+
connector: p.connector,
|
|
299
|
+
ref: `auth profile "${p.slug}"`,
|
|
300
|
+
})),
|
|
301
|
+
...state.connectors.connections.map((c) => ({
|
|
302
|
+
connector: c.connector,
|
|
303
|
+
ref: `connection "${c.slug}"`,
|
|
304
|
+
})),
|
|
305
|
+
];
|
|
306
|
+
for (const { connector, ref } of refs) {
|
|
307
|
+
const def = defByKey.get(connector);
|
|
308
|
+
if (!def || def.installed !== true) {
|
|
309
|
+
throw new ValidationError(`connector "${connector}" referenced by ${ref} is not installed in the org — check the \`connector\` key (and, for a local \`*.connector.ts\`, that its \`definition.key\` matches)`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const schemasFor = (connectorKey) => {
|
|
314
|
+
if (opts.skipSchemaForConnectorKeys?.has(connectorKey))
|
|
315
|
+
return null;
|
|
316
|
+
const def = defByKey.get(connectorKey);
|
|
317
|
+
return def ? resolveConnectorSchemas(def) : null;
|
|
318
|
+
};
|
|
319
|
+
for (const profile of state.connectors.authProfiles) {
|
|
320
|
+
validateAuthProfileAgainstConnector(profile, schemasFor(profile.connector));
|
|
321
|
+
}
|
|
322
|
+
for (const connection of state.connectors.connections) {
|
|
323
|
+
validateConnectionAgainstConnector(connection, authProfilesBySlug, schemasFor(connection.connector));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// Connector keys declared locally (`*.connector.ts` / `type: connector`).
|
|
327
|
+
// We don't know the key for an auto-discovered `*.connector.ts` until the
|
|
328
|
+
// server compiles it — those have `key === null` — so they can't be in the
|
|
329
|
+
// skip set; their connections are validated only after install (when the key
|
|
330
|
+
// is known and the def is in the refreshed catalog).
|
|
331
|
+
export function locallyDeclaredConnectorKeys(state) {
|
|
332
|
+
return new Set(state.connectors.definitions
|
|
333
|
+
.map((d) => d.key)
|
|
334
|
+
.filter((k) => !!k));
|
|
335
|
+
}
|
|
336
|
+
async function executePlan(ctx, pendingAuth) {
|
|
57
337
|
const rowsByKind = (kind) => ctx.plan.rows.filter((row) => row.kind === kind && row.verb !== "noop" && row.verb !== "drift");
|
|
338
|
+
// 0) Connector definitions FIRST — install/update them (the plan was already
|
|
339
|
+
// confirmed), refetch the catalog, then re-validate connection/feed config
|
|
340
|
+
// against the now-current schemas. Doing this before any other resource
|
|
341
|
+
// means a post-install schema rejection halts apply before mutating
|
|
342
|
+
// anything unrelated.
|
|
343
|
+
const hasConnectorWork = ctx.state.connectors.definitions.length > 0 ||
|
|
344
|
+
ctx.state.connectors.authProfiles.length > 0 ||
|
|
345
|
+
ctx.state.connectors.connections.length > 0;
|
|
346
|
+
if (hasConnectorWork) {
|
|
347
|
+
const freshCatalog = await installConnectorDefinitions(ctx.client, ctx.state, ctx.remote.connectorDefinitions, ctx.plan);
|
|
348
|
+
validateConnectorState(ctx.state, freshCatalog, { requireInstalled: true });
|
|
349
|
+
}
|
|
58
350
|
// 1) Agents
|
|
59
351
|
for (const row of rowsByKind("agent")) {
|
|
60
352
|
if (row.kind !== "agent")
|
|
@@ -85,6 +377,18 @@ async function executePlan(ctx) {
|
|
|
85
377
|
await ctx.client.patchAgentSettings(row.id, desired.settings);
|
|
86
378
|
printText(renderProgress(row.verb, "settings", row.id, row.changedFields ? `(${row.changedFields.join(", ")})` : undefined));
|
|
87
379
|
}
|
|
380
|
+
// 2b) Provider API keys — pushed as org-shared `agent_secrets` rows so the
|
|
381
|
+
// worker can inject them at runtime without a per-user auth profile. Idempotent
|
|
382
|
+
// (PUT); same value → 200, different value → rotation. Walk all desired agents
|
|
383
|
+
// (not just those with a settings diff) — the secret value isn't part of the
|
|
384
|
+
// settings JSON, so a row can need a key even when settings are noop (e.g.
|
|
385
|
+
// first apply after the gateway picked up support, or a key rotation).
|
|
386
|
+
for (const desired of ctx.state.agents) {
|
|
387
|
+
for (const { providerId, value } of desired.providerKeys) {
|
|
388
|
+
await ctx.client.setProviderApiKey(desired.metadata.agentId, providerId, value);
|
|
389
|
+
printText(chalk.dim(` ↻ provider-key ${desired.metadata.agentId}/${providerId}`));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
88
392
|
// 3) Platforms
|
|
89
393
|
for (const row of rowsByKind("platform")) {
|
|
90
394
|
if (row.kind !== "platform")
|
|
@@ -104,6 +408,21 @@ async function executePlan(ctx) {
|
|
|
104
408
|
: undefined;
|
|
105
409
|
printText(renderProgress(row.verb, "platform", `${row.agentId}/${row.id}`, detail));
|
|
106
410
|
}
|
|
411
|
+
// 3b) Declarative channel bindings — reconcile after the platform upserts
|
|
412
|
+
// above so the connection rows exist. Runs for every agent/platform that
|
|
413
|
+
// declares `channels` (the server reconcile is idempotent), independent of
|
|
414
|
+
// whether the platform's config changed in this plan.
|
|
415
|
+
for (const agent of ctx.state.agents) {
|
|
416
|
+
for (const platform of agent.platforms) {
|
|
417
|
+
if (!platform.channels || platform.channels.length === 0)
|
|
418
|
+
continue;
|
|
419
|
+
const res = await ctx.client.syncPlatformChannels(agent.metadata.agentId, platform.stableId, platform.channels);
|
|
420
|
+
const detail = res.removed.length > 0
|
|
421
|
+
? `(${res.bound.length} bound, ${res.removed.length} unbound)`
|
|
422
|
+
: `(${res.bound.length} bound)`;
|
|
423
|
+
printText(` ${chalk.cyan("↻")} ${chalk.bold("channels")} ${agent.metadata.agentId}/${platform.stableId} ${chalk.dim(detail)}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
107
426
|
// 4) Entity types
|
|
108
427
|
for (const row of rowsByKind("entity-type")) {
|
|
109
428
|
if (row.kind !== "entity-type")
|
|
@@ -122,11 +441,288 @@ async function executePlan(ctx) {
|
|
|
122
441
|
await ctx.client.upsertRelationshipType(row.desired);
|
|
123
442
|
printText(renderProgress(row.verb, "relationship-type", row.id));
|
|
124
443
|
}
|
|
444
|
+
// 6) Watchers — create (full payload + reaction script) or update (scalar
|
|
445
|
+
// row fields via `update`, version-bound fields via `create_version`,
|
|
446
|
+
// reaction script via `set_reaction_script`). Drift detection lives in
|
|
447
|
+
// `diffWatcher`; this loop just routes to the right admin action.
|
|
448
|
+
const remoteWatcherBySlug = new Map(ctx.remote.watchers.map((w) => [w.slug, w]));
|
|
449
|
+
for (const row of rowsByKind("watcher")) {
|
|
450
|
+
if (row.kind !== "watcher")
|
|
451
|
+
continue;
|
|
452
|
+
if (!row.desired)
|
|
453
|
+
continue;
|
|
454
|
+
const w = row.desired;
|
|
455
|
+
let watcherId;
|
|
456
|
+
if (row.verb === "create") {
|
|
457
|
+
const created = await ctx.client.createWatcher({
|
|
458
|
+
slug: w.slug,
|
|
459
|
+
agentId: w.agent,
|
|
460
|
+
name: w.name,
|
|
461
|
+
description: w.description,
|
|
462
|
+
prompt: w.prompt,
|
|
463
|
+
extraction_schema: w.extractionSchema,
|
|
464
|
+
schedule: w.schedule,
|
|
465
|
+
sources: w.sources,
|
|
466
|
+
reactions_guidance: w.reactionsGuidance,
|
|
467
|
+
device_worker_id: w.deviceWorkerId,
|
|
468
|
+
scheduler_client_id: w.schedulerClientId,
|
|
469
|
+
notification_channel: w.notificationChannel,
|
|
470
|
+
notification_priority: w.notificationPriority,
|
|
471
|
+
min_cooldown_seconds: w.minCooldownSeconds,
|
|
472
|
+
tags: w.tags,
|
|
473
|
+
agent_kind: w.agentKind,
|
|
474
|
+
json_template: w.jsonTemplate,
|
|
475
|
+
keying_config: w.keyingConfig,
|
|
476
|
+
classifiers: w.classifiers,
|
|
477
|
+
condensation_prompt: w.condensationPrompt,
|
|
478
|
+
condensation_window_count: w.condensationWindowCount,
|
|
479
|
+
});
|
|
480
|
+
watcherId = created.watcher_id;
|
|
481
|
+
}
|
|
482
|
+
else if (row.verb === "update") {
|
|
483
|
+
const remote = remoteWatcherBySlug.get(w.slug);
|
|
484
|
+
watcherId = remote?.watcher_id;
|
|
485
|
+
if (!watcherId) {
|
|
486
|
+
throw new ApiError(`update watcher "${w.slug}" failed: remote row is missing watcher_id (refetch may be stale)`);
|
|
487
|
+
}
|
|
488
|
+
const versionBound = new Set(row.versionBoundFields ?? []);
|
|
489
|
+
const changed = new Set(row.changedFields ?? []);
|
|
490
|
+
const scalarChanges = [...changed].filter((f) => !versionBound.has(f) && f !== "reaction_script");
|
|
491
|
+
// a) Scalar fields → manage_watchers update
|
|
492
|
+
if (scalarChanges.length > 0) {
|
|
493
|
+
await ctx.client.updateWatcher({
|
|
494
|
+
watcher_id: watcherId,
|
|
495
|
+
...(scalarChanges.includes("schedule")
|
|
496
|
+
? { schedule: w.schedule ?? null }
|
|
497
|
+
: {}),
|
|
498
|
+
...(scalarChanges.includes("agent_id") ? { agent_id: w.agent } : {}),
|
|
499
|
+
...(scalarChanges.includes("device_worker_id")
|
|
500
|
+
? { device_worker_id: w.deviceWorkerId ?? null }
|
|
501
|
+
: {}),
|
|
502
|
+
...(scalarChanges.includes("scheduler_client_id")
|
|
503
|
+
? { scheduler_client_id: w.schedulerClientId ?? null }
|
|
504
|
+
: {}),
|
|
505
|
+
...(scalarChanges.includes("notification_channel") &&
|
|
506
|
+
w.notificationChannel
|
|
507
|
+
? { notification_channel: w.notificationChannel }
|
|
508
|
+
: {}),
|
|
509
|
+
...(scalarChanges.includes("notification_priority") &&
|
|
510
|
+
w.notificationPriority
|
|
511
|
+
? { notification_priority: w.notificationPriority }
|
|
512
|
+
: {}),
|
|
513
|
+
...(scalarChanges.includes("min_cooldown_seconds") &&
|
|
514
|
+
w.minCooldownSeconds !== undefined
|
|
515
|
+
? { min_cooldown_seconds: w.minCooldownSeconds }
|
|
516
|
+
: {}),
|
|
517
|
+
...(scalarChanges.includes("tags") && w.tags ? { tags: w.tags } : {}),
|
|
518
|
+
...(scalarChanges.includes("agent_kind")
|
|
519
|
+
? { agent_kind: w.agentKind ?? null }
|
|
520
|
+
: {}),
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
// b) Version-bound fields → manage_watchers create_version (server
|
|
524
|
+
// inherits unset fields from the previous version row, but we always
|
|
525
|
+
// send the desired-side values for the changed keys).
|
|
526
|
+
if (row.versionBoundFields && row.versionBoundFields.length > 0) {
|
|
527
|
+
await ctx.client.createWatcherVersion({
|
|
528
|
+
watcher_id: watcherId,
|
|
529
|
+
...(versionBound.has("prompt") ? { prompt: w.prompt } : {}),
|
|
530
|
+
...(versionBound.has("extraction_schema")
|
|
531
|
+
? { extraction_schema: w.extractionSchema }
|
|
532
|
+
: {}),
|
|
533
|
+
...(versionBound.has("sources") && w.sources !== undefined
|
|
534
|
+
? { sources: w.sources }
|
|
535
|
+
: {}),
|
|
536
|
+
...(versionBound.has("reactions_guidance") &&
|
|
537
|
+
w.reactionsGuidance !== undefined
|
|
538
|
+
? { reactions_guidance: w.reactionsGuidance }
|
|
539
|
+
: {}),
|
|
540
|
+
...(versionBound.has("json_template") && w.jsonTemplate !== undefined
|
|
541
|
+
? { json_template: w.jsonTemplate }
|
|
542
|
+
: {}),
|
|
543
|
+
...(versionBound.has("keying_config") && w.keyingConfig !== undefined
|
|
544
|
+
? { keying_config: w.keyingConfig }
|
|
545
|
+
: {}),
|
|
546
|
+
...(versionBound.has("classifiers") && w.classifiers !== undefined
|
|
547
|
+
? { classifiers: w.classifiers }
|
|
548
|
+
: {}),
|
|
549
|
+
...(versionBound.has("condensation_prompt") &&
|
|
550
|
+
w.condensationPrompt !== undefined
|
|
551
|
+
? { condensation_prompt: w.condensationPrompt }
|
|
552
|
+
: {}),
|
|
553
|
+
...(versionBound.has("condensation_window_count") &&
|
|
554
|
+
w.condensationWindowCount !== undefined
|
|
555
|
+
? { condensation_window_count: w.condensationWindowCount }
|
|
556
|
+
: {}),
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
// c) Reaction script — push when declared (idempotent server-side, no
|
|
561
|
+
// drift signal available because it's not returned by list_watchers).
|
|
562
|
+
if (w.reactionScript && watcherId) {
|
|
563
|
+
await ctx.client.setReactionScript(watcherId, w.reactionScript.sourceCode);
|
|
564
|
+
}
|
|
565
|
+
printText(renderProgress(row.verb, "watcher", row.id, row.changedFields ? `(${row.changedFields.join(", ")})` : undefined));
|
|
566
|
+
}
|
|
567
|
+
// Auth profiles (create / update; interactive kinds → punch-list)
|
|
568
|
+
for (const row of rowsByKind("auth-profile")) {
|
|
569
|
+
if (row.kind !== "auth-profile")
|
|
570
|
+
continue;
|
|
571
|
+
const desired = ctx.state.connectors.authProfiles.find((p) => p.slug === row.id);
|
|
572
|
+
if (!desired)
|
|
573
|
+
continue;
|
|
574
|
+
const result = row.verb === "create"
|
|
575
|
+
? await ctx.client.createAuthProfile({
|
|
576
|
+
slug: desired.slug,
|
|
577
|
+
connector: desired.connector,
|
|
578
|
+
kind: desired.kind,
|
|
579
|
+
name: desired.name,
|
|
580
|
+
credentials: desired.credentials,
|
|
581
|
+
})
|
|
582
|
+
: await ctx.client.updateAuthProfile({
|
|
583
|
+
slug: desired.slug,
|
|
584
|
+
name: desired.name,
|
|
585
|
+
credentials: desired.credentials,
|
|
586
|
+
});
|
|
587
|
+
if ((desired.kind === "oauth_account" ||
|
|
588
|
+
desired.kind === "browser_session") &&
|
|
589
|
+
result.status !== "active") {
|
|
590
|
+
pendingAuth.push({
|
|
591
|
+
slug: desired.slug,
|
|
592
|
+
kind: desired.kind,
|
|
593
|
+
...(result.connectUrl ? { connectUrl: result.connectUrl } : {}),
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
printText(renderProgress(row.verb, "auth-profile", row.id));
|
|
597
|
+
}
|
|
598
|
+
// 9) Connections, keyed by slug.
|
|
599
|
+
const remoteConnBySlug = new Map(ctx.remote.connections.map((c) => [c.slug, c]));
|
|
600
|
+
const connectionIdBySlug = new Map(ctx.remote.connections.map((c) => [c.slug, c.id]));
|
|
601
|
+
for (const row of rowsByKind("connection")) {
|
|
602
|
+
if (row.kind !== "connection")
|
|
603
|
+
continue;
|
|
604
|
+
const desired = ctx.state.connectors.connections.find((c) => c.slug === row.id);
|
|
605
|
+
if (!desired)
|
|
606
|
+
continue;
|
|
607
|
+
const existing = remoteConnBySlug.get(desired.slug);
|
|
608
|
+
if (existing && row.verb === "update") {
|
|
609
|
+
const updated = await ctx.client.updateConnection(existing.id, {
|
|
610
|
+
name: desired.name,
|
|
611
|
+
authProfileSlug: desired.authProfileSlug ?? null,
|
|
612
|
+
appAuthProfileSlug: desired.appAuthProfileSlug ?? null,
|
|
613
|
+
config: desired.config ?? {},
|
|
614
|
+
// Always pass — server treats undefined as "leave alone", null as
|
|
615
|
+
// "unpin to server", and a uuid as "move to that device".
|
|
616
|
+
deviceWorkerId: desired.deviceWorkerId ?? null,
|
|
617
|
+
});
|
|
618
|
+
connectionIdBySlug.set(desired.slug, updated.id);
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
const created = await ctx.client.createConnection({
|
|
622
|
+
slug: desired.slug,
|
|
623
|
+
connector: desired.connector,
|
|
624
|
+
name: desired.name,
|
|
625
|
+
authProfileSlug: desired.authProfileSlug,
|
|
626
|
+
appAuthProfileSlug: desired.appAuthProfileSlug,
|
|
627
|
+
config: desired.config,
|
|
628
|
+
...(desired.deviceWorkerId
|
|
629
|
+
? { deviceWorkerId: desired.deviceWorkerId }
|
|
630
|
+
: {}),
|
|
631
|
+
});
|
|
632
|
+
connectionIdBySlug.set(desired.slug, created.id);
|
|
633
|
+
}
|
|
634
|
+
printText(renderProgress(row.verb, "connection", row.id));
|
|
635
|
+
}
|
|
636
|
+
// 10) Feeds (per connection — covers feeds whose connection itself was a noop)
|
|
637
|
+
for (const row of rowsByKind("feed")) {
|
|
638
|
+
if (row.kind !== "feed")
|
|
639
|
+
continue;
|
|
640
|
+
if (!row.desired)
|
|
641
|
+
continue;
|
|
642
|
+
const feed = row.desired;
|
|
643
|
+
const connectionId = connectionIdBySlug.get(row.connectionSlug);
|
|
644
|
+
if (connectionId === undefined) {
|
|
645
|
+
throw new ApiError(`feed "${feed.feedKey}" references connection "${row.connectionSlug}" which has no remote ID — connection create may have failed`);
|
|
646
|
+
}
|
|
647
|
+
const existingConn = remoteConnBySlug.get(row.connectionSlug);
|
|
648
|
+
const remoteFeed = existingConn
|
|
649
|
+
? (ctx.remote.feedsByConnectionId.get(existingConn.id) ?? []).find((f) => f.feed_key === feed.feedKey)
|
|
650
|
+
: undefined;
|
|
651
|
+
if (remoteFeed && row.verb === "update") {
|
|
652
|
+
await ctx.client.updateFeed(remoteFeed.id, {
|
|
653
|
+
name: feed.name,
|
|
654
|
+
schedule: feed.schedule,
|
|
655
|
+
config: feed.config ?? {},
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
await ctx.client.createFeed({
|
|
660
|
+
connectionId,
|
|
661
|
+
feedKey: feed.feedKey,
|
|
662
|
+
name: feed.name,
|
|
663
|
+
schedule: feed.schedule,
|
|
664
|
+
config: feed.config,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
printText(renderProgress(row.verb, "feed", row.id));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
// Collect pending interactive-auth profiles from a (no-op) plan and re-issue a
|
|
671
|
+
// fresh connect URL — used both when "nothing to apply" and on partial failure.
|
|
672
|
+
async function collectPendingAuthFromPlan(client, plan, already) {
|
|
673
|
+
const out = [...already];
|
|
674
|
+
for (const row of plan.rows) {
|
|
675
|
+
if (row.kind !== "auth-profile" || !("needsAuth" in row) || !row.needsAuth)
|
|
676
|
+
continue;
|
|
677
|
+
if (!row.desired)
|
|
678
|
+
continue;
|
|
679
|
+
const desired = row.desired;
|
|
680
|
+
if (out.some((p) => p.slug === desired.slug))
|
|
681
|
+
continue;
|
|
682
|
+
if (desired.kind === "oauth_account") {
|
|
683
|
+
// A successful reconnect implies the profile exists remotely (and yields
|
|
684
|
+
// a fresh connect URL). If it fails, the profile may not exist (a failed
|
|
685
|
+
// create in a partial apply) — don't tell the operator to go finish auth
|
|
686
|
+
// for something that isn't there; just skip it.
|
|
687
|
+
const connectUrl = await client
|
|
688
|
+
.reconnectAuthProfile(desired.slug)
|
|
689
|
+
.catch(() => undefined);
|
|
690
|
+
if (!connectUrl)
|
|
691
|
+
continue;
|
|
692
|
+
out.push({ slug: desired.slug, kind: desired.kind, connectUrl });
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
// browser_session (no reconnect endpoint): include only if the profile row
|
|
696
|
+
// actually exists remotely.
|
|
697
|
+
const exists = await client
|
|
698
|
+
.getAuthProfileBySlug(desired.slug)
|
|
699
|
+
.catch(() => null);
|
|
700
|
+
if (!exists)
|
|
701
|
+
continue;
|
|
702
|
+
out.push({ slug: desired.slug, kind: desired.kind });
|
|
703
|
+
}
|
|
704
|
+
return out;
|
|
125
705
|
}
|
|
126
706
|
// ── Top-level command ──────────────────────────────────────────────────────
|
|
707
|
+
/** "office-bot" → "Office Bot" — default display name for a bootstrapped org. */
|
|
708
|
+
function slugToTitle(slug) {
|
|
709
|
+
return (slug
|
|
710
|
+
.split(/[-_]+/)
|
|
711
|
+
.filter(Boolean)
|
|
712
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
713
|
+
.join(" ") || slug);
|
|
714
|
+
}
|
|
127
715
|
export async function applyCommand(opts = {}) {
|
|
128
716
|
const cwd = opts.cwd ?? process.cwd();
|
|
129
|
-
const
|
|
717
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
718
|
+
// Auto-load `.env` from the project dir so $VAR refs in lobu.toml resolve
|
|
719
|
+
// without the user having to `set -a; source .env; set +a`. Mirrors what
|
|
720
|
+
// `lobu dev` does. Existing process.env values win (don't clobber the shell).
|
|
721
|
+
await loadProjectEnvFile(cwd);
|
|
722
|
+
const { state, configPath } = await loadDesiredState({
|
|
723
|
+
cwd,
|
|
724
|
+
...(opts.only ? { only: opts.only } : {}),
|
|
725
|
+
});
|
|
130
726
|
printText(chalk.dim(`Config: ${configPath}`));
|
|
131
727
|
// Required secrets gate: fail before any network mutation.
|
|
132
728
|
const { missing } = checkRequiredSecrets(state);
|
|
@@ -134,9 +730,12 @@ export async function applyCommand(opts = {}) {
|
|
|
134
730
|
printError(renderMissingSecrets(missing));
|
|
135
731
|
throw new ValidationError(`${missing.length} required secret${missing.length === 1 ? "" : "s"} missing — see above.`);
|
|
136
732
|
}
|
|
137
|
-
|
|
733
|
+
// Org slug resolution: explicit --org ▸ active-session org ▸ `[memory].org`
|
|
734
|
+
// from lobu.toml. The toml slug is the declarative default — if no org with
|
|
735
|
+
// that slug exists yet, `lobu apply` offers to provision it (below).
|
|
736
|
+
const { client, orgSlug, apiBaseUrl } = await resolveApplyClient({
|
|
138
737
|
url: opts.url,
|
|
139
|
-
org: opts.org,
|
|
738
|
+
org: opts.org ?? state.memory?.org,
|
|
140
739
|
fetchImpl: opts.fetchImpl,
|
|
141
740
|
});
|
|
142
741
|
printText(chalk.dim(`Org: ${orgSlug}`));
|
|
@@ -164,21 +763,71 @@ export async function applyCommand(opts = {}) {
|
|
|
164
763
|
throw new ValidationError("project-link mismatch");
|
|
165
764
|
}
|
|
166
765
|
}
|
|
766
|
+
// Check the resolved org exists / the operator is a member. `lobu apply`
|
|
767
|
+
// can't create an org headlessly — that needs a logged-in browser session —
|
|
768
|
+
// so a missing org stops here with a link to create it. `listOrgs()` failing
|
|
769
|
+
// (old server, or a token the userinfo endpoint rejects) → null → skip the
|
|
770
|
+
// check and let the normal flow surface any org error.
|
|
771
|
+
const myOrgs = await client.listOrgs().catch(() => null);
|
|
772
|
+
const resolvedOrg = myOrgs?.find((o) => o.slug === orgSlug) ??
|
|
773
|
+
(state.memory?.organizationId
|
|
774
|
+
? myOrgs?.find((o) => o.id === state.memory?.organizationId)
|
|
775
|
+
: undefined);
|
|
776
|
+
if (myOrgs !== null && !resolvedOrg) {
|
|
777
|
+
const orgName = state.memory?.name ?? slugToTitle(orgSlug);
|
|
778
|
+
const createUrl = `${apiBaseUrl}/orgs/new?slug=${encodeURIComponent(orgSlug)}&name=${encodeURIComponent(orgName)}`;
|
|
779
|
+
printError([
|
|
780
|
+
"",
|
|
781
|
+
`Organization "${orgSlug}" not found, or you're not a member.`,
|
|
782
|
+
"",
|
|
783
|
+
` Create it: ${createUrl}`,
|
|
784
|
+
` (or: \`lobu org create ${orgSlug}\`)`,
|
|
785
|
+
"",
|
|
786
|
+
"then re-run `lobu apply`. (Or target an existing org with `--org <slug>`.)",
|
|
787
|
+
].join("\n"));
|
|
788
|
+
throw new ValidationError(`organization "${orgSlug}" not found`);
|
|
789
|
+
}
|
|
790
|
+
// Persist the resolved org id back into lobu.toml so the whole team applies
|
|
791
|
+
// to the same org. Best-effort — a read-only lobu.toml must not fail apply.
|
|
792
|
+
// Skipped on `--dry-run`: that flag promises no mutation, local files included.
|
|
793
|
+
if (!opts.dryRun &&
|
|
794
|
+
resolvedOrg &&
|
|
795
|
+
state.memory?.organizationId !== resolvedOrg.id) {
|
|
796
|
+
await writeMemoryOrganizationId(cwd, resolvedOrg.id).catch(() => undefined);
|
|
797
|
+
}
|
|
798
|
+
// SECURITY (#4): confirm BEFORE fetching any `source_url` or uploading custom
|
|
799
|
+
// connector source — `lobu apply --dry-run` should never hit a manifest URL.
|
|
800
|
+
if (!opts.dryRun) {
|
|
801
|
+
await confirmCustomConnectorSource(state.connectors.definitions, opts.yes ?? false);
|
|
802
|
+
await materializeConnectorSource(state.connectors.definitions, fetchImpl);
|
|
803
|
+
}
|
|
804
|
+
// Snapshot remote state. Connector-def rows in the plan are computed against
|
|
805
|
+
// this (current/stale) catalog — "create" when the key isn't installed,
|
|
806
|
+
// "update" when it is. Connector defs are NOT installed here; that happens in
|
|
807
|
+
// `executePlan`, AFTER plan confirmation.
|
|
167
808
|
const remote = await fetchRemoteSnapshot(client, state, opts.only);
|
|
809
|
+
// Validate connection/auth-profile config against the catalog we have now,
|
|
810
|
+
// but SKIP schema validation for connector keys declared locally — those
|
|
811
|
+
// might update an already-installed schema in this same apply, so they're
|
|
812
|
+
// schema-validated later (post-install, against the fresh catalog) inside
|
|
813
|
+
// `executePlan`. Structural checks (auth-slug existence, connector match)
|
|
814
|
+
// still run here for every connection.
|
|
815
|
+
validateConnectorState(state, remote.connectorDefinitions, {
|
|
816
|
+
skipSchemaForConnectorKeys: locallyDeclaredConnectorKeys(state),
|
|
817
|
+
});
|
|
168
818
|
const plan = computeDiff(state, remote, { only: opts.only });
|
|
169
819
|
printText(renderPlan(plan));
|
|
170
820
|
if (opts.dryRun) {
|
|
171
|
-
printText(chalk.dim("\nDry run — no changes applied."));
|
|
821
|
+
printText(chalk.dim("\nDry run — no changes applied. (Connector-definition install + post-install schema validation are skipped in dry-run.)"));
|
|
172
822
|
return;
|
|
173
823
|
}
|
|
174
|
-
|
|
824
|
+
const hasPendingAuth = plan.rows.some((r) => r.kind === "auth-profile" && "needsAuth" in r && r.needsAuth);
|
|
825
|
+
if (plan.counts.create === 0 && plan.counts.update === 0 && !hasPendingAuth) {
|
|
175
826
|
printText(chalk.green("\nNothing to apply."));
|
|
176
827
|
return;
|
|
177
828
|
}
|
|
178
|
-
// Build a plain-text summary for the inquirer prompt — chalk-decorated
|
|
179
|
-
// text confuses some terminals when re-printed by the prompt library.
|
|
180
829
|
const { create, update, noop, drift } = plan.counts;
|
|
181
|
-
const summaryLine = `${create} create, ${update} update, ${noop} noop, ${drift} drift`;
|
|
830
|
+
const summaryLine = `${create} create, ${update} update, ${noop} noop, ${drift} drift${hasPendingAuth ? " + pending auth" : ""}`;
|
|
182
831
|
const approved = await confirmPlan({
|
|
183
832
|
yes: opts.yes ?? false,
|
|
184
833
|
summaryLine,
|
|
@@ -187,23 +836,30 @@ export async function applyCommand(opts = {}) {
|
|
|
187
836
|
printText(chalk.dim("\nCancelled."));
|
|
188
837
|
return;
|
|
189
838
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
printText(chalk.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
printError(`\n${err.message}`);
|
|
198
|
-
}
|
|
199
|
-
else if (err instanceof Error) {
|
|
200
|
-
printError(`\n${err.message}`);
|
|
839
|
+
const pendingAuth = [];
|
|
840
|
+
let applyErr;
|
|
841
|
+
if (plan.counts.create > 0 || plan.counts.update > 0) {
|
|
842
|
+
printText(chalk.bold("\nApplying:"));
|
|
843
|
+
try {
|
|
844
|
+
await executePlan({ client, state, plan, remote }, pendingAuth);
|
|
845
|
+
printText(chalk.green("\nApply complete."));
|
|
201
846
|
}
|
|
202
|
-
|
|
203
|
-
|
|
847
|
+
catch (err) {
|
|
848
|
+
applyErr = err;
|
|
849
|
+
printError(`\n${err instanceof Error ? err.message : String(err)}`);
|
|
850
|
+
printError("Apply halted on first failure. Re-run `lobu apply` once the underlying issue is resolved — every endpoint is idempotent.");
|
|
204
851
|
}
|
|
205
|
-
printError("Apply halted on first failure. Re-run `lobu apply` once the underlying issue is resolved — every endpoint is idempotent.");
|
|
206
|
-
throw err;
|
|
207
852
|
}
|
|
853
|
+
// Always render the punch-list — even on partial failure, so the operator
|
|
854
|
+
// keeps the connect URLs and the informational notes.
|
|
855
|
+
const finalPending = await collectPendingAuthFromPlan(client, plan, pendingAuth);
|
|
856
|
+
const punchList = renderPostApplyPunchList({
|
|
857
|
+
pendingAuth: finalPending,
|
|
858
|
+
notes: plan.notes,
|
|
859
|
+
});
|
|
860
|
+
if (punchList)
|
|
861
|
+
printText(punchList);
|
|
862
|
+
if (applyErr)
|
|
863
|
+
throw applyErr;
|
|
208
864
|
}
|
|
209
865
|
//# sourceMappingURL=apply-cmd.js.map
|