@nanobpm/urban 0.17.1 → 0.19.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 (52) hide show
  1. package/dist/cli.js +131 -5
  2. package/dist/runtime/adapters/node.js +61 -1
  3. package/dist/runtime/connector-worker-sdk.d.ts +39 -0
  4. package/dist/runtime/connector-worker-sdk.js +40 -0
  5. package/dist/runtime/core/host.d.ts +30 -0
  6. package/dist/runtime/core/host.js +26 -1
  7. package/dist/runtime/core/modules/connectors.d.ts +53 -0
  8. package/dist/runtime/core/modules/connectors.js +204 -0
  9. package/dist/runtime/core/modules/datasource.js +20 -5
  10. package/dist/runtime/core/modules/workers.js +5 -0
  11. package/dist/runtime/core/runtime.js +7 -0
  12. package/dist/runtime/core/validate.js +34 -8
  13. package/dist/runtime/engine/nanosdk.d.ts +7 -0
  14. package/dist/runtime/engine/nanosdk.js +27 -0
  15. package/dist/runtime/index.d.ts +3 -0
  16. package/dist/runtime/index.js +5 -0
  17. package/dist/toolkit/addConnector.d.ts +29 -0
  18. package/dist/toolkit/addConnector.js +170 -0
  19. package/dist/toolkit/fsio.js +31 -2
  20. package/dist/toolkit/gen.d.ts +28 -0
  21. package/dist/toolkit/gen.js +55 -12
  22. package/dist/toolkit/index.d.ts +5 -1
  23. package/dist/toolkit/index.js +4 -1
  24. package/dist/toolkit/models.d.ts +63 -0
  25. package/dist/toolkit/models.js +142 -0
  26. package/package.json +2 -2
  27. package/src/cli.ts +128 -5
  28. package/src/runtime/adapters/node.ts +64 -1
  29. package/src/runtime/connector-worker-sdk.test.ts +56 -0
  30. package/src/runtime/connector-worker-sdk.ts +78 -0
  31. package/src/runtime/core/host.ts +40 -0
  32. package/src/runtime/core/modules/connectors.test.ts +206 -0
  33. package/src/runtime/core/modules/connectors.ts +269 -0
  34. package/src/runtime/core/modules/datasource.migrations.test.ts +114 -0
  35. package/src/runtime/core/modules/datasource.ts +24 -5
  36. package/src/runtime/core/modules/workers.test.ts +13 -0
  37. package/src/runtime/core/modules/workers.ts +4 -0
  38. package/src/runtime/core/runtime.ts +6 -0
  39. package/src/runtime/core/validate.test.ts +24 -0
  40. package/src/runtime/core/validate.ts +32 -8
  41. package/src/runtime/engine/nanosdk.test.ts +76 -0
  42. package/src/runtime/engine/nanosdk.ts +30 -0
  43. package/src/runtime/index.ts +12 -0
  44. package/src/toolkit/addConnector.test.ts +161 -0
  45. package/src/toolkit/addConnector.ts +233 -0
  46. package/src/toolkit/fsio.test.ts +23 -0
  47. package/src/toolkit/fsio.ts +22 -2
  48. package/src/toolkit/gen.test.ts +76 -1
  49. package/src/toolkit/gen.ts +81 -13
  50. package/src/toolkit/index.ts +16 -1
  51. package/src/toolkit/models.test.ts +159 -0
  52. package/src/toolkit/models.ts +193 -0
