@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.
Files changed (177) hide show
  1. package/dist/commands/_lib/apply/apply-cmd.d.ts +36 -0
  2. package/dist/commands/_lib/apply/apply-cmd.d.ts.map +1 -1
  3. package/dist/commands/_lib/apply/apply-cmd.js +696 -40
  4. package/dist/commands/_lib/apply/apply-cmd.js.map +1 -1
  5. package/dist/commands/_lib/apply/client.d.ts +285 -0
  6. package/dist/commands/_lib/apply/client.d.ts.map +1 -1
  7. package/dist/commands/_lib/apply/client.js +469 -28
  8. package/dist/commands/_lib/apply/client.js.map +1 -1
  9. package/dist/commands/_lib/apply/desired-state.d.ts +187 -3
  10. package/dist/commands/_lib/apply/desired-state.d.ts.map +1 -1
  11. package/dist/commands/_lib/apply/desired-state.js +879 -88
  12. package/dist/commands/_lib/apply/desired-state.js.map +1 -1
  13. package/dist/commands/_lib/apply/diff.d.ts +72 -3
  14. package/dist/commands/_lib/apply/diff.d.ts.map +1 -1
  15. package/dist/commands/_lib/apply/diff.js +473 -84
  16. package/dist/commands/_lib/apply/diff.js.map +1 -1
  17. package/dist/commands/_lib/apply/prompt.d.ts +6 -0
  18. package/dist/commands/_lib/apply/prompt.d.ts.map +1 -1
  19. package/dist/commands/_lib/apply/prompt.js +16 -0
  20. package/dist/commands/_lib/apply/prompt.js.map +1 -1
  21. package/dist/commands/_lib/apply/render.d.ts +9 -0
  22. package/dist/commands/_lib/apply/render.d.ts.map +1 -1
  23. package/dist/commands/_lib/apply/render.js +80 -3
  24. package/dist/commands/_lib/apply/render.js.map +1 -1
  25. package/dist/commands/_lib/connector-loader.d.ts +3 -0
  26. package/dist/commands/_lib/connector-loader.d.ts.map +1 -0
  27. package/dist/commands/_lib/connector-loader.js +129 -0
  28. package/dist/commands/_lib/connector-loader.js.map +1 -0
  29. package/dist/commands/_lib/connector-run-cmd.d.ts +35 -0
  30. package/dist/commands/_lib/connector-run-cmd.d.ts.map +1 -0
  31. package/dist/commands/_lib/connector-run-cmd.js +351 -0
  32. package/dist/commands/_lib/connector-run-cmd.js.map +1 -0
  33. package/dist/commands/_lib/export/export-cmd.d.ts +35 -0
  34. package/dist/commands/_lib/export/export-cmd.d.ts.map +1 -0
  35. package/dist/commands/_lib/export/export-cmd.js +329 -0
  36. package/dist/commands/_lib/export/export-cmd.js.map +1 -0
  37. package/dist/commands/agent.d.ts.map +1 -1
  38. package/dist/commands/agent.js +11 -14
  39. package/dist/commands/agent.js.map +1 -1
  40. package/dist/commands/chat.d.ts.map +1 -1
  41. package/dist/commands/chat.js +28 -7
  42. package/dist/commands/chat.js.map +1 -1
  43. package/dist/commands/connector.d.ts +3 -0
  44. package/dist/commands/connector.d.ts.map +1 -0
  45. package/dist/commands/connector.js +5 -0
  46. package/dist/commands/connector.js.map +1 -0
  47. package/dist/commands/dev.d.ts +23 -0
  48. package/dist/commands/dev.d.ts.map +1 -1
  49. package/dist/commands/dev.js +273 -8
  50. package/dist/commands/dev.js.map +1 -1
  51. package/dist/commands/doctor.d.ts.map +1 -1
  52. package/dist/commands/doctor.js +2 -3
  53. package/dist/commands/doctor.js.map +1 -1
  54. package/dist/commands/eval.d.ts.map +1 -1
  55. package/dist/commands/eval.js +28 -18
  56. package/dist/commands/eval.js.map +1 -1
  57. package/dist/commands/init.d.ts +2 -0
  58. package/dist/commands/init.d.ts.map +1 -1
  59. package/dist/commands/init.js +29 -1
  60. package/dist/commands/init.js.map +1 -1
  61. package/dist/commands/login.d.ts.map +1 -1
  62. package/dist/commands/login.js +22 -16
  63. package/dist/commands/login.js.map +1 -1
  64. package/dist/commands/memory/_lib/browser-auth-cmd.d.ts.map +1 -1
  65. package/dist/commands/memory/_lib/browser-auth-cmd.js +15 -144
  66. package/dist/commands/memory/_lib/browser-auth-cmd.js.map +1 -1
  67. package/dist/commands/memory/_lib/schema.d.ts +28 -1
  68. package/dist/commands/memory/_lib/schema.d.ts.map +1 -1
  69. package/dist/commands/memory/_lib/schema.js +120 -4
  70. package/dist/commands/memory/_lib/schema.js.map +1 -1
  71. package/dist/commands/memory/_lib/seed-cmd.d.ts.map +1 -1
  72. package/dist/commands/memory/_lib/seed-cmd.js +41 -18
  73. package/dist/commands/memory/_lib/seed-cmd.js.map +1 -1
  74. package/dist/commands/org.d.ts +4 -0
  75. package/dist/commands/org.d.ts.map +1 -1
  76. package/dist/commands/org.js +10 -0
  77. package/dist/commands/org.js.map +1 -1
  78. package/dist/commands/token.d.ts +9 -0
  79. package/dist/commands/token.d.ts.map +1 -1
  80. package/dist/commands/token.js +54 -3
  81. package/dist/commands/token.js.map +1 -1
  82. package/dist/commands/validate.d.ts.map +1 -1
  83. package/dist/commands/validate.js +4 -13
  84. package/dist/commands/validate.js.map +1 -1
  85. package/dist/config/loader.js +2 -2
  86. package/dist/config/loader.js.map +1 -1
  87. package/dist/connectors/README.md +2 -3
  88. package/dist/connectors/apple_health.ts +138 -0
  89. package/dist/connectors/apple_photos.ts +178 -0
  90. package/dist/connectors/apple_screen_time.ts +82 -0
  91. package/dist/connectors/browser/evaluate.ts +120 -0
  92. package/dist/connectors/browser/fill_form.ts +107 -0
  93. package/dist/connectors/browser/page_text.ts +108 -0
  94. package/dist/connectors/browser-scraper-utils.ts +111 -3
  95. package/dist/connectors/capterra.ts +5 -1
  96. package/dist/connectors/chrome_tabs.ts +74 -0
  97. package/dist/connectors/g2.ts +5 -1
  98. package/dist/connectors/github.ts +16 -38
  99. package/dist/connectors/glassdoor.ts +5 -1
  100. package/dist/connectors/google_calendar.ts +28 -6
  101. package/dist/connectors/google_gmail.ts +6 -3
  102. package/dist/connectors/google_play.ts +32 -5
  103. package/dist/connectors/hackernews.ts +37 -2
  104. package/dist/connectors/index.ts +14 -1
  105. package/dist/connectors/linkedin.ts +32 -9
  106. package/dist/connectors/local_directory.ts +91 -0
  107. package/dist/connectors/reddit.ts +1 -0
  108. package/dist/connectors/revolut.ts +569 -0
  109. package/dist/connectors/rss.ts +33 -8
  110. package/dist/connectors/trustpilot.ts +36 -21
  111. package/dist/connectors/website.ts +8 -69
  112. package/dist/connectors/whatsapp.ts +21 -22
  113. package/dist/connectors/whatsapp_local.ts +125 -0
  114. package/dist/connectors/x.ts +17 -7
  115. package/dist/db/migrations/20260510220000_connector_required_capability.sql +47 -0
  116. package/dist/db/migrations/20260512000000_device_worker_connection_binding.sql +113 -0
  117. package/dist/db/migrations/20260512131703_connections_slug.sql +131 -0
  118. package/dist/db/migrations/20260513000000_chat_user_identities.sql +24 -0
  119. package/dist/db/migrations/20260513120000_auth_profiles_device_binding.sql +50 -0
  120. package/dist/db/migrations/20260513150000_auth_profiles_cdp_url.sql +43 -0
  121. package/dist/db/migrations/20260513200000_notifications_as_events.sql +86 -0
  122. package/dist/db/migrations/20260514000000_scheduled_jobs.sql +97 -0
  123. package/dist/db/migrations/20260514120000_auth_profiles_connector_key_nullable.sql +42 -0
  124. package/dist/db/migrations/20260514130000_connection_action_modes.sql +103 -0
  125. package/dist/db/migrations/20260514160000_auth_profiles_mirror_mode.sql +32 -0
  126. package/dist/db/migrations/20260515120000_agents_per_org_pk.sql +66 -0
  127. package/dist/db/migrations/20260515150000_geo_enrichment.sql +208 -0
  128. package/dist/db/migrations/20260515160000_drop_agents_org_id_unique.sql +24 -0
  129. package/dist/db/migrations/20260515170000_auth_profiles_default_for_connector.sql +23 -0
  130. package/dist/db/migrations/20260516120000_agents_per_org_pk_swap.sql +125 -0
  131. package/dist/db/migrations/20260516200000_events_search_tsv.sql +134 -0
  132. package/dist/db/migrations/20260516200100_events_lifecycle_changes_index.sql +25 -0
  133. package/dist/db/migrations/20260517010000_drop_unused_indexes.sql +49 -0
  134. package/dist/db/migrations/20260517020000_softdelete_orphan_feeds.sql +56 -0
  135. package/dist/db/migrations/20260517030000_pat_worker_id_binding.sql +27 -0
  136. package/dist/db/migrations/20260517040000_archive_orphan_watchers.sql +30 -0
  137. package/dist/db/migrations/20260517050000_watcher_agent_id_not_null.sql +34 -0
  138. package/dist/db/migrations/20260517060000_watcher_schema_additions.sql +78 -0
  139. package/dist/db/migrations/20260517150000_goals_primitive.sql +55 -0
  140. package/dist/db/migrations/20260517160000_drop_goals_primitive.sql +45 -0
  141. package/dist/db/migrations/20260518000000_pending_interactions.sql +49 -0
  142. package/dist/db/migrations/20260518010000_runs_heartbeat_reaper_index.sql +22 -0
  143. package/dist/eval/client.d.ts.map +1 -1
  144. package/dist/eval/client.js +11 -0
  145. package/dist/eval/client.js.map +1 -1
  146. package/dist/eval/grader.js +2 -1
  147. package/dist/eval/grader.js.map +1 -1
  148. package/dist/eval/types.d.ts +2 -0
  149. package/dist/eval/types.d.ts.map +1 -1
  150. package/dist/index.d.ts +11 -0
  151. package/dist/index.d.ts.map +1 -1
  152. package/dist/index.js +115 -114
  153. package/dist/index.js.map +1 -1
  154. package/dist/internal/context.d.ts +9 -0
  155. package/dist/internal/context.d.ts.map +1 -1
  156. package/dist/internal/context.js +41 -6
  157. package/dist/internal/context.js.map +1 -1
  158. package/dist/internal/credentials.d.ts +5 -0
  159. package/dist/internal/credentials.d.ts.map +1 -1
  160. package/dist/internal/credentials.js +75 -1
  161. package/dist/internal/credentials.js.map +1 -1
  162. package/dist/internal/gateway-url.d.ts +14 -0
  163. package/dist/internal/gateway-url.d.ts.map +1 -1
  164. package/dist/internal/gateway-url.js +19 -0
  165. package/dist/internal/gateway-url.js.map +1 -1
  166. package/dist/internal/index.d.ts +1 -1
  167. package/dist/internal/index.d.ts.map +1 -1
  168. package/dist/internal/index.js +1 -1
  169. package/dist/internal/index.js.map +1 -1
  170. package/dist/internal/local-env.d.ts.map +1 -1
  171. package/dist/internal/local-env.js +9 -2
  172. package/dist/internal/local-env.js.map +1 -1
  173. package/dist/server.bundle.mjs +42251 -36931
  174. package/dist/start-local.bundle.mjs +16437 -9882
  175. package/dist/templates/TESTING.md.tmpl +9 -9
  176. package/package.json +8 -6
  177. 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
- * v1 secret check: every `$VAR` referenced in lobu.toml must be present in
14
- * the apply runner's environment. The file-loader already substitutes envs
15
- * in-place during gateway boot, so this is the same set of names operators
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
- * Execute the plan in dependency order. Plan §footgun-7: agents → settings →
53
- * connections entity types relationship types. No retry loop, no
54
- * topological sort. First failure prints partial progress and re-throws.
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 executePlan(ctx) {
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 { state, configPath } = await loadDesiredState({ cwd });
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
- const { client, orgSlug } = await resolveApplyClient({
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
- if (plan.counts.create === 0 && plan.counts.update === 0) {
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
- printText(chalk.bold("\nApplying:"));
191
- try {
192
- await executePlan({ client, state, plan });
193
- printText(chalk.green("\nApply complete."));
194
- }
195
- catch (err) {
196
- if (err instanceof ApiError) {
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
- else {
203
- printError(`\n${String(err)}`);
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