@lobu/cli 6.0.1 → 7.0.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 (265) hide show
  1. package/README.md +20 -27
  2. package/dist/bundled-skills/lobu/SKILL.md +11 -11
  3. package/dist/commands/_lib/apply/apply-cmd.d.ts +38 -0
  4. package/dist/commands/_lib/apply/apply-cmd.d.ts.map +1 -1
  5. package/dist/commands/_lib/apply/apply-cmd.js +574 -40
  6. package/dist/commands/_lib/apply/apply-cmd.js.map +1 -1
  7. package/dist/commands/_lib/apply/client.d.ts +180 -1
  8. package/dist/commands/_lib/apply/client.d.ts.map +1 -1
  9. package/dist/commands/_lib/apply/client.js +308 -28
  10. package/dist/commands/_lib/apply/client.js.map +1 -1
  11. package/dist/commands/_lib/apply/desired-state.d.ts +134 -3
  12. package/dist/commands/_lib/apply/desired-state.d.ts.map +1 -1
  13. package/dist/commands/_lib/apply/desired-state.js +703 -89
  14. package/dist/commands/_lib/apply/desired-state.js.map +1 -1
  15. package/dist/commands/_lib/apply/diff.d.ts +61 -3
  16. package/dist/commands/_lib/apply/diff.d.ts.map +1 -1
  17. package/dist/commands/_lib/apply/diff.js +382 -92
  18. package/dist/commands/_lib/apply/diff.js.map +1 -1
  19. package/dist/commands/_lib/apply/prompt.d.ts +6 -0
  20. package/dist/commands/_lib/apply/prompt.d.ts.map +1 -1
  21. package/dist/commands/_lib/apply/prompt.js +16 -0
  22. package/dist/commands/_lib/apply/prompt.js.map +1 -1
  23. package/dist/commands/_lib/apply/render.d.ts +9 -0
  24. package/dist/commands/_lib/apply/render.d.ts.map +1 -1
  25. package/dist/commands/_lib/apply/render.js +80 -3
  26. package/dist/commands/_lib/apply/render.js.map +1 -1
  27. package/dist/commands/agent.d.ts +7 -0
  28. package/dist/commands/agent.d.ts.map +1 -1
  29. package/dist/commands/agent.js +65 -1
  30. package/dist/commands/agent.js.map +1 -1
  31. package/dist/commands/chat.d.ts +12 -9
  32. package/dist/commands/chat.d.ts.map +1 -1
  33. package/dist/commands/chat.js +125 -57
  34. package/dist/commands/chat.js.map +1 -1
  35. package/dist/commands/dev.d.ts +23 -7
  36. package/dist/commands/dev.d.ts.map +1 -1
  37. package/dist/commands/dev.js +197 -49
  38. package/dist/commands/dev.js.map +1 -1
  39. package/dist/commands/doctor.d.ts +1 -0
  40. package/dist/commands/doctor.d.ts.map +1 -1
  41. package/dist/commands/doctor.js +136 -0
  42. package/dist/commands/doctor.js.map +1 -1
  43. package/dist/commands/eval.d.ts +8 -0
  44. package/dist/commands/eval.d.ts.map +1 -1
  45. package/dist/commands/eval.js +72 -6
  46. package/dist/commands/eval.js.map +1 -1
  47. package/dist/commands/init.d.ts +22 -5
  48. package/dist/commands/init.d.ts.map +1 -1
  49. package/dist/commands/init.js +355 -182
  50. package/dist/commands/init.js.map +1 -1
  51. package/dist/commands/link.d.ts +11 -0
  52. package/dist/commands/link.d.ts.map +1 -0
  53. package/dist/commands/link.js +28 -0
  54. package/dist/commands/link.js.map +1 -0
  55. package/dist/commands/login.d.ts.map +1 -1
  56. package/dist/commands/login.js +14 -2
  57. package/dist/commands/login.js.map +1 -1
  58. package/dist/commands/memory/_lib/browser-auth-cmd.d.ts.map +1 -1
  59. package/dist/commands/memory/_lib/browser-auth-cmd.js +3 -3
  60. package/dist/commands/memory/_lib/browser-auth-cmd.js.map +1 -1
  61. package/dist/commands/memory/_lib/mcp.d.ts +2 -2
  62. package/dist/commands/memory/_lib/mcp.d.ts.map +1 -1
  63. package/dist/commands/memory/_lib/mcp.js +24 -12
  64. package/dist/commands/memory/_lib/mcp.js.map +1 -1
  65. package/dist/commands/memory/_lib/openclaw-auth.d.ts +1 -0
  66. package/dist/commands/memory/_lib/openclaw-auth.d.ts.map +1 -1
  67. package/dist/commands/memory/_lib/openclaw-auth.js +14 -3
  68. package/dist/commands/memory/_lib/openclaw-auth.js.map +1 -1
  69. package/dist/commands/memory/_lib/openclaw-cmd.js +1 -1
  70. package/dist/commands/memory/_lib/openclaw-cmd.js.map +1 -1
  71. package/dist/commands/memory/_lib/schema.d.ts +29 -2
  72. package/dist/commands/memory/_lib/schema.d.ts.map +1 -1
  73. package/dist/commands/memory/_lib/schema.js +121 -5
  74. package/dist/commands/memory/_lib/schema.js.map +1 -1
  75. package/dist/commands/memory/_lib/seed-cmd.d.ts.map +1 -1
  76. package/dist/commands/memory/_lib/seed-cmd.js +46 -24
  77. package/dist/commands/memory/_lib/seed-cmd.js.map +1 -1
  78. package/dist/commands/memory/run.d.ts.map +1 -1
  79. package/dist/commands/memory/run.js +2 -2
  80. package/dist/commands/memory/run.js.map +1 -1
  81. package/dist/commands/org.d.ts +4 -0
  82. package/dist/commands/org.d.ts.map +1 -1
  83. package/dist/commands/org.js +10 -0
  84. package/dist/commands/org.js.map +1 -1
  85. package/dist/commands/platforms/platform-prompts.d.ts +0 -1
  86. package/dist/commands/platforms/platform-prompts.d.ts.map +1 -1
  87. package/dist/commands/platforms/platform-prompts.js +54 -8
  88. package/dist/commands/platforms/platform-prompts.js.map +1 -1
  89. package/dist/commands/telemetry.d.ts +10 -0
  90. package/dist/commands/telemetry.d.ts.map +1 -0
  91. package/dist/commands/telemetry.js +68 -0
  92. package/dist/commands/telemetry.js.map +1 -0
  93. package/dist/commands/token.d.ts +9 -0
  94. package/dist/commands/token.d.ts.map +1 -1
  95. package/dist/commands/token.js +54 -0
  96. package/dist/commands/token.js.map +1 -1
  97. package/dist/commands/whoami.d.ts.map +1 -1
  98. package/dist/commands/whoami.js +1 -1
  99. package/dist/commands/whoami.js.map +1 -1
  100. package/dist/connectors/README.md +534 -0
  101. package/dist/connectors/__tests__/browser-scraper-utils.test.ts +186 -0
  102. package/dist/connectors/apple_health.ts +138 -0
  103. package/dist/connectors/apple_screen_time.ts +82 -0
  104. package/dist/connectors/browser-scraper-utils.ts +246 -0
  105. package/dist/connectors/capterra.ts +277 -0
  106. package/dist/connectors/g2.ts +290 -0
  107. package/dist/connectors/github.ts +1530 -0
  108. package/dist/connectors/glassdoor.ts +295 -0
  109. package/dist/connectors/gmaps.ts +197 -0
  110. package/dist/connectors/google_calendar.ts +641 -0
  111. package/dist/connectors/google_gmail.ts +754 -0
  112. package/dist/connectors/google_photos.ts +776 -0
  113. package/dist/connectors/google_play.ts +349 -0
  114. package/dist/connectors/hackernews.ts +471 -0
  115. package/dist/connectors/index.ts +28 -0
  116. package/dist/connectors/ios_appstore.ts +226 -0
  117. package/dist/connectors/linkedin.ts +494 -0
  118. package/dist/connectors/local_directory.ts +91 -0
  119. package/dist/connectors/microsoft_outlook.ts +410 -0
  120. package/dist/connectors/producthunt.ts +471 -0
  121. package/dist/connectors/reddit.ts +600 -0
  122. package/dist/connectors/revolut.ts +572 -0
  123. package/dist/connectors/rss.ts +448 -0
  124. package/dist/connectors/spotify.ts +590 -0
  125. package/dist/connectors/trustpilot.ts +203 -0
  126. package/dist/connectors/website.ts +629 -0
  127. package/dist/connectors/whatsapp.ts +1081 -0
  128. package/dist/connectors/whatsapp_local.ts +125 -0
  129. package/dist/connectors/x.ts +536 -0
  130. package/dist/connectors/youtube.ts +666 -0
  131. package/dist/db/migrations/00000000000000_baseline.sql +4867 -0
  132. package/dist/db/migrations/20260405193000_add_mcp_sessions.sql +33 -0
  133. package/dist/db/migrations/20260408120000_remove_system_connectors.sql +48 -0
  134. package/dist/db/migrations/20260408120001_optional_compiled_code.sql +6 -0
  135. package/dist/db/migrations/20260409110000_add_active_watcher_run_index.sql +9 -0
  136. package/dist/db/migrations/20260409130000_connector_default_config.sql +5 -0
  137. package/dist/db/migrations/20260410120000_add_agent_secrets.sql +25 -0
  138. package/dist/db/migrations/20260413170000_add_watcher_group_id.sql +67 -0
  139. package/dist/db/migrations/20260416120000_add_entity_wa_jid_index.sql +14 -0
  140. package/dist/db/migrations/20260417100000_add_entity_identities.sql +77 -0
  141. package/dist/db/migrations/20260418100000_add_auth_runs.sql +83 -0
  142. package/dist/db/migrations/20260418110000_add_runs_created_by_user.sql +18 -0
  143. package/dist/db/migrations/20260419120000_add_event_identity_indexes.sql +56 -0
  144. package/dist/db/migrations/20260420120000_extend_reserved_org_slugs.sql +56 -0
  145. package/dist/db/migrations/20260424030000_add_watcher_run_correlation.sql +52 -0
  146. package/dist/db/migrations/20260424130000_relax_events_client_id_fk.sql +47 -0
  147. package/dist/db/migrations/20260425100000_normalize_watcher_feedback.sql +91 -0
  148. package/dist/db/migrations/20260425120000_add_run_diagnostics.sql +20 -0
  149. package/dist/db/migrations/20260425130000_add_repair_agent_plumbing.sql +46 -0
  150. package/dist/db/migrations/20260426120000_entities_entity_type_fk.sql +101 -0
  151. package/dist/db/migrations/20260426130000_db_integrity_cleanup.sql +104 -0
  152. package/dist/db/migrations/20260426130001_db_integrity_cleanup_concurrent.sql +187 -0
  153. package/dist/db/migrations/20260427133000_events_created_by_nullable.sql +74 -0
  154. package/dist/db/migrations/20260427140000_identity_engine_indexes.sql +140 -0
  155. package/dist/db/migrations/20260427150000_drop_events_source_id.sql +177 -0
  156. package/dist/db/migrations/20260427160000_drop_dead_schema.sql +76 -0
  157. package/dist/db/migrations/20260427170000_market_founder_to_member.sql +364 -0
  158. package/dist/db/migrations/20260428040000_cascade_events_watchers_org_fk.sql +66 -0
  159. package/dist/db/migrations/20260428050000_add_runs_approved_input.sql +9 -0
  160. package/dist/db/migrations/20260429010000_auth_profile_tenant_scoped_fk.sql +79 -0
  161. package/dist/db/migrations/20260429060000_extend_runs_for_lobu_queue.sql +108 -0
  162. package/dist/db/migrations/20260429120000_agent_changed_notify.sql +97 -0
  163. package/dist/db/migrations/20260429120100_user_auth_profiles_and_model_prefs.sql +36 -0
  164. package/dist/db/migrations/20260429120200_fix_notify_old_keys.sql +130 -0
  165. package/dist/db/migrations/20260429130000_oauth_states_cli_sessions_rate_limits.sql +83 -0
  166. package/dist/db/migrations/20260429140000_phase8_grants_chat_connections_mcp_sessions.sql +84 -0
  167. package/dist/db/migrations/20260429140100_runs_priority_expires_at_retry_delay.sql +44 -0
  168. package/dist/db/migrations/20260429180000_drop_invalidatable_cache_triggers.sql +25 -0
  169. package/dist/db/migrations/20260430005614_agents_apply_fields.sql +21 -0
  170. package/dist/db/migrations/20260430022231_fix_connection_config_encryption.sql +69 -0
  171. package/dist/db/migrations/20260430151215_add_task_run_type.sql +77 -0
  172. package/dist/db/migrations/20260501000000_drop_cli_sessions.sql +27 -0
  173. package/dist/db/migrations/20260501133000_lobu_memory_mcp_id.sql +117 -0
  174. package/dist/db/migrations/20260502000000_drop_chat_connections.sql +60 -0
  175. package/dist/db/migrations/20260503000000_agent_secrets_org_scope.sql +56 -0
  176. package/dist/db/migrations/20260504000000_flatten_agents_drop_sandbox_model.sql +48 -0
  177. package/dist/db/migrations/20260510220000_connector_required_capability.sql +47 -0
  178. package/dist/db/migrations/20260512000000_device_worker_connection_binding.sql +113 -0
  179. package/dist/db/migrations/20260512131703_connections_slug.sql +131 -0
  180. package/dist/db/migrations/20260513000000_chat_user_identities.sql +24 -0
  181. package/dist/db/migrations/20260513120000_auth_profiles_device_binding.sql +50 -0
  182. package/dist/db/migrations/20260513150000_auth_profiles_cdp_url.sql +43 -0
  183. package/dist/db/migrations/20260513200000_notifications_as_events.sql +86 -0
  184. package/dist/db/migrations/20260514000000_scheduled_jobs.sql +97 -0
  185. package/dist/db/migrations/20260514120000_auth_profiles_connector_key_nullable.sql +42 -0
  186. package/dist/eval/types.d.ts +2 -0
  187. package/dist/eval/types.d.ts.map +1 -1
  188. package/dist/index.d.ts +11 -0
  189. package/dist/index.d.ts.map +1 -1
  190. package/dist/index.js +210 -132
  191. package/dist/index.js.map +1 -1
  192. package/dist/internal/api-client.d.ts +4 -8
  193. package/dist/internal/api-client.d.ts.map +1 -1
  194. package/dist/internal/api-client.js +1 -1
  195. package/dist/internal/api-client.js.map +1 -1
  196. package/dist/internal/context.js +2 -2
  197. package/dist/internal/context.js.map +1 -1
  198. package/dist/internal/credentials.d.ts.map +1 -1
  199. package/dist/internal/credentials.js +6 -1
  200. package/dist/internal/credentials.js.map +1 -1
  201. package/dist/internal/gateway-url.d.ts +14 -0
  202. package/dist/internal/gateway-url.d.ts.map +1 -1
  203. package/dist/internal/gateway-url.js +19 -0
  204. package/dist/internal/gateway-url.js.map +1 -1
  205. package/dist/internal/index.d.ts +3 -4
  206. package/dist/internal/index.d.ts.map +1 -1
  207. package/dist/internal/index.js +3 -3
  208. package/dist/internal/index.js.map +1 -1
  209. package/dist/internal/oauth.d.ts +6 -5
  210. package/dist/internal/oauth.d.ts.map +1 -1
  211. package/dist/internal/oauth.js +2 -2
  212. package/dist/internal/project-link.d.ts +10 -0
  213. package/dist/internal/project-link.d.ts.map +1 -0
  214. package/dist/internal/project-link.js +48 -0
  215. package/dist/internal/project-link.js.map +1 -0
  216. package/dist/providers.json +2 -2
  217. package/dist/server.bundle.mjs +31654 -30866
  218. package/dist/start-local.bundle.mjs +74409 -0
  219. package/dist/templates/README.md.tmpl +10 -11
  220. package/dist/templates/TESTING.md.tmpl +9 -9
  221. package/package.json +15 -13
  222. package/dist/__tests__/chat.integration.test.d.ts +0 -2
  223. package/dist/__tests__/chat.integration.test.d.ts.map +0 -1
  224. package/dist/__tests__/chat.integration.test.js +0 -337
  225. package/dist/__tests__/chat.integration.test.js.map +0 -1
  226. package/dist/__tests__/dev.test.d.ts +0 -2
  227. package/dist/__tests__/dev.test.d.ts.map +0 -1
  228. package/dist/__tests__/dev.test.js +0 -25
  229. package/dist/__tests__/dev.test.js.map +0 -1
  230. package/dist/__tests__/init-memory.test.d.ts +0 -2
  231. package/dist/__tests__/init-memory.test.d.ts.map +0 -1
  232. package/dist/__tests__/init-memory.test.js +0 -45
  233. package/dist/__tests__/init-memory.test.js.map +0 -1
  234. package/dist/__tests__/token.test.d.ts +0 -2
  235. package/dist/__tests__/token.test.d.ts.map +0 -1
  236. package/dist/__tests__/token.test.js +0 -52
  237. package/dist/__tests__/token.test.js.map +0 -1
  238. package/dist/commands/_lib/apply/__tests__/client.test.d.ts +0 -2
  239. package/dist/commands/_lib/apply/__tests__/client.test.d.ts.map +0 -1
  240. package/dist/commands/_lib/apply/__tests__/client.test.js +0 -23
  241. package/dist/commands/_lib/apply/__tests__/client.test.js.map +0 -1
  242. package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts +0 -2
  243. package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts.map +0 -1
  244. package/dist/commands/_lib/apply/__tests__/desired-state.test.js +0 -140
  245. package/dist/commands/_lib/apply/__tests__/desired-state.test.js.map +0 -1
  246. package/dist/commands/_lib/apply/__tests__/diff.test.d.ts +0 -2
  247. package/dist/commands/_lib/apply/__tests__/diff.test.d.ts.map +0 -1
  248. package/dist/commands/_lib/apply/__tests__/diff.test.js +0 -378
  249. package/dist/commands/_lib/apply/__tests__/diff.test.js.map +0 -1
  250. package/dist/commands/apply.d.ts +0 -3
  251. package/dist/commands/apply.d.ts.map +0 -1
  252. package/dist/commands/apply.js +0 -5
  253. package/dist/commands/apply.js.map +0 -1
  254. package/dist/commands/memory/_lib/openclaw-auth.test.d.ts +0 -2
  255. package/dist/commands/memory/_lib/openclaw-auth.test.d.ts.map +0 -1
  256. package/dist/commands/memory/_lib/openclaw-auth.test.js +0 -9
  257. package/dist/commands/memory/_lib/openclaw-auth.test.js.map +0 -1
  258. package/dist/internal/__tests__/api-client.test.d.ts +0 -2
  259. package/dist/internal/__tests__/api-client.test.d.ts.map +0 -1
  260. package/dist/internal/__tests__/api-client.test.js +0 -95
  261. package/dist/internal/__tests__/api-client.test.js.map +0 -1
  262. package/dist/internal/__tests__/context.test.d.ts +0 -2
  263. package/dist/internal/__tests__/context.test.d.ts.map +0 -1
  264. package/dist/internal/__tests__/context.test.js +0 -77
  265. package/dist/internal/__tests__/context.test.js.map +0 -1
