@nanobpm/urban 0.18.0 → 0.20.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 (44) hide show
  1. package/dist/cli.js +64 -2
  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/pages.js +23 -2
  11. package/dist/runtime/core/modules/workers.js +5 -0
  12. package/dist/runtime/core/runtime.js +7 -0
  13. package/dist/runtime/core/validate.js +34 -8
  14. package/dist/runtime/engine/nanosdk.d.ts +7 -0
  15. package/dist/runtime/engine/nanosdk.js +27 -0
  16. package/dist/runtime/index.d.ts +3 -0
  17. package/dist/runtime/index.js +5 -0
  18. package/dist/toolkit/addConnector.d.ts +29 -0
  19. package/dist/toolkit/addConnector.js +170 -0
  20. package/dist/toolkit/index.d.ts +2 -0
  21. package/dist/toolkit/index.js +1 -0
  22. package/package.json +2 -2
  23. package/src/cli.ts +60 -2
  24. package/src/runtime/adapters/node.ts +64 -1
  25. package/src/runtime/connector-worker-sdk.test.ts +56 -0
  26. package/src/runtime/connector-worker-sdk.ts +78 -0
  27. package/src/runtime/core/host.ts +40 -0
  28. package/src/runtime/core/modules/connectors.test.ts +206 -0
  29. package/src/runtime/core/modules/connectors.ts +269 -0
  30. package/src/runtime/core/modules/datasource.migrations.test.ts +114 -0
  31. package/src/runtime/core/modules/datasource.ts +24 -5
  32. package/src/runtime/core/modules/pages.test.ts +13 -0
  33. package/src/runtime/core/modules/pages.ts +23 -2
  34. package/src/runtime/core/modules/workers.test.ts +13 -0
  35. package/src/runtime/core/modules/workers.ts +4 -0
  36. package/src/runtime/core/runtime.ts +6 -0
  37. package/src/runtime/core/validate.test.ts +24 -0
  38. package/src/runtime/core/validate.ts +32 -8
  39. package/src/runtime/engine/nanosdk.test.ts +76 -0
  40. package/src/runtime/engine/nanosdk.ts +30 -0
  41. package/src/runtime/index.ts +12 -0
  42. package/src/toolkit/addConnector.test.ts +161 -0
  43. package/src/toolkit/addConnector.ts +233 -0
  44. package/src/toolkit/index.ts +3 -0
@@ -392,6 +392,27 @@ function renderText(node) {
392
392
  return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
393
393
  }
394
394
 