@@ -0,0 +1,204 @@
1
+ // connectors — mount connector-pack workers (ADR 0050, in-process port).
2
+ //
3
+ // A connector is an installed npm pack that ships a `nano-ide.ext.json` manifest
4
+ // and a worker `entry` program. The app enables it by adding a `workers[]` entry
5
+ // with a `connector: <pack-id>` field (no `handler`/`llm`) — see `urban add` and
6
+ // `addConnector`. This module resolves each such enabled worker to its installed
7
+ // pack, imports the pack `entry` in-process (via the host's `@nanobpm/worker`
8
+ // alias), and registers the drained handler on the engine like any other worker.
9
+ //
10
+ // The old Rust host supervised each connector worker as its own child process
11
+ // (ADR 0050 §4). The @nanobpm/urban runtime hosts app workers in-process, so it
12
+ // hosts connector workers the same way — trading process isolation for a simpler,
13
+ // single-process model. `job.error(code)` still routes to a real BPMN error.
14
+ //
15
+ // This file is runtime-agnostic (core purity): the Node/Deno specifics of aliasing
16
+ // the pack's `@nanobpm/worker` import live behind `host.importConnectorModule`.
17
+ import { BpmnError } from "../host.js";
18
+ import { workerJobType } from "../manifest.js";
19
+ import { drainDefinedWorkers, } from "../../connector-worker-sdk.js";
20
+ function joinRoot(root, p) {
21
+ return p.startsWith("/") ? p : `${root.replace(/\/+$/, "")}/${p.replace(/^\/+/, "")}`;
22
+ }
23
+ /** Env vars a pack worker requires before it can be imported/run: every config
24
+ * field that points at an env var and has no baked default. In-process, a missing
25
+ * var would let the pack's top-level guard `process.exit` the whole app, so the
26
+ * runtime skips the worker instead. */
27
+ function requiredEnv(spec) {
28
+ const seen = new Set();
29
+ for (const f of spec.configFields ?? []) {
30
+ if (f.env && (f.default === undefined || f.default === ""))
31
+ seen.add(f.env);
32
+ }
33
+ return [...seen];
34
+ }
35
+ /** Read + parse a JSON file under the app root; undefined if absent. In `strict`
36
+ * mode a malformed file throws a named parse error (used for the app's own files);
37
+ * otherwise a malformed file is treated as absent (used for third-party packs). */
38
+ async function readJson(ctx, relPath, opts) {
39
+ const path = joinRoot(ctx.root, relPath);
40
+ if (!(await ctx.host.exists(path)))
41
+ return undefined;
42
+ try {
43
+ return JSON.parse(await ctx.host.readTextFile(path));
44
+ }
45
+ catch (err) {
46
+ if (opts?.strict) {
47
+ throw new Error(`failed to parse ${relPath}: ${err.message}`);
48
+ }
49
+ return undefined;
50
+ }
51
+ }
52
+ /**
53
+ * Discover installed connector packs: read the app `package.json` dependencies,
54
+ * and for each that ships a `nano-ide.ext.json` with a worker, index it by its
55
+ * declared pack `id` (the value a manifest `worker.connector` field carries — NOT
56
+ * the npm package name).
57
+ */
58
+ export async function resolveInstalledConnectors(ctx) {
59
+ const out = new Map();
60
+ const pkg = await readJson(ctx, "package.json", { strict: true });
61
+ if (!pkg)
62
+ return out;
63
+ const names = new Set([
64
+ ...Object.keys(pkg.dependencies ?? {}),
65
+ ...Object.keys(pkg.devDependencies ?? {}),
66
+ ...Object.keys(pkg.optionalDependencies ?? {}),
67
+ ]);
68
+ for (const name of names) {
69
+ const dir = `node_modules/${name}`;
70
+ const manifest = await readJson(ctx, `${dir}/nano-ide.ext.json`);
71
+ if (!manifest || typeof manifest.id !== "string" || !manifest.id)
72
+ continue;
73
+ if (!Array.isArray(manifest.workers) || manifest.workers.length === 0)
74
+ continue;
75
+ out.set(manifest.id, { id: manifest.id, dir, manifest });
76
+ }
77
+ return out;
78
+ }
79
+ /** Wrap a pack's `DefinedConnectorWorker.handle` as an engine `JobHandler`,
80
+ * bridging the `@nanobpm/worker` job facade (complete/fail/error) to the engine
81
+ * seam. `job.error(code)` becomes a thrown {@link BpmnError} (routed to a BPMN
82
+ * error boundary by the engine adapter); `job.fail(msg)` a plain throw (retry). */
83
+ export function adaptConnectorHandler(worker) {
84
+ return async (job) => {
85
+ let outcome;
86
+ const facade = {
87
+ jobKey: job.jobKey,
88
+ type: job.jobType,
89
+ processInstanceKey: job.processInstanceKey ?? "",
90
+ elementId: job.elementId,
91
+ variables: job.variables,
92
+ async complete(vars) {
93
+ outcome = { kind: "complete", vars };
94
+ },
95
+ async fail(message) {
96
+ outcome = { kind: "fail", message };
97
+ },
98
+ async error(code, message) {
99
+ outcome = { kind: "error", code, message };
100
+ },
101
+ };
102
+ const ret = await worker.handle(facade);
103
+ if (outcome?.kind === "error")
104
+ throw new BpmnError(outcome.code, outcome.message);
105
+ if (outcome?.kind === "fail")
106
+ throw new Error(outcome.message);
107
+ if (outcome?.kind === "complete")
108
+ return outcome.vars ?? {};
109
+ // No explicit outcome: fall back to the handle's return value (SDK-style).
110
+ return ret && typeof ret === "object" ? ret : {};
111
+ };
112
+ }
113
+ /**
114
+ * Mount every enabled connector worker (`workers[]` entries carrying a
115
+ * `connector` field). Fails closed on a seam mismatch (an enabled connector whose
116
+ * pack or worker type is not installed); skips-with-warning a worker whose
117
+ * required env is unset (so a missing credential does not crash the app), or when
118
+ * the host cannot host connector workers (`importConnectorModule` absent).
119
+ */
120
+ export async function mountConnectors(ctx, app) {
121
+ const decls = (ctx.manifest.workers ?? []).filter((w) => w.connector);
122
+ const subs = [];
123
+ const jobTypes = [];
124
+ if (decls.length === 0)
125
+ return makeHandle(subs, jobTypes);
126
+ const packs = await resolveInstalledConnectors(ctx);
127
+ // Cache drained workers per imported entry (an entry may register several).
128
+ const drainedByEntry = new Map();
129
+ for (const decl of decls) {
130
+ const jobType = workerJobType(decl);
131
+ if (!jobType)
132
+ continue;
133
+ const packId = decl.connector;
134
+ const pack = packs.get(packId);
135
+ if (!pack) {
136
+ throw new Error(`connector worker "${jobType}" references pack "${packId}", which is not installed ` +
137
+ `(no dependency exposes a nano-ide.ext.json with id "${packId}"). Run \`urban add <pkg>\`.`);
138
+ }
139
+ const spec = pack.manifest.workers?.find((w) => w.type === jobType);
140
+ if (!spec) {
141
+ throw new Error(`connector worker "${jobType}" is not provided by pack "${packId}" ` +
142
+ `(its nano-ide.ext.json declares no worker of that type).`);
143
+ }
144
+ const missing = requiredEnv(spec).filter((name) => !app.env(name));
145
+ if (missing.length > 0) {
146
+ ctx.host.log("warn", "skipping connector worker: required env unset", {
147
+ jobType,
148
+ connector: packId,
149
+ missing: missing.join(", "),
150
+ });
151
+ continue;
152
+ }
153
+ if (!ctx.host.importConnectorModule) {
154
+ ctx.host.log("warn", "skipping connector worker: host cannot host connector packs", {
155
+ jobType,
156
+ connector: packId,
157
+ runtime: ctx.host.runtime,
158
+ });
159
+ continue;
160
+ }
161
+ const entryAbs = joinRoot(ctx.root, `${pack.dir}/${spec.entry}`);
162
+ let drained = drainedByEntry.get(entryAbs);
163
+ if (!drained) {
164
+ await ctx.host.importConnectorModule(entryAbs);
165
+ // Fail closed on a duplicate worker `type` within one pack entry: silently
166
+ // keeping the last registration would bind an arbitrary handler.
167
+ drained = new Map();
168
+ for (const w of drainDefinedWorkers()) {
169
+ if (drained.has(w.type)) {
170
+ throw new Error(`connector pack "${packId}" entry "${spec.entry}" registered the worker ` +
171
+ `type "${w.type}" more than once (duplicate defineWorker call).`);
172
+ }
173
+ drained.set(w.type, w);
174
+ }
175
+ drainedByEntry.set(entryAbs, drained);
176
+ }
177
+ const worker = drained.get(jobType);
178
+ if (!worker) {
179
+ throw new Error(`connector pack "${packId}" entry "${spec.entry}" did not register a worker for ` +
180
+ `"${jobType}" (its defineWorker call declares a different type).`);
181
+ }
182
+ const wrapped = adaptConnectorHandler(worker);
183
+ const sub = await ctx.engine.registerWorker(jobType, wrapped, {
184
+ workerName: `${ctx.manifest.id}:${jobType}`,
185
+ // The pack's `defineWorker({ maxParallelJobs })` is the authoritative runtime
186
+ // value; the ext.json spec is a design-time default when the code omits it.
187
+ maxParallelJobs: worker.maxParallelJobs ?? spec.maxParallelJobs,
188
+ });
189
+ subs.push(sub);
190
+ jobTypes.push(jobType);
191
+ ctx.host.log("info", "connector worker registered", { jobType, connector: packId });
192
+ }
193
+ return makeHandle(subs, jobTypes);
194
+ }
195
+ function makeHandle(subs, jobTypes) {
196
+ return {
197
+ name: "connectors",
198
+ jobTypes,
199
+ async stop() {
200
+ await Promise.allSettled(subs.map((s) => s.unsubscribe()));
201
+ },
202
+ describe: () => ({ jobTypes }),
203
+ };
204
+ }
@@ -140,11 +140,26 @@ async function applyMigrations(host, db, root, migrationsDir) {
140
140
  if (applied.has(file))
141
141
  continue;
142
142
  const sql = await host.readTextFile(`${dir}/${file}`);
143
- db.exec(sql);
144
- db.run("INSERT INTO _urban_migrations (name, applied_at) VALUES (?, ?)", [
145
- file,
146
- new Date(host.now()).toISOString(),
147
- ]);
143
+ // Apply the migration and record it in the ledger atomically. SQLite DDL is
144
+ // transactional, so wrapping both in one BEGIN/COMMIT makes a migration all-or-nothing:
145
+ // either the schema change AND its `_urban_migrations` row commit together, or neither
146
+ // does. Without this, an interruption (or an error later in the file's SQL) can leave the
147
+ // schema changed but the migration unrecorded — which poisons every future boot, because
148
+ // the runner then re-applies the "unapplied" migration and hits e.g. "duplicate column".
149
+ // Migration files must therefore not contain their own transaction-control statements.
150
+ db.exec("BEGIN");
151
+ try {
152
+ db.exec(sql);
153
+ db.run("INSERT INTO _urban_migrations (name, applied_at) VALUES (?, ?)", [
154
+ file,
155
+ new Date(host.now()).toISOString(),
156
+ ]);
157
+ db.exec("COMMIT");
158
+ }
159
+ catch (err) {
160
+ db.exec("ROLLBACK");
161
+ throw new Error(`migration "${file}" failed and was rolled back (no partial schema change, not recorded as applied): ${err instanceof Error ? err.message : String(err)}`, { cause: err });
162
+ }
148
163
  newlyApplied.push(file);
149
164
  }