@@ -1,25 +1,165 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
1
3
  import chalk from "chalk";
4
+ import { resolveContext } from "../../../internal/context.js";
5
+ import { loadProjectLink } from "../../../internal/project-link.js";
6
+ import { CONFIG_FILENAME } from "../../../config/loader.js";
2
7
  import { ApiError, ValidationError } from "../../memory/_lib/errors.js";
3
8
  import { printError, printText } from "../../memory/_lib/output.js";
4
9
  import { resolveApplyClient, } from "./client.js";
5
10
  import { computeDiff, } from "./diff.js";
6
- import { loadDesiredState } from "./desired-state.js";
7
- import { confirmPlan } from "./prompt.js";
8
- import { renderMissingSecrets, renderPlan, renderProgress } from "./render.js";
9
- // ── Required-secrets check ─────────────────────────────────────────────────
11
+ import { loadDesiredState, resolveConnectorSchemas, validateAuthProfileAgainstConnector, validateConnectionAgainstConnector, } from "./desired-state.js";
12
+ import { confirmCustomConnectors, confirmPlan } from "./prompt.js";
13
+ import { renderMissingSecrets, renderPlan, renderPostApplyPunchList, renderProgress, } from "./render.js";
10
14
  /**
11
- * v1 secret check: every `$VAR` referenced in lobu.toml must be present in
12
- * the apply runner's environment. The file-loader already substitutes envs
13
- * in-place during gateway boot, so this is the same set of names operators
14
- * must satisfy at runtime — surfacing it pre-mutation gives the operator
15
- * a cleaner failure than a silent empty-string config push.
16
- *
17
- * Plan §7 reserves cloud-side secret-list cross-checks for v3.
15
+ * Write `organization_id = "<id>"` into the `[memory]` section of lobu.toml
16
+ * replacing an existing value or inserting it just under the `[memory]` header.
17
+ * Surgical text edit; preserves comments and the rest of the file.
18
18
  */