395
+ // A grid td cell. When the column declares linkField, the cell text (the
396
+ // column's own value) becomes a link to the URL held in that other field,
397
+ // opened in a new tab. Only http(s) hrefs are linked — anything else (e.g. a
398
+ // javascript: URL smuggled through row data) falls back to plain text — and
399
+ // external links get rel=noopener noreferrer so the opened page can't reach
400
+ // window.opener. Shared by the top-level grid and child grids.
401
+ function gridCell(col, row) {
402
+ const text = row[col.field] == null ? "" : String(row[col.field]);
403
+ if (col.linkField) {
404
+ const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
405
+ if (text !== "" && /^https?:\/\//i.test(href)) {
406
+ return el(
407
+ "td",
408
+ {},
409
+ el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text),
410
+ );
411
+ }
412
+ }
413
+ return el("td", {}, text);
414
+ }
415
+
395
416
  function renderActionForm(node) {
396
417
  const p = node.props;
397
418
  const card = el("section", { class: "pc-card" });
@@ -552,7 +573,7 @@ function renderDataGrid(node) {
552
573
  cbody.append(el("tr", {}, el("td", { colspan: cspan }, "None")));
553
574
  }
554
575
  for (const cr of rows) {
555
- const cells = ccols.map((c) => el("td", {}, cr[c.field] == null ? "" : String(cr[c.field])));
576
+ const cells = ccols.map((c) => gridCell(c, cr));
556
577
  if (cg.lazyField) {
557
578
  const lf = cg.lazyField;
558
579
  const has = cr[lf.field] != null && String(cr[lf.field]).trim() !== "";
@@ -606,7 +627,7 @@ function renderDataGrid(node) {
606
627
  }
607
628
 
608
629
  function renderRow(row) {
609
- const cells = cols.map((c) => el("td", {}, row[c.field] == null ? "" : String(row[c.field])));
630
+ const cells = cols.map((c) => gridCell(c, row));
610
631
  let toggle = null;
611
632
  if (hasExtra) {
612
633
  const actionCell = el("td", { class: "pc-row-actions" });
@@ -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
+ }
@@ -29,3 +29,5 @@ export { planWorkerScaffold, renderWorkerStub, slugifyTaskType, } from "./scaffo
29
29
  export type { ScaffoldWorker, SkippedWorker, SkipReason, StubManifestEntry, WorkerScaffoldPlan, WorkerStubPlan, } from "./scaffold/workers.ts";
30
30
  export { scaffoldWorkers } from "./scaffold.ts";
31
31
  export type { ScaffoldOptions, ScaffoldRun, StubOutcome, StubStatus } from "./scaffold.ts";
32
+ export { addConnector } from "./addConnector.ts";
33
+ export type { AddConnectorOptions, AddConnectorResult } from "./addConnector.ts";
@@ -25,3 +25,4 @@ export { deriveModels, isWorkflow, bpmnFilename, processesOutDir, DEFAULT_WORKFL
25
25
  // Worker-stub scaffolder (ADR 0056): write-once handler stubs from the model.
26
26
  export { planWorkerScaffold, renderWorkerStub, slugifyTaskType, } from "./scaffold/workers.js";
27
27
  export { scaffoldWorkers } from "./scaffold.js";
28
+ export { addConnector } from "./addConnector.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -64,7 +64,7 @@
64
64
  "dependencies": {
65
65
  "@nanobpm/nano-app-schema": "^0.1.0",
66
66
  "@nanobpm/nano-sdk": "^1.2.3",
67
- "@nanobpm/workflow": "^0.7.0",
67
+ "@nanobpm/workflow": "^0.8.0",
68
68
  "bpmn-auto-layout": "^2.0.0-alpha.2",
69
69
  "create-urban-app": "^0.5.0"
70
70
  },
package/src/cli.ts CHANGED
@@ -20,9 +20,10 @@ import {
20
20
  selectHost,
21
21
  } from "./runtime/index.ts";
22
22
  import { scaffold, slugify } from "create-urban-app";
23
- import { createNodeGenIO, previewModels, runGen, scaffoldWorkers } from "./toolkit/index.ts";
23
+ import { addConnector, createNodeGenIO, previewModels, runGen, scaffoldWorkers } from "./toolkit/index.ts";
24
24
  import { pathToFileURL } from "node:url";
25
25
  import { readFileSync } from "node:fs";
26
+ import { spawnSync } from "node:child_process";
26
27
 
27
28
  // Read the package version from package.json (one hop up from both src/cli.ts and the
28
29
  // compiled dist/cli.js) so `urban --version` never drifts from the published version.
@@ -47,6 +48,7 @@ interface Flags {
47
48
  deno: boolean;
48
49
  models: boolean;
49
50
  stdout: boolean;
51
+ install: boolean;
50
52
  style?: "model" | "code";
51
53
  help: boolean;
52
54
  version: boolean;
@@ -54,7 +56,7 @@ interface Flags {
54
56
  }
55
57
 
56
58
  function parse(argv: string[]): Flags {
57
- const f: Flags = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, models: true, stdout: false, help: false, version: false, _: [] };
59
+ const f: Flags = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, models: true, stdout: false, install: true, help: false, version: false, _: [] };
58
60
  const need = (i: number, flag: string): string => {
59
61
  const v = argv[i];
60
62
  if (v === undefined || v.startsWith("-")) throw new Error(`flag ${flag} requires a value`);
@@ -70,6 +72,8 @@ function parse(argv: string[]): Flags {
70
72
  else if (a === "--no-models") f.models = false;
71
73
  else if (a === "--models") f.models = true;
72
74
  else if (a === "--stdout") f.stdout = true;
75
+ else if (a === "--no-install") f.install = false;
76
+ else if (a === "--install") f.install = true;
73
77
  else if (a === "--code-first") f.style = "code";
74
78
  else if (a === "--style") {
75
79
  const v = need(++i, a);
@@ -103,6 +107,7 @@ Usage:
103
107
  urban derive [--check|--stdout] derive code-first models only (workflows/*.ts → processes/*.bpmn)
104
108
  (--stdout: print {models,incomplete} JSON without writing)
105
109
  urban stubs [--write] scaffold write-once handler stubs from the model
110
+ urban add <pkg> [--no-install] install a connector pack + wire it into nano.app.json
106
111
  urban run materialize + serve the app
107
112
  urban dev run + watch sources, hot-reload on change
108
113
  urban deploy deploy models only, then exit
@@ -281,6 +286,57 @@ async function cmdNew(f: Flags): Promise<number> {
281
286
  return 0;
282
287
  }
283
288
 
289
+ async function cmdAdd(f: Flags): Promise<number> {
290
+ const pkg = f._[1];
291
+ if (!pkg) {
292
+ console.error("usage: urban add <pkg> [--no-install]");
293
+ return 1;
294
+ }
295
+ // Install the connector pack (a subprocess — the toolkit stays runtime-agnostic).
296
+ if (f.install) {
297
+ console.log(`Installing ${pkg} …`);
298
+ const res = spawnSync("npm", ["install", pkg], {
299
+ cwd: f.root,
300
+ stdio: "inherit",
301
+ });
302
+ if (res.error) {
303
+ console.error(`npm install failed: ${res.error.message}`);
304
+ return 1;
305
+ }
306
+ if (res.signal) {
307
+ console.error(`npm install was terminated by signal ${res.signal}`);
308
+ return 1;
309
+ }
310
+ if (res.status !== 0) {
311
+ console.error(`npm install exited with code ${res.status}`);
312
+ return res.status ?? 1;
313
+ }
314
+ }
315
+
316
+ try {
317
+ const result = await addConnector({
318
+ root: f.root,
319
+ pkg,
320
+ manifestFile: f.manifest,
321
+ io: createNodeGenIO(),
322
+ });
323
+ const added = result.wired.filter((w) => !w.alreadyPresent);
324
+ const kept = result.wired.filter((w) => w.alreadyPresent);
325
+ console.log(`✔ enabled connector "${result.packId}" from ${pkg}`);
326
+ for (const w of added) console.log(` + worker ${w.taskType}`);
327
+ for (const w of kept) console.log(` = worker ${w.taskType} (already wired)`);
328
+ if (result.connection) console.log(` connection: ${result.connection}`);
329
+ if (result.requiredEnv.length > 0) {
330
+ console.log(`\nSet these environment variables before \`urban run\`:`);
331
+ for (const name of result.requiredEnv) console.log(` ${name}`);
332
+ }
333
+ return 0;
334
+ } catch (err) {
335
+ console.error(`✖ ${(err as Error).message}`);
336
+ return 1;
337
+ }
338
+ }
339
+
284
340
  export async function main(argv: string[]): Promise<number> {
285
341
  let f: Flags;
286
342
  try {
@@ -310,6 +366,8 @@ export async function main(argv: string[]): Promise<number> {
310
366
  return cmdDerive(f);
311
367
  case "stubs":
312
368
  return cmdStubs(f);
369
+ case "add":
370
+ return cmdAdd(f);
313
371
  case "run":
314
372
  return cmdRun(f);
315
373
  case "dev":
@@ -2,10 +2,12 @@
2
2
  // (with deno.ts) allowed to touch a concrete runtime.
3
3
 
4
4
  import { readFile, readdir, stat } from "node:fs/promises";
5
+ import { existsSync } from "node:fs";
5
6
  import { watch as fsWatch, type FSWatcher } from "node:fs";
6
7
  import { createServer } from "node:http";
8
+ import { register } from "node:module";
7
9
  import { isAbsolute, resolve } from "node:path";
8
- import { pathToFileURL } from "node:url";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
11
  import { DatabaseSync } from "node:sqlite";
10
12
  import type {
11
13
  HostContext,
@@ -28,6 +30,57 @@ export interface NodeHostOptions {
28
30
  importNonce?: string;
29
31
  }
30
32
 
33
+ /** Resolve the in-process `@nanobpm/worker` shim URL relative to this adapter,
34
+ * tolerating both the compiled package (`.js`, the published/`urban run` path)
35
+ * and a from-source run (`.ts`). */
36
+ function connectorShimUrl(): string {
37
+ const js = new URL("../connector-worker-sdk.js", import.meta.url);
38
+ if (existsSync(fileURLToPath(js))) return js.href;
39
+ return new URL("../connector-worker-sdk.ts", import.meta.url).href;
40
+ }
41
+
42
+ let connectorHooksRegistered = false;
43
+
44
+ /**
45
+ * Register (once per process) the ESM customization hooks that let a connector
46
+ * pack's worker `entry` be imported in-process (ADR 0050, in-process port):
47
+ * - a **resolve** hook aliasing the bare `@nanobpm/worker` specifier the pack
48
+ * imports to the runtime's shim, so its `defineWorker(...)` registers into the
49
+ * registry the runtime drains;
50
+ * - a **load** hook that strips TypeScript from ANY `.ts` module resolved under
51
+ * `node_modules` (Node refuses to type-strip there by default). In practice
52
+ * only a connector pack's `.ts` entry and its `.ts` imports travel this path,
53
+ * but the hook is process-global once registered, so it applies to every
54
+ * subsequent `node_modules` `.ts` import — a safe superset (Node would
55
+ * otherwise throw on such a file). The module's real URL is preserved so its
56
+ * own bare imports (npm deps) still resolve.
57
+ * The hooks run on a separate thread, so the shim URL is baked into the hook
58
+ * source; the shim itself loads on the main thread (shared registry instance).
59
+ */
60
+ function ensureConnectorHooks(): void {
61
+ if (connectorHooksRegistered) return;
62
+ const shim = JSON.stringify(connectorShimUrl());
63
+ const src =
64
+ `import { readFileSync } from "node:fs";\n` +
65
+ `import { fileURLToPath } from "node:url";\n` +
66
+ `import { stripTypeScriptTypes } from "node:module";\n` +
67
+ `const SHIM = ${shim};\n` +
68
+ `export async function resolve(spec, ctx, next) {\n` +
69
+ ` if (spec === "@nanobpm/worker") return { url: SHIM, shortCircuit: true };\n` +
70
+ ` return next(spec, ctx);\n` +
71
+ `}\n` +
72
+ `export async function load(url, ctx, next) {\n` +
73
+ ` const path = url.split("?")[0];\n` +
74
+ ` if (path.endsWith(".ts") && path.includes("/node_modules/")) {\n` +
75
+ ` const source = stripTypeScriptTypes(readFileSync(fileURLToPath(path), "utf8"), { mode: "strip" });\n` +
76
+ ` return { format: "module", source, shortCircuit: true };\n` +
77
+ ` }\n` +
78
+ ` return next(url, ctx);\n` +
79
+ `}\n`;
80
+ register("data:text/javascript," + encodeURIComponent(src));
81
+ connectorHooksRegistered = true;
82
+ }
83
+
31
84
  export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
32
85
  const cwd = opts.cwd ?? process.cwd();
33
86
  const abs = (p: string) => (isAbsolute(p) ? p : resolve(cwd, p));
@@ -69,6 +122,16 @@ export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
69
122
  pathToFileURL(abs(p)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
70
123
  return import(href) as Promise<Record<string, unknown>>;
71
124
  },
125
+ async importConnectorModule(entry) {
126
+ ensureConnectorHooks();
127
+ // Import for side effects only: the pack's top-level `defineWorker(...)` runs
128
+ // and registers into the shim registry, which the caller drains. Apply the
129
+ // dev `importNonce` cache-buster (as importModule does) so a hot-reloaded
130
+ // pack entry is re-evaluated instead of served stale from the ESM cache.
131
+ const href =
132
+ pathToFileURL(abs(entry)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
133
+ await import(href);
134
+ },
72
135
  async serveHttp(port, handler) {
73
136
  return await startNodeServer(port, handler);
74
137
  },