150
165
  return newlyApplied;
@@ -68,6 +68,11 @@ export async function mountWorkers(ctx, app) {
68
68
  const jobType = workerJobType(decl);
69
69
  if (!jobType)
70
70
  continue;
71
+ // Connector-backed workers (a `connector` field, no handler/llm) are mounted
72
+ // separately by mountConnectors — skip them here silently so they don't trip
73
+ // the "neither handler nor llm" warning below on every startup.
74
+ if (decl.connector)
75
+ continue;
71
76
  if (!decl.handler) {
72
77
  // LLM-backed worker (schema `oneOf` handler|llm): synthesise a handler that runs
73
78
  // the job through the bound LLM (and optional decision rails) instead of loading a
@@ -8,6 +8,7 @@ import { makeRouter } from "./router.js";
8
8
  import { deployModels } from "./modules/deploy.js";
9
9
  import { provisionData, DataLayer } from "./modules/datasource.js";
10
10
  import { mountWorkers } from "./modules/workers.js";
11
+ import { mountConnectors } from "./modules/connectors.js";
11
12
  import { mountSurfaces } from "./modules/surfaces.js";
12
13
  import { mountTriggers } from "./modules/triggers.js";
13
14
  import { mountSecurity } from "./modules/security.js";
@@ -122,6 +123,12 @@ export async function createUrbanApp(opts) {
122
123
  const w = await mountWorkers(ctx, api);
123
124
  mounted.push(w);
124
125
  describe.workers = w.describe?.();
126
+ // Connector-pack workers share the `workers` flag: same lifecycle, same
127
+ // engine, distinguished only by a `connector` field on the declaration.
128
+ const c = await mountConnectors(ctx, api);
129
+ mounted.push(c);
130
+ if (c.jobTypes.length > 0)
131
+ describe.connectors = c.describe?.();
125
132
  }
126
133
  const routes = [];
127
134
  if (flags.surfaces) {
@@ -56,15 +56,41 @@ export function collectManifestIssues(m) {
56
56
  if (!workerJobType(w)) {
57
57
  issues.push({ path: `workers[${i}]`, message: "missing taskType" });
58
58
  }
59
- // The schema allows a worker backed by either a `handler` file or an `llm`
60
- // binding (oneOf). Require exactly one — don't force `handler`.
61
- const hasHandler = typeof w?.handler === "string" && w.handler.length > 0;
62
- const hasLlm = typeof w?.llm === "string" && w.llm.length > 0;
63
- if (!hasHandler && !hasLlm) {
64
- issues.push({ path: `workers[${i}]`, message: "worker requires a `handler` or `llm`" });
59
+ // A worker is backed by exactly one of: a `handler` file, an `llm` binding,
60
+ // or an installed `connector` pack (ADR 0050). The schema models these as a
61
+ // oneOf; enforce the "exactly one" here without forcing `handler`. Whether a
62
+ // `connector` actually resolves to an installed pack is a runtime seam check
63
+ // (mountConnectors), not something this static manifest check can see.
64
+ const backings = [];
65
+ if (typeof w?.handler === "string" && w.handler.length > 0)
66
+ backings.push("handler");
67
+ if (typeof w?.llm === "string" && w.llm.length > 0)
68
+ backings.push("llm");
69
+ if (typeof w?.connector === "string" && w.connector.length > 0)
70
+ backings.push("connector");
71
+ if (backings.length === 0) {
72
+ issues.push({
73
+ path: `workers[${i}]`,
74
+ message: "worker requires a `handler`, `llm`, or `connector`",
75
+ });
65
76
  }
66
- else if (hasHandler && hasLlm) {
67
- issues.push({ path: `workers[${i}]`, message: "worker declares both `handler` and `llm` (mutually exclusive)" });
77
+ else if (backings.length > 1) {
78
+ issues.push({
79
+ path: `workers[${i}]`,
80
+ message: `worker declares ${backings.join(" + ")} (mutually exclusive)`,
81
+ });
82
+ }
83
+ // A `connection` must reference a declared top-level `connections` entry.
84
+ if (typeof w?.connection === "string" && w.connection.length > 0) {
85
+ const conns = man.connections && typeof man.connections === "object" && !Array.isArray(man.connections)
86
+ ? man.connections
87
+ : undefined;
88
+ if (!conns || !(w.connection in conns)) {
89
+ issues.push({
90
+ path: `workers[${i}].connection`,
91
+ message: `no such connection "${w.connection}" (add it to connections)`,
92
+ });
93
+ }
68
94
  }
69
95
  });
70
96
  }
@@ -16,6 +16,13 @@ export interface NanoSdkActivatedJob {
16
16
  errorMessage: string;
17
17
  retries?: number;
18
18
  }): Promise<unknown>;
19
+ /** Raise a BPMN error (Zeebe `ThrowError`) routed to an error boundary/event.
20
+ * Present on the nano-sdk enriched job at runtime; typed here so the adapter
21
+ * can honour a handler's {@link BpmnError} (ADR 0050). */
22
+ error?(e: {
23
+ errorCode: string;
24
+ errorMessage?: string;
25
+ }): Promise<unknown>;
19
26
  }
20
27
  /** Config for a nano-sdk job worker (the subset this adapter sets). */
21
28
  export interface NanoSdkJobWorkerConfig {
@@ -18,6 +18,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
18
18
  }
19
19
  return path;
20
20
  };