19
+ async function writeMemoryOrganizationId(cwd, organizationId) {
20
+ const path = join(cwd, CONFIG_FILENAME);
21
+ const raw = await readFile(path, "utf-8");
22
+ const line = `organization_id = "${organizationId}"`;
23
+ if (/^\s*organization_id\s*=.*$/m.test(raw)) {
24
+ const next = raw.replace(/^\s*organization_id\s*=.*$/m, line);
25
+ if (next !== raw)
26
+ await writeFile(path, next);
27
+ return;
28
+ }
29
+ const header = raw.match(/^\[memory\][^\n]*$/m);
30
+ if (!header || header.index === undefined)
31
+ return;
32
+ const at = header.index + header[0].length;
33
+ await writeFile(path, `${raw.slice(0, at)}\n${line}${raw.slice(at)}`);
34
+ }
35
+ // ── Required-secrets check ─────────────────────────────────────────────────
19
36
  function checkRequiredSecrets(state) {
20
37
  const missing = state.requiredSecrets.filter((name) => process.env[name] === undefined || process.env[name] === "");
21
38
  return { missing };
22
39
  }
40
+ // ── source_url: confirmed-before-fetch, https-only, bounded fetch ──────────
41
+ const CONNECTOR_SOURCE_MAX_BYTES = 2 * 1024 * 1024; // 2 MiB
42
+ const CONNECTOR_SOURCE_FETCH_TIMEOUT_MS = 15_000;
43
+ /**
44
+ * Read a response body as a stream, counting *bytes* and aborting as soon as
45
+ * the running total exceeds `maxBytes` — before buffering the rest. Decodes to
46
+ * UTF-8 text only after the (bounded) body is in hand. Exported for testing.
47
+ */
48
+ export async function readBoundedBody(res, maxBytes, onOverflow) {
49
+ const reader = res.body?.getReader();
50
+ if (!reader) {
51
+ // No streaming body (rare; e.g. a mock). Fall back to text() + a byte check.
52
+ const text = await res.text();
53
+ if (Buffer.byteLength(text, "utf8") > maxBytes)
54
+ onOverflow();
55
+ return text;
56
+ }
57
+ const chunks = [];
58
+ let total = 0;
59
+ try {
60
+ for (;;) {
61
+ const { done, value } = await reader.read();
62
+ if (done)
63
+ break;
64
+ if (value) {
65
+ total += value.byteLength;
66
+ if (total > maxBytes) {
67
+ await reader.cancel().catch(() => undefined);
68
+ onOverflow();
69
+ }
70
+ chunks.push(value);
71
+ }
72
+ }
73
+ }
74
+ finally {
75
+ try {
76
+ reader.releaseLock?.();
77
+ }
78
+ catch {
79
+ // already released by cancel() — ignore
80
+ }
81
+ }
82
+ return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8");
83
+ }
84
+ async function materializeConnectorSource(defs, fetchImpl) {
85
+ for (const def of defs) {
86
+ if (def.sourceCode !== undefined || !def.sourceUrl)
87
+ continue;
88
+ let url;
89
+ try {
90
+ url = new URL(def.sourceUrl);
91
+ }
92
+ catch {
93
+ throw new ValidationError(`${def.sourceFile}: connector source_url is not a valid URL: ${def.sourceUrl}`);
94
+ }
95
+ if (url.protocol !== "https:") {
96
+ throw new ValidationError(`${def.sourceFile}: connector source_url must use https (got ${url.protocol}//): ${def.sourceUrl}`);
97
+ }
98
+ const controller = new AbortController();
99
+ // Single timer covering the whole exchange — connect AND body consumption.
100
+ const timer = setTimeout(() => controller.abort(), CONNECTOR_SOURCE_FETCH_TIMEOUT_MS);
101
+ let body;
102
+ try {
103
+ let res;
104
+ try {
105
+ res = await fetchImpl(def.sourceUrl, { signal: controller.signal });
106
+ }
107
+ catch (err) {
108
+ throw new ValidationError(`${def.sourceFile}: failed to fetch connector source_url ${def.sourceUrl} — ${err instanceof Error ? err.message : String(err)}`);
109
+ }
110
+ if (!res.ok) {
111
+ throw new ValidationError(`${def.sourceFile}: connector source_url ${def.sourceUrl} returned HTTP ${res.status} ${res.statusText}`);
112
+ }
113
+ const contentType = (res.headers.get("content-type") ?? "").toLowerCase();
114
+ if (contentType &&
115
+ !/(text\/|application\/(typescript|javascript|x-typescript|octet-stream))/.test(contentType)) {
116
+ throw new ValidationError(`${def.sourceFile}: connector source_url ${def.sourceUrl} returned unexpected content-type "${contentType}" — expected text/*, application/typescript, or application/javascript`);
117
+ }
118
+ try {
119
+ body = await readBoundedBody(res, CONNECTOR_SOURCE_MAX_BYTES, () => {
120
+ throw new ValidationError(`${def.sourceFile}: connector source_url ${def.sourceUrl} body exceeds the ${CONNECTOR_SOURCE_MAX_BYTES}-byte cap`);
121
+ });
122
+ }
123
+ catch (err) {
124
+ if (err instanceof ValidationError)
125
+ throw err;
126
+ throw new ValidationError(`${def.sourceFile}: failed to read connector source_url ${def.sourceUrl} — ${err instanceof Error ? err.message : String(err)}`);
127
+ }
128
+ }
129
+ finally {
130
+ clearTimeout(timer);
131
+ }
132
+ if (!body.trim()) {
133
+ throw new ValidationError(`${def.sourceFile}: connector source_url ${def.sourceUrl} returned an empty body`);
134
+ }
135
+ def.sourceCode = body;
136
+ }
137
+ }
138
+ /**
139
+ * Warn + require confirmation BEFORE the CLI fetches any `source_url` or
140
+ * uploads any custom connector source for compilation on the gateway.
141
+ *
142
+ * SECURITY: `install_connector` compiles + imports + instantiates the connector
143
+ * runtime class on the gateway. The server-side compiler currently runs with
144
+ * full gateway env/fs/network and only blocks relative imports — this consent
145
+ * gate is the operator's last line of defence. (TODO(security): sandbox the
146
+ * server-side connector compiler — tracked separately, out of scope here.)
147
+ */
148
+ async function confirmCustomConnectorSource(defs, yes) {
149
+ if (defs.length === 0)
150
+ return;
151
+ printText(chalk.yellow(`\n ⚠ This project ships ${defs.length} custom connector source ${defs.length === 1 ? "definition" : "definitions"}:`));
152
+ for (const def of defs) {
153
+ printText(chalk.yellow(def.sourceUrl
154
+ ? ` - ${def.sourceFile} → fetches ${def.sourceUrl}`
155
+ : ` - ${def.sourceFile}`));
156
+ }
157
+ 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."));
158
+ const ok = await confirmCustomConnectors(yes);
159
+ if (!ok) {
160
+ throw new ValidationError("Cancelled — custom connectors not confirmed.");
161
+ }
162
+ }
23
163
  // ── Snapshot ───────────────────────────────────────────────────────────────
24
164
  async function fetchRemoteSnapshot(client, state, only) {
25
165
  const agents = only === "memory" ? [] : await client.listAgents();
@@ -28,8 +168,6 @@ async function fetchRemoteSnapshot(client, state, only) {
28
168
  if (only !== "memory") {
29
169
  const desiredAgentIds = state.agents.map((a) => a.metadata.agentId);
30
170
  const remoteAgentIds = new Set(agents.map((a) => a.agentId));
31
- // Only GET settings for agents that exist; new agents have no remote
32
- // settings to compare against.
33
171
  const targetAgentIds = desiredAgentIds.filter((id) => remoteAgentIds.has(id));
34
172
  for (const agentId of targetAgentIds) {
35
173
  agentSettings.set(agentId, await client.getAgentSettings(agentId));
@@ -38,21 +176,155 @@ async function fetchRemoteSnapshot(client, state, only) {
38
176
  }
39
177
  const entityTypes = only === "agents" ? [] : await client.listEntityTypes();
40
178
  const relationshipTypes = only === "agents" ? [] : await client.listRelationshipTypes();
179
+ const watchers = only === "agents" ? [] : await client.listWatchers();
180
+ // Connectors run only on a full apply (`--only` skips them).
181
+ const hasConnectors = state.connectors.definitions.length > 0 ||
182
+ state.connectors.authProfiles.length > 0 ||
183
+ state.connectors.connections.length > 0;
184
+ const connectorDefinitions = only || !hasConnectors ? [] : await client.listConnectorDefinitions(true);
185
+ const authProfiles = only || !hasConnectors ? [] : await client.listAuthProfiles();
186
+ const connections = only || !hasConnectors ? [] : await client.listConnections();
187
+ const feedsByConnectionId = new Map();
188
+ if (!only && hasConnectors) {
189
+ const desiredConnSlugs = new Set(state.connectors.connections.map((c) => c.slug));
190
+ for (const conn of connections) {
191
+ if (!desiredConnSlugs.has(conn.slug))
192
+ continue;
193
+ feedsByConnectionId.set(conn.id, await client.listFeeds(conn.id));
194
+ }
195
+ }
41
196
  return {
42
197
  agents,
43
198
  agentSettings,
44
199
  platformsByAgent,
45
200
  entityTypes,
46
201
  relationshipTypes,
202
+ watchers,
203
+ connectorDefinitions,
204
+ authProfiles,
205
+ connections,
206
+ feedsByConnectionId,
47
207
  };
48
208
  }
209
+ // ── Connector definition install (runs INSIDE executePlan, after confirm) ──
49
210
  /**
50
- * Execute the plan in dependency order. Plan §footgun-7: agents → settings →
51
- * connections entity types relationship types. No retry loop, no
52
- * topological sort. First failure prints partial progress and re-throws.
211
+ * Install/update the project's custom connector definitions, then any *bundled*
212
+ * connectors referenced by an auth-profile / connection (the server only
213
+ * resolves *installed* defs in `create_auth_profile` / `create_feed`, not the
214
+ * catalog). Returns the fresh connector-definition catalog.
53
215
  */
54
- async function executePlan(ctx) {
216
+ async function installConnectorDefinitions(client, state, catalog, plan) {
217
+ const installedKeys = new Set(catalog.filter((d) => d.installed).map((d) => d.key));
218
+ // Connector keys this project supplies its own source for — these must NEVER
219
+ // be replaced by a bundled `source_uri` install, even if a bundled connector
220
+ // shares the key. (`null` keys — auto-discovered `*.connector.ts` whose key
221
+ // the server resolves at compile time — are added to this set below as soon
222
+ // as `install_connector` returns the resolved key, so the bundled loop can't
223
+ // race them either.)
224
+ const locallySuppliedKeys = new Set(state.connectors.definitions
225
+ .map((d) => d.key)
226
+ .filter((k) => !!k));
227
+ let mutated = false;
228
+ // Iterate the plan's connector-definition rows so progress mirrors the plan.
229
+ for (const row of plan.rows) {
230
+ if (row.kind !== "connector-definition")
231
+ continue;
232
+ if (row.verb === "noop" || row.verb === "drift")
233
+ continue;
234
+ const def = row.desired;
235
+ if (!def)
236
+ continue;
237
+ const result = def.sourceCode !== undefined
238
+ ? await client.installConnector({ sourceCode: def.sourceCode })
239
+ : await client.installConnector({ sourceUrl: def.sourceUrl });
240
+ if (result.connectorKey) {
241
+ locallySuppliedKeys.add(result.connectorKey);
242
+ installedKeys.add(result.connectorKey);
243
+ }
244
+ mutated = true;
245
+ printText(renderProgress(row.verb, "connector-definition", result.connectorKey || def.key || def.sourceFile, result.updated ? "(installed)" : "(unchanged)"));
246
+ }
247
+ // Bundled connectors referenced by an auth-profile / connection — installed
248
+ // ONLY if the org doesn't already have that key (installed in a prior apply
249
+ // or just installed from local source above). A locally-supplied key is never
250
+ // overwritten by the bundled `source_uri`.
251
+ const catalogByKey = new Map(catalog.filter((d) => d.installable && d.source_uri).map((d) => [d.key, d]));
252
+ const referenced = new Set([
253
+ ...state.connectors.authProfiles.map((p) => p.connector),
254
+ ...state.connectors.connections.map((c) => c.connector),
255
+ ]);
256
+ for (const key of [...referenced].sort()) {
257
+ if (installedKeys.has(key) || locallySuppliedKeys.has(key))
258
+ continue;
259
+ const entry = catalogByKey.get(key);
260
+ if (!entry?.source_uri)
261
+ continue; // custom local-only — handled above
262
+ const result = await client.installConnector({
263
+ sourceUri: entry.source_uri,
264
+ });
265
+ mutated = true;
266
+ printText(renderProgress("create", "connector-definition", result.connectorKey || key, result.updated ? "(installed bundled)" : "(bundled — unchanged)"));
267
+ }
268
+ return mutated ? await client.listConnectorDefinitions(true) : catalog;
269
+ }
270
+ export function validateConnectorState(state, connectorDefinitions, opts = {}) {
271
+ const defByKey = new Map(connectorDefinitions.map((d) => [d.key, d]));
272
+ const authProfilesBySlug = new Map(state.connectors.authProfiles.map((p) => [p.slug, p]));
273
+ if (opts.requireInstalled) {
274
+ const refs = [
275
+ ...state.connectors.authProfiles.map((p) => ({
276
+ connector: p.connector,
277
+ ref: `auth profile "${p.slug}"`,
278
+ })),
279
+ ...state.connectors.connections.map((c) => ({
280
+ connector: c.connector,
281
+ ref: `connection "${c.slug}"`,
282
+ })),
283
+ ];
284
+ for (const { connector, ref } of refs) {
285
+ const def = defByKey.get(connector);
286
+ if (!def || def.installed !== true) {
287
+ 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)`);
288
+ }
289
+ }
290
+ }
291
+ const schemasFor = (connectorKey) => {
292
+ if (opts.skipSchemaForConnectorKeys?.has(connectorKey))
293
+ return null;
294
+ const def = defByKey.get(connectorKey);
295
+ return def ? resolveConnectorSchemas(def) : null;
296
+ };
297
+ for (const profile of state.connectors.authProfiles) {
298
+ validateAuthProfileAgainstConnector(profile, schemasFor(profile.connector));
299
+ }
300
+ for (const connection of state.connectors.connections) {
301
+ validateConnectionAgainstConnector(connection, authProfilesBySlug, schemasFor(connection.connector));
302
+ }
303
+ }
304
+ // Connector keys declared locally (`*.connector.ts` / `type: connector`).
305
+ // We don't know the key for an auto-discovered `*.connector.ts` until the
306
+ // server compiles it — those have `key === null` — so they can't be in the
307
+ // skip set; their connections are validated only after install (when the key
308
+ // is known and the def is in the refreshed catalog).
309
+ export function locallyDeclaredConnectorKeys(state) {
310
+ return new Set(state.connectors.definitions
311
+ .map((d) => d.key)
312
+ .filter((k) => !!k));
313
+ }
314
+ async function executePlan(ctx, pendingAuth) {
55
315
  const rowsByKind = (kind) => ctx.plan.rows.filter((row) => row.kind === kind && row.verb !== "noop" && row.verb !== "drift");
316
+ // 0) Connector definitions FIRST — install/update them (the plan was already
317
+ // confirmed), refetch the catalog, then re-validate connection/feed config
318
+ // against the now-current schemas. Doing this before any other resource
319
+ // means a post-install schema rejection halts apply before mutating
320
+ // anything unrelated.
321
+ const hasConnectorWork = ctx.state.connectors.definitions.length > 0 ||
322
+ ctx.state.connectors.authProfiles.length > 0 ||
323
+ ctx.state.connectors.connections.length > 0;
324
+ if (hasConnectorWork) {
325
+ const freshCatalog = await installConnectorDefinitions(ctx.client, ctx.state, ctx.remote.connectorDefinitions, ctx.plan);
326
+ validateConnectorState(ctx.state, freshCatalog, { requireInstalled: true });
327
+ }
56
328
  // 1) Agents
57
329
  for (const row of rowsByKind("agent")) {
58
330
  if (row.kind !== "agent")
@@ -102,6 +374,21 @@ async function executePlan(ctx) {
102
374
  : undefined;
103
375
  printText(renderProgress(row.verb, "platform", `${row.agentId}/${row.id}`, detail));
104
376
  }
377
+ // 3b) Declarative channel bindings — reconcile after the platform upserts
378
+ // above so the connection rows exist. Runs for every agent/platform that
379
+ // declares `channels` (the server reconcile is idempotent), independent of
380
+ // whether the platform's config changed in this plan.
381
+ for (const agent of ctx.state.agents) {
382
+ for (const platform of agent.platforms) {
383
+ if (!platform.channels || platform.channels.length === 0)
384
+ continue;
385
+ const res = await ctx.client.syncPlatformChannels(agent.metadata.agentId, platform.stableId, platform.channels);
386
+ const detail = res.removed.length > 0
387
+ ? `(${res.bound.length} bound, ${res.removed.length} unbound)`
388
+ : `(${res.bound.length} bound)`;
389
+ printText(` ${chalk.cyan("↻")} ${chalk.bold("channels")} ${agent.metadata.agentId}/${platform.stableId} ${chalk.dim(detail)}`);
390
+ }
391
+ }
105
392
  // 4) Entity types
106
393
  for (const row of rowsByKind("entity-type")) {
107
394
  if (row.kind !== "entity-type")
@@ -120,11 +407,174 @@ async function executePlan(ctx) {
120
407
  await ctx.client.upsertRelationshipType(row.desired);
121
408
  printText(renderProgress(row.verb, "relationship-type", row.id));
122
409
  }
410
+ // 6) Watchers (create-only; drift ignored)
411
+ for (const row of rowsByKind("watcher")) {
412
+ if (row.kind !== "watcher")
413
+ continue;
414
+ if (!row.desired)
415
+ continue;
416
+ const w = row.desired;
417
+ await ctx.client.createWatcher({
418
+ slug: w.slug,
419
+ agentId: w.agent,
420
+ name: w.name,
421
+ description: w.description,
422
+ prompt: w.prompt,
423
+ extraction_schema: w.extractionSchema,
424
+ schedule: w.schedule,
425
+ sources: w.sources,
426
+ });
427
+ printText(renderProgress(row.verb, "watcher", row.id));
428
+ }
429
+ // Auth profiles (create / update; interactive kinds → punch-list)
430
+ for (const row of rowsByKind("auth-profile")) {
431
+ if (row.kind !== "auth-profile")
432
+ continue;
433
+ const desired = ctx.state.connectors.authProfiles.find((p) => p.slug === row.id);
434
+ if (!desired)
435
+ continue;
436
+ const result = row.verb === "create"
437
+ ? await ctx.client.createAuthProfile({
438
+ slug: desired.slug,
439
+ connector: desired.connector,
440
+ kind: desired.kind,
441
+ name: desired.name,
442
+ credentials: desired.credentials,
443
+ })
444
+ : await ctx.client.updateAuthProfile({
445
+ slug: desired.slug,
446
+ name: desired.name,
447
+ credentials: desired.credentials,
448
+ });
449
+ if ((desired.kind === "oauth_account" ||
450
+ desired.kind === "browser_session") &&
451
+ result.status !== "active") {
452
+ pendingAuth.push({
453
+ slug: desired.slug,
454
+ kind: desired.kind,
455
+ ...(result.connectUrl ? { connectUrl: result.connectUrl } : {}),
456
+ });
457
+ }
458
+ printText(renderProgress(row.verb, "auth-profile", row.id));
459
+ }
460
+ // 9) Connections, keyed by slug.
461
+ const remoteConnBySlug = new Map(ctx.remote.connections.map((c) => [c.slug, c]));
462
+ const connectionIdBySlug = new Map(ctx.remote.connections.map((c) => [c.slug, c.id]));
463
+ for (const row of rowsByKind("connection")) {
464
+ if (row.kind !== "connection")
465
+ continue;
466
+ const desired = ctx.state.connectors.connections.find((c) => c.slug === row.id);
467
+ if (!desired)
468
+ continue;
469
+ const existing = remoteConnBySlug.get(desired.slug);
470
+ if (existing && row.verb === "update") {
471
+ const updated = await ctx.client.updateConnection(existing.id, {
472
+ name: desired.name,
473
+ authProfileSlug: desired.authProfileSlug ?? null,
474
+ appAuthProfileSlug: desired.appAuthProfileSlug ?? null,
475
+ config: desired.config ?? {},
476
+ });
477
+ connectionIdBySlug.set(desired.slug, updated.id);
478
+ }
479
+ else {
480
+ const created = await ctx.client.createConnection({
481
+ slug: desired.slug,
482
+ connector: desired.connector,
483
+ name: desired.name,
484
+ authProfileSlug: desired.authProfileSlug,
485
+ appAuthProfileSlug: desired.appAuthProfileSlug,
486
+ config: desired.config,
487
+ });
488
+ connectionIdBySlug.set(desired.slug, created.id);
489
+ }
490
+ printText(renderProgress(row.verb, "connection", row.id));
491
+ }
492
+ // 10) Feeds (per connection — covers feeds whose connection itself was a noop)
493
+ for (const row of rowsByKind("feed")) {
494
+ if (row.kind !== "feed")
495
+ continue;
496
+ if (!row.desired)
497
+ continue;
498
+ const feed = row.desired;
499
+ const connectionId = connectionIdBySlug.get(row.connectionSlug);
500
+ if (connectionId === undefined) {
501
+ throw new ApiError(`feed "${feed.feedKey}" references connection "${row.connectionSlug}" which has no remote ID — connection create may have failed`);
502
+ }
503
+ const existingConn = remoteConnBySlug.get(row.connectionSlug);
504
+ const remoteFeed = existingConn
505
+ ? (ctx.remote.feedsByConnectionId.get(existingConn.id) ?? []).find((f) => f.feed_key === feed.feedKey)
506
+ : undefined;
507
+ if (remoteFeed && row.verb === "update") {
508
+ await ctx.client.updateFeed(remoteFeed.id, {
509
+ name: feed.name,
510
+ schedule: feed.schedule,
511
+ config: feed.config ?? {},
512
+ });
513
+ }
514
+ else {
515
+ await ctx.client.createFeed({
516
+ connectionId,
517
+ feedKey: feed.feedKey,
518
+ name: feed.name,
519
+ schedule: feed.schedule,
520
+ config: feed.config,
521
+ });
522
+ }
523
+ printText(renderProgress(row.verb, "feed", row.id));
524
+ }
525
+ }
526
+ // Collect pending interactive-auth profiles from a (no-op) plan and re-issue a
527
+ // fresh connect URL — used both when "nothing to apply" and on partial failure.
528
+ async function collectPendingAuthFromPlan(client, plan, already) {
529
+ const out = [...already];
530
+ for (const row of plan.rows) {
531
+ if (row.kind !== "auth-profile" || !("needsAuth" in row) || !row.needsAuth)
532
+ continue;
533
+ if (!row.desired)
534
+ continue;
535
+ const desired = row.desired;
536
+ if (out.some((p) => p.slug === desired.slug))
537
+ continue;
538
+ if (desired.kind === "oauth_account") {
539
+ // A successful reconnect implies the profile exists remotely (and yields
540
+ // a fresh connect URL). If it fails, the profile may not exist (a failed
541
+ // create in a partial apply) — don't tell the operator to go finish auth
542
+ // for something that isn't there; just skip it.
543
+ const connectUrl = await client
544
+ .reconnectAuthProfile(desired.slug)
545
+ .catch(() => undefined);
546
+ if (!connectUrl)
547
+ continue;
548
+ out.push({ slug: desired.slug, kind: desired.kind, connectUrl });
549
+ continue;
550
+ }
551
+ // browser_session (no reconnect endpoint): include only if the profile row
552
+ // actually exists remotely.
553
+ const exists = await client
554
+ .getAuthProfileBySlug(desired.slug)
555
+ .catch(() => null);
556
+ if (!exists)
557
+ continue;
558
+ out.push({ slug: desired.slug, kind: desired.kind });
559
+ }
560
+ return out;
123
561
  }
124
562
  // ── Top-level command ──────────────────────────────────────────────────────
563
+ /** "office-bot" → "Office Bot" — default display name for a bootstrapped org. */
564
+ function slugToTitle(slug) {
565
+ return (slug
566
+ .split(/[-_]+/)
567
+ .filter(Boolean)
568
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
569
+ .join(" ") || slug);
570
+ }
125
571
  export async function applyCommand(opts = {}) {
126
572
  const cwd = opts.cwd ?? process.cwd();
127
- const { state, configPath } = await loadDesiredState({ cwd });
573
+ const fetchImpl = opts.fetchImpl ?? fetch;
574
+ const { state, configPath } = await loadDesiredState({
575
+ cwd,
576
+ ...(opts.only ? { only: opts.only } : {}),
577
+ });
128
578
  printText(chalk.dim(`Config: ${configPath}`));
129
579
  // Required secrets gate: fail before any network mutation.
130
580
  const { missing } = checkRequiredSecrets(state);
@@ -132,27 +582,104 @@ export async function applyCommand(opts = {}) {
132
582
  printError(renderMissingSecrets(missing));
133
583
  throw new ValidationError(`${missing.length} required secret${missing.length === 1 ? "" : "s"} missing — see above.`);
134
584
  }
135
- const { client, orgSlug } = await resolveApplyClient({
585
+ // Org slug resolution: explicit --org active-session org ▸ `[memory].org`
586
+ // from lobu.toml. The toml slug is the declarative default — if no org with
587
+ // that slug exists yet, `lobu apply` offers to provision it (below).
588
+ const { client, orgSlug, apiBaseUrl } = await resolveApplyClient({
136
589
  url: opts.url,
137
- org: opts.org,
590
+ org: opts.org ?? state.memory?.org,
138
591
  fetchImpl: opts.fetchImpl,
139
592
  });
140
593
  printText(chalk.dim(`Org: ${orgSlug}`));
594
+ // Refuse if .lobu/project.json points at a different (context, org).
595
+ const link = await loadProjectLink(cwd);
596
+ if (link && !opts.force) {
597
+ const activeContext = await resolveContext().catch(() => null);
598
+ const contextMismatch = activeContext !== null && activeContext.name !== link.context;
599
+ const orgMismatch = orgSlug !== link.org;
600
+ if (contextMismatch || orgMismatch) {
601
+ const detail = [];
602
+ if (contextMismatch) {
603
+ detail.push(` context: linked=${link.context}, active=${activeContext.name}`);
604
+ }
605
+ if (orgMismatch) {
606
+ detail.push(` org: linked=${link.org}, applying=${orgSlug}`);
607
+ }
608
+ printError([
609
+ "",
610
+ "Project link mismatch — refusing to apply.",
611
+ ...detail,
612
+ "",
613
+ "Run `lobu link --org <slug>` to update the link, or pass `--force` to override.",
614
+ ].join("\n"));
615
+ throw new ValidationError("project-link mismatch");
616
+ }
617
+ }
618
+ // Check the resolved org exists / the operator is a member. `lobu apply`
619
+ // can't create an org headlessly — that needs a logged-in browser session —
620
+ // so a missing org stops here with a link to create it. `listOrgs()` failing
621
+ // (old server, or a token the userinfo endpoint rejects) → null → skip the
622
+ // check and let the normal flow surface any org error.
623
+ const myOrgs = await client.listOrgs().catch(() => null);
624
+ const resolvedOrg = myOrgs?.find((o) => o.slug === orgSlug) ??
625
+ (state.memory?.organizationId
626
+ ? myOrgs?.find((o) => o.id === state.memory?.organizationId)
627
+ : undefined);
628
+ if (myOrgs !== null && !resolvedOrg) {
629
+ const orgName = state.memory?.name ?? slugToTitle(orgSlug);
630
+ const createUrl = `${apiBaseUrl}/orgs/new?slug=${encodeURIComponent(orgSlug)}&name=${encodeURIComponent(orgName)}`;
631
+ printError([
632
+ "",
633
+ `Organization "${orgSlug}" not found, or you're not a member.`,
634
+ "",
635
+ ` Create it: ${createUrl}`,
636
+ ` (or: \`lobu org create ${orgSlug}\`)`,
637
+ "",
638
+ "then re-run `lobu apply`. (Or target an existing org with `--org <slug>`.)",
639
+ ].join("\n"));
640
+ throw new ValidationError(`organization "${orgSlug}" not found`);
641
+ }
642
+ // Persist the resolved org id back into lobu.toml so the whole team applies
643
+ // to the same org. Best-effort — a read-only lobu.toml must not fail apply.
644
+ // Skipped on `--dry-run`: that flag promises no mutation, local files included.
645
+ if (!opts.dryRun &&
646
+ resolvedOrg &&
647
+ state.memory?.organizationId !== resolvedOrg.id) {
648
+ await writeMemoryOrganizationId(cwd, resolvedOrg.id).catch(() => undefined);
649
+ }
650
+ // SECURITY (#4): confirm BEFORE fetching any `source_url` or uploading custom
651
+ // connector source — `lobu apply --dry-run` should never hit a manifest URL.
652
+ if (!opts.dryRun) {
653
+ await confirmCustomConnectorSource(state.connectors.definitions, opts.yes ?? false);
654
+ await materializeConnectorSource(state.connectors.definitions, fetchImpl);
655
+ }
656
+ // Snapshot remote state. Connector-def rows in the plan are computed against
657
+ // this (current/stale) catalog — "create" when the key isn't installed,
658
+ // "update" when it is. Connector defs are NOT installed here; that happens in
659
+ // `executePlan`, AFTER plan confirmation.
141
660
  const remote = await fetchRemoteSnapshot(client, state, opts.only);
661
+ // Validate connection/auth-profile config against the catalog we have now,
662
+ // but SKIP schema validation for connector keys declared locally — those
663
+ // might update an already-installed schema in this same apply, so they're
664
+ // schema-validated later (post-install, against the fresh catalog) inside
665
+ // `executePlan`. Structural checks (auth-slug existence, connector match)
666
+ // still run here for every connection.
667
+ validateConnectorState(state, remote.connectorDefinitions, {
668
+ skipSchemaForConnectorKeys: locallyDeclaredConnectorKeys(state),
669
+ });
142
670
  const plan = computeDiff(state, remote, { only: opts.only });
143
671
  printText(renderPlan(plan));
144
672
  if (opts.dryRun) {
145
- printText(chalk.dim("\nDry run — no changes applied."));
673
+ printText(chalk.dim("\nDry run — no changes applied. (Connector-definition install + post-install schema validation are skipped in dry-run.)"));
146
674
  return;
147
675
  }
148
- if (plan.counts.create === 0 && plan.counts.update === 0) {
676
+ const hasPendingAuth = plan.rows.some((r) => r.kind === "auth-profile" && "needsAuth" in r && r.needsAuth);
677
+ if (plan.counts.create === 0 && plan.counts.update === 0 && !hasPendingAuth) {
149
678
  printText(chalk.green("\nNothing to apply."));
150
679
  return;
151
680
  }
152
- // Build a plain-text summary for the inquirer prompt — chalk-decorated
153
- // text confuses some terminals when re-printed by the prompt library.
154
681
  const { create, update, noop, drift } = plan.counts;
155
- const summaryLine = `${create} create, ${update} update, ${noop} noop, ${drift} drift`;
682
+ const summaryLine = `${create} create, ${update} update, ${noop} noop, ${drift} drift${hasPendingAuth ? " + pending auth" : ""}`;
156
683
  const approved = await confirmPlan({
157
684
  yes: opts.yes ?? false,
158
685
  summaryLine,
@@ -161,23 +688,30 @@ export async function applyCommand(opts = {}) {
161
688
  printText(chalk.dim("\nCancelled."));
162
689
  return;
163
690
  }
164
- printText(chalk.bold("\nApplying:"));
165
- try {
166
- await executePlan({ client, state, plan });
167
- printText(chalk.green("\nApply complete."));
168
- }
169
- catch (err) {
170
- if (err instanceof ApiError) {
171
- printError(`\n${err.message}`);
691
+ const pendingAuth = [];
692
+ let applyErr;
693
+ if (plan.counts.create > 0 || plan.counts.update > 0) {
694
+ printText(chalk.bold("\nApplying:"));
695
+ try {
696
+ await executePlan({ client, state, plan, remote }, pendingAuth);
697
+ printText(chalk.green("\nApply complete."));
172
698
  }
173
- else if (err instanceof Error) {
174
- printError(`\n${err.message}`);
699
+ catch (err) {
700
+ applyErr = err;
701
+ printError(`\n${err instanceof Error ? err.message : String(err)}`);
702
+ printError("Apply halted on first failure. Re-run `lobu apply` once the underlying issue is resolved — every endpoint is idempotent.");
175
703
  }
176
- else {
177
- printError(`\n${String(err)}`);
178
- }
179
- printError("Apply halted on first failure. Re-run `lobu apply` once the underlying issue is resolved — every endpoint is idempotent.");
180
- throw err;
181
704
  }
705
+ // Always render the punch-list — even on partial failure, so the operator
706
+ // keeps the connect URLs and the informational notes.
707
+ const finalPending = await collectPendingAuthFromPlan(client, plan, pendingAuth);
708
+ const punchList = renderPostApplyPunchList({
709
+ pendingAuth: finalPending,
710
+ notes: plan.notes,
711
+ });
712
+ if (punchList)
713
+ printText(punchList);
714
+ if (applyErr)
715
+ throw applyErr;
182
716
  }
183
717
  //# sourceMappingURL=apply-cmd.js.map