21
+ import { isBpmnError } from "../core/host.js";
21
22
  /** Coerce an engine response's process-instance key to a non-empty string, or
22
23
  * throw — a missing key means a malformed/partial response, not a real instance. */
23
24
  export function requireProcessInstanceKey(key) {
@@ -121,6 +122,32 @@ export class SdkEngineClient {
121
122
  return await job.complete(out ?? {});
122
123
  }
123
124
  catch (err) {
125
+ // A handler-raised BPMN error is a modelled, non-retryable outcome:
126
+ // report it as a BPMN error (routed to an error boundary/event) rather
127
+ // than a plain failure. If the transport lacks `error` (older SDK) — or
128
+ // the `error` call itself fails — fall through to the `fail` path below
129
+ // so the job is still acknowledged rather than left to lock-timeout.
130
+ if (isBpmnError(err) && typeof job.error === "function") {
131
+ // `message` is typed loosely (the guard survives module duplication),
132
+ // so coerce to a string before slicing to avoid a secondary crash.
133
+ const raw = err.message;
134
+ const message = typeof raw === "string" && raw.length > 0 ? raw : err.errorCode;
135
+ this.log("info", `handler ${jobType} raised BPMN error`, {
136
+ errorCode: err.errorCode,
137
+ });
138
+ try {
139
+ return await job.error({
140
+ errorCode: err.errorCode,
141
+ errorMessage: message.slice(0, 500),
142
+ });
143
+ }
144
+ catch (errReportErr) {
145
+ this.log("warn", `handler ${jobType}: BPMN error report failed, failing the job`, {
146
+ err: errReportErr instanceof Error ? errReportErr.message : String(errReportErr),
147
+ });
148
+ // fall through to the fail path
149
+ }
150
+ }
124
151
  const message = err instanceof Error ? err.message : String(err);
125
152
  this.log("error", `handler ${jobType} threw`, { err: message });
126
153
  try {
@@ -6,6 +6,9 @@ export { collectManifestIssues, ManifestValidationError, validateManifest, } fro
6
6
  export type { ValidationIssue } from "./core/validate.ts";
7
7
  export type { EngineClient, EngineJob, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, SqliteDb, WatchHandle, WorkerSubscription, } from "./core/host.ts";
8
8
  export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
9
+ export { BpmnError, isBpmnError } from "./core/host.ts";
10
+ export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } from "./core/modules/connectors.ts";
11
+ export type { ConnectorsHandle } from "./core/modules/connectors.ts";
9
12
  export { DataLayer, TypeRepo } from "./core/modules/datasource.ts";
10
13
  export type { ProvisionedSource } from "./core/modules/datasource.ts";
11
14
  export { makeGateway, Table } from "./core/modules/gateway.ts";
@@ -5,6 +5,11 @@ export { createUrbanApp } from "./core/runtime.js";
5
5
  export { expandEnv, expandEnvString, loadManifest, parseManifest, workerJobType, } from "./core/manifest.js";
6
6
  // Validation
7
7
  export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.js";
8
+ // BPMN error contract: a handler throws `BpmnError(code)` to raise a modelled
9
+ // error routed to an error boundary (ADR 0050), instead of a retryable failure.
10
+ export { BpmnError, isBpmnError } from "./core/host.js";
11
+ // Connector packs (ADR 0050, in-process port)
12
+ export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } from "./core/modules/connectors.js";
8
13
  // Data layer
9
14
  export { DataLayer, TypeRepo } from "./core/modules/datasource.js";
10
15
  export { makeGateway, Table } from "./core/modules/gateway.js";
@@ -0,0 +1,29 @@
1
+ import type { GenIO } from "./gen.ts";
2
+ export interface AddConnectorOptions {
3
+ /** App root directory. */
4
+ root: string;
5
+ /** The installed npm package name (already present under node_modules). */
6
+ pkg: string;
7
+ /** Manifest filename under root. Default "nano.app.json". */
8
+ manifestFile?: string;
9
+ io: GenIO;
10
+ }
11
+ export interface AddConnectorResult {
12
+ /** The pack id (`nano-ide.ext.json` `id`) written to each worker's `connector`. */
13
+ packId: string;
14
+ /** Workers considered, with whether one was already wired (idempotent). */
15
+ wired: {
16
+ taskType: string;
17
+ alreadyPresent: boolean;
18
+ }[];
19
+ /** The named connection created/reused, if the pack needs credentials. */
20
+ connection?: string;
21
+ /** Env vars the operator must set for the enabled workers to run. */
22
+ requiredEnv: string[];
23
+ }
24
+ /**
25
+ * Read the installed pack manifest, then wire its workers into the app manifest.
26
+ * Idempotent: re-running never duplicates a `workers[]`/`connections[]` entry.
27
+ * The app manifest object is mutated in place and written back.
28
+ */
29
+ export declare function addConnector(opts: AddConnectorOptions): Promise<AddConnectorResult>;
@@ -0,0 +1,170 @@
1
+ // addConnector — enable an installed connector pack into an app's nano.app.json.
2
+ //
3
+ // A connector pack ships a `nano-ide.ext.json` manifest declaring one or more
4
+ // workers (a `taskType`, a worker `entry`, and its config fields). "Enabling" it
5
+ // means the pure, idempotent manifest edit the `urban add` CLI performs after
6
+ // `npm install <pkg>` (ADR 0050 §5): for every worker the pack declares, append a
7
+ // pack-backed `workers[]` entry (`{ taskType, connector: <pack-id>, connection }`)
8
+ // and, when the pack needs credentials, a named `connections[]` entry whose values
9
+ // are env-pointer templates (never inline secrets, ADR 0025 §1 / 0027 §5).
10
+ //
11
+ // This module only reads/writes files through the GenIO port — installing the
12
+ // package (a subprocess) is the CLI's job, so this stays runtime-agnostic and
13
+ // unit-testable.
14
+ const MANIFEST_FILE = "nano-ide.ext.json";
15
+ function joinPath(a, b) {
16
+ // Trim both slash styles (as toolkit/gen.ts joinPath does) so a Windows-style
17
+ // root does not produce an invalid path.
18
+ return `${a.replace(/[/\\]+$/, "")}/${b.replace(/^[/\\]+/, "")}`;
19
+ }
20
+ /** A stable, slug-ish connection name for a pack id (kebab, no leading scope). */
21
+ function connectionNameFor(packId) {
22
+ const base = packId.includes("/") ? packId.slice(packId.lastIndexOf("/") + 1) : packId;
23
+ return base.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || packId;
24
+ }
25
+ /**
26
+ * Read the installed pack manifest, then wire its workers into the app manifest.
27
+ * Idempotent: re-running never duplicates a `workers[]`/`connections[]` entry.
28
+ * The app manifest object is mutated in place and written back.
29
+ */
30
+ export async function addConnector(opts) {
31
+ const { root, pkg, io } = opts;
32
+ const manifestFile = opts.manifestFile ?? "nano.app.json";
33
+ const packManifestPath = joinPath(joinPath(joinPath(root, "node_modules"), pkg), MANIFEST_FILE);
34
+ if (!(await io.exists(packManifestPath))) {
35
+ throw new Error(`"${pkg}" is not an Urban connector: no ${MANIFEST_FILE} found at ${packManifestPath}. ` +
36
+ `Install the package first (\`npm install ${pkg}\`), and check it is a connector pack.`);
37
+ }
38
+ let pack;
39
+ try {
40
+ pack = JSON.parse(await io.readText(packManifestPath));
41
+ }
42
+ catch (err) {
43
+ throw new Error(`failed to parse ${packManifestPath}: ${err.message}`);
44
+ }
45
+ if (typeof pack.id !== "string" || !pack.id) {
46
+ throw new Error(`${packManifestPath} has no string "id" (a pack must declare its id).`);
47
+ }
48
+ const specs = Array.isArray(pack.workers) ? pack.workers : [];
49
+ if (specs.length === 0) {
50
+ throw new Error(`connector pack "${pack.id}" declares no workers[] — nothing to enable.`);
51
+ }
52
+ // `type` comes from JSON; a malformed pack must fail loudly rather than wire an
53
+ // invalid worker entry (or manufacture a confusing conflict).
54
+ for (const spec of specs) {
55
+ if (typeof spec.type !== "string" || spec.type.length === 0) {
56
+ throw new Error(`connector pack "${pack.id}" has a worker with a missing/non-string "type" in ${packManifestPath}.`);
57
+ }
58
+ }
59
+ const appManifestPath = joinPath(root, manifestFile);
60
+ if (!(await io.exists(appManifestPath))) {
61
+ throw new Error(`no ${manifestFile} at ${appManifestPath} — run this inside an Urban app.`);
62
+ }
63
+ let app;
64
+ try {
65
+ app = JSON.parse(await io.readText(appManifestPath));
66
+ }
67
+ catch (err) {
68
+ throw new Error(`failed to parse ${appManifestPath}: ${err.message}`);
69
+ }
70
+ if (app === null || typeof app !== "object" || Array.isArray(app)) {
71
+ throw new Error(`${appManifestPath} is not a JSON object.`);
72
+ }
73
+ if (app.workers !== undefined && !Array.isArray(app.workers)) {
74
+ throw new Error(`${appManifestPath} "workers" must be an array.`);
75
+ }
76
+ if (app.connections !== undefined &&
77
+ (app.connections === null || typeof app.connections !== "object" || Array.isArray(app.connections))) {
78
+ throw new Error(`${appManifestPath} "connections" must be an object.`);
79
+ }
80
+ const workers = (app.workers ??= []);
81
+ // Required env across the pack's workers (env-pointer fields without a default).
82
+ const envFields = [];
83
+ const requiredEnvSet = new Set();
84
+ for (const spec of specs) {
85
+ for (const f of spec.configFields ?? []) {
86
+ if (f.env) {
87
+ envFields.push(f);
88
+ if (f.default === undefined || f.default === "")
89
+ requiredEnvSet.add(f.env);
90
+ }
91
+ }
92
+ }
93
+ // Create/reuse a named connection when the pack carries credential config, so the
94
+ // required env pointers are documented in the manifest (secrets stay templates).
95
+ let connection;
96
+ if (envFields.length > 0) {
97
+ connection = connectionNameFor(pack.id);
98
+ const connections = (app.connections ??= {});
99
+ const existing = connections[connection];
100
+ if (existing === undefined) {
101
+ const conn = { type: pack.id };
102
+ for (const f of envFields) {
103
+ if (!(f.key in conn) && f.env)
104
+ conn[f.key] = `\${${f.env}}`;
105
+ }
106
+ connections[connection] = conn;
107
+ }
108
+ else {
109
+ // A pre-existing connection of this name must be a compatible object of the
110
+ // same pack `type`; otherwise wiring workers to it would produce an invalid
111
+ // or misleading manifest. Fail loudly instead of silently reusing it.
112
+ if (existing === null || typeof existing !== "object" || Array.isArray(existing)) {
113
+ throw new Error(`connection "${connection}" already exists and is not an object.`);
114
+ }
115
+ const conn = existing;
116
+ if (conn.type !== undefined && conn.type !== pack.id) {
117
+ throw new Error(`connection "${connection}" already exists with type "${String(conn.type)}", ` +
118
+ `not "${pack.id}".`);
119
+ }
120
+ // Backfill the pack's `type` and any missing env-pointer templates.
121
+ if (conn.type === undefined)
122
+ conn.type = pack.id;
123
+ for (const f of envFields) {
124
+ if (!(f.key in conn) && f.env)
125
+ conn[f.key] = `\${${f.env}}`;
126
+ }
127
+ }
128
+ }
129
+ const wired = [];
130
+ for (const spec of specs) {
131
+ const existing = workers.find((w) => w.taskType === spec.type);
132
+ if (existing) {
133
+ // A same-taskType worker backed by a handler/llm or a different connector is
134
+ // a real conflict — enabling this pack would not take effect. Fail loudly
135
+ // instead of reporting a false success.
136
+ if (existing.connector !== pack.id) {
137
+ const backing = existing.handler
138
+ ? `handler "${existing.handler}"`
139
+ : existing.llm
140
+ ? `llm "${existing.llm}"`
141
+ : existing.connector
142
+ ? `connector "${existing.connector}"`
143
+ : "another backing";
144
+ throw new Error(`worker "${spec.type}" is already declared with ${backing}; ` +
145
+ `remove it before enabling connector "${pack.id}" for that taskType.`);
146
+ }
147
+ wired.push({ taskType: spec.type, alreadyPresent: true });
148
+ // Heal an entry that references the pack but predates the connection.
149
+ if (connection && !existing.connection) {
150
+ existing.connection = connection;
151
+ }
152
+ continue;
153
+ }
154
+ const entry = {
155
+ taskType: spec.type,
156
+ connector: pack.id,
157
+ };
158
+ if (connection)
159
+ entry.connection = connection;
160
+ workers.push(entry);
161
+ wired.push({ taskType: spec.type, alreadyPresent: false });
162
+ }
163
+ await io.writeText(appManifestPath, JSON.stringify(app, null, 2) + "\n");
164
+ return {
165
+ packId: pack.id,
166
+ wired,
167
+ connection,
168
+ requiredEnv: [...requiredEnvSet],
169
+ };
170
+ }
@@ -1,7 +1,16 @@
1
1
  // Node/Deno filesystem implementation of the GenIO port. Uses node:fs/promises, which both
2
2
  // Node and Deno provide, so a single implementation serves both runtimes.
3
- import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
4
- import { dirname } from "node:path";
3
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
4
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
5
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
6
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
7
+ });
8
+ }
9
+ return path;
10
+ };
11
+ import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
12
+ import { dirname, resolve } from "node:path";
13
+ import { pathToFileURL } from "node:url";
5
14
  export function createNodeGenIO() {
6
15
  return {
7
16
  async readText(path) {
@@ -31,5 +40,25 @@ export function createNodeGenIO() {
31
40
  return false;
32
41
  }
33
42
  },
43
+ async remove(path) {
44
+ await rm(path, { force: true });
45
+ },
46
+ // Import an app module for code-first model derivation. `import()` of a file URL loads TS on
47
+ // Deno natively and on Node with type-stripping (≥22.18 default, or `--experimental-strip-types`).
48
+ // The cache key is the file's mtime, not a monotonic clock: an unchanged workflow reuses the
49
+ // cached module instance (bounding the ESM module map in long-lived processes like `urban dev`)
50
+ // while an edited one still reloads.
51
+ async importModule(path) {
52
+ const abs = resolve(path);
53
+ let version = "";
54
+ try {
55
+ version = `?v=${(await stat(abs)).mtimeMs}`;
56
+ }
57
+ catch {
58
+ // stat failed (e.g. a virtual path); fall back to no cache key and let import() surface it.
59
+ }
60
+ const href = `${pathToFileURL(abs).href}${version}`;
61
+ return import(__rewriteRelativeImportExtension(href));
62
+ },
34
63
  };
35
64
  }