@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
@@ -0,0 +1,161 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { GenIO } from "./gen.ts";
4
+ import { addConnector } from "./addConnector.ts";
5
+
6
+ function memIO(files: Record<string, string>): GenIO & { files: Record<string, string> } {
7
+ return {
8
+ files,
9
+ async readText(p) {
10
+ if (!(p in files)) throw new Error(`ENOENT ${p}`);
11
+ return files[p];
12
+ },
13
+ async writeText(p, c) {
14
+ files[p] = c;
15
+ },
16
+ async listDir() {
17
+ return [];
18
+ },
19
+ async exists(p) {
20
+ return p in files;
21
+ },
22
+ async remove(p) {
23
+ delete files[p];
24
+ },
25
+ };
26
+ }
27
+
28
+ const PKG = "@nanobpm/nano-ide-connector-slack";
29
+ const PACK_ID = "nano-ide-connector-slack";
30
+
31
+ function baseFiles(appManifest: Record<string, unknown> = { schemaVersion: 1, id: "t", name: "T" }) {
32
+ return {
33
+ "app/nano.app.json": JSON.stringify(appManifest),
34
+ [`app/node_modules/${PKG}/nano-ide.ext.json`]: JSON.stringify({
35
+ id: PACK_ID,
36
+ workers: [
37
+ {
38
+ type: "slack:send-message",
39
+ entry: "worker.ts",
40
+ configFields: [{ key: "botToken", env: "SLACK_BOT_TOKEN" }],
41
+ },
42
+ ],
43
+ }),
44
+ };
45
+ }
46
+
47
+ test("addConnector wires a pack-backed worker + a connection into nano.app.json", async () => {
48
+ const io = memIO(baseFiles());
49
+ const res = await addConnector({ root: "app", pkg: PKG, io });
50
+
51
+ assert.equal(res.packId, PACK_ID);
52
+ assert.deepEqual(res.wired, [{ taskType: "slack:send-message", alreadyPresent: false }]);
53
+ assert.deepEqual(res.requiredEnv, ["SLACK_BOT_TOKEN"]);
54
+ assert.equal(res.connection, "nano-ide-connector-slack");
55
+
56
+ const app = JSON.parse(io.files["app/nano.app.json"]);
57
+ assert.deepEqual(app.workers, [
58
+ {
59
+ taskType: "slack:send-message",
60
+ connector: PACK_ID,
61
+ connection: "nano-ide-connector-slack",
62
+ },
63
+ ]);
64
+ assert.deepEqual(app.connections["nano-ide-connector-slack"], {
65
+ type: PACK_ID,
66
+ botToken: "${SLACK_BOT_TOKEN}",
67
+ });
68
+ });
69
+
70
+ test("addConnector is idempotent — re-running does not duplicate entries", async () => {
71
+ const io = memIO(baseFiles());
72
+ await addConnector({ root: "app", pkg: PKG, io });
73
+ const res2 = await addConnector({ root: "app", pkg: PKG, io });
74
+ assert.deepEqual(res2.wired, [{ taskType: "slack:send-message", alreadyPresent: true }]);
75
+ const app = JSON.parse(io.files["app/nano.app.json"]);
76
+ assert.equal(app.workers.length, 1);
77
+ assert.equal(Object.keys(app.connections).length, 1);
78
+ });
79
+
80
+ test("addConnector throws on a taskType already backed by a handler (conflict)", async () => {
81
+ const io = memIO(
82
+ baseFiles({
83
+ schemaVersion: 1,
84
+ id: "t",
85
+ name: "T",
86
+ workers: [{ taskType: "slack:send-message", handler: "handlers/slack.ts" }],
87
+ }),
88
+ );
89
+ await assert.rejects(() => addConnector({ root: "app", pkg: PKG, io }), /already declared with handler/);
90
+ });
91
+
92
+ test("addConnector throws when the package is not an Urban connector", async () => {
93
+ const io = memIO({ "app/nano.app.json": JSON.stringify({ schemaVersion: 1, id: "t", name: "T" }) });
94
+ await assert.rejects(() => addConnector({ root: "app", pkg: PKG, io }), /not an Urban connector/);
95
+ });
96
+
97
+ test("addConnector reports which app manifest fails to parse", async () => {
98
+ const io = memIO({
99
+ "app/nano.app.json": "{ not json",
100
+ [`app/node_modules/${PKG}/nano-ide.ext.json`]: JSON.stringify({
101
+ id: PACK_ID,
102
+ workers: [{ type: "slack:send-message", entry: "worker.ts" }],
103
+ }),
104
+ });
105
+ await assert.rejects(() => addConnector({ root: "app", pkg: PKG, io }), /failed to parse .*nano\.app\.json/);
106
+ });
107
+
108
+ test("addConnector rejects a manifest whose connections is not an object", async () => {
109
+ const io = memIO(baseFiles({ schemaVersion: 1, id: "t", name: "T", connections: "nope" }));
110
+ await assert.rejects(() => addConnector({ root: "app", pkg: PKG, io }), /"connections" must be an object/);
111
+ });
112
+
113
+ test("addConnector rejects an existing connection of a different type", async () => {
114
+ const io = memIO(
115
+ baseFiles({
116
+ schemaVersion: 1,
117
+ id: "t",
118
+ name: "T",
119
+ connections: { [PACK_ID]: { type: "other" } },
120
+ }),
121
+ );
122
+ await assert.rejects(
123
+ () => addConnector({ root: "app", pkg: PKG, io }),
124
+ /already exists with type "other"/,
125
+ );
126
+ });
127
+
128
+ test("addConnector rejects an existing connection that is not an object", async () => {
129
+ const io = memIO(
130
+ baseFiles({ schemaVersion: 1, id: "t", name: "T", connections: { [PACK_ID]: "nope" } }),
131
+ );
132
+ await assert.rejects(
133
+ () => addConnector({ root: "app", pkg: PKG, io }),
134
+ /already exists and is not an object/,
135
+ );
136
+ });
137
+
138
+ test("addConnector backfills missing env pointers on a matching existing connection", async () => {
139
+ const io = memIO(
140
+ baseFiles({ schemaVersion: 1, id: "t", name: "T", connections: { [PACK_ID]: { type: PACK_ID } } }),
141
+ );
142
+ await addConnector({ root: "app", pkg: PKG, io });
143
+ const app = JSON.parse(io.files["app/nano.app.json"]);
144
+ assert.deepEqual(app.connections[PACK_ID], { type: PACK_ID, botToken: "${SLACK_BOT_TOKEN}" });
145
+ });
146
+
147
+ test("addConnector omits a connection when the pack needs no env credentials", async () => {
148
+ const io = memIO({
149
+ "app/nano.app.json": JSON.stringify({ schemaVersion: 1, id: "t", name: "T" }),
150
+ [`app/node_modules/${PKG}/nano-ide.ext.json`]: JSON.stringify({
151
+ id: PACK_ID,
152
+ workers: [{ type: "noop:run", entry: "worker.ts" }],
153
+ }),
154
+ });
155
+ const res = await addConnector({ root: "app", pkg: PKG, io });
156
+ assert.equal(res.connection, undefined);
157
+ assert.deepEqual(res.requiredEnv, []);
158
+ const app = JSON.parse(io.files["app/nano.app.json"]);
159
+ assert.deepEqual(app.workers, [{ taskType: "noop:run", connector: PACK_ID }]);
160
+ assert.equal(app.connections, undefined);
161
+ });
@@ -0,0 +1,233 @@
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
+
15
+ import type { GenIO } from "./gen.ts";
16
+
17
+ /** A config field a pack declares (subset of ext-types `ConfigField`). */
18
+ interface PackConfigField {
19
+ key: string;
20
+ label?: string;
21
+ env?: string;
22
+ default?: string;
23
+ }
24
+
25
+ /** A worker a pack declares (subset of ext-types `WorkerSpec`). */
26
+ interface PackWorkerSpec {
27
+ type: string;
28
+ entry: string;
29
+ displayName?: string;
30
+ maxParallelJobs?: number;
31
+ configFields?: PackConfigField[];
32
+ }
33
+
34
+ /** The pack manifest (`nano-ide.ext.json`) fields `addConnector` reads. */
35
+ interface PackManifest {
36
+ id: string;
37
+ workers?: PackWorkerSpec[];
38
+ }
39
+
40
+ export interface AddConnectorOptions {
41
+ /** App root directory. */
42
+ root: string;
43
+ /** The installed npm package name (already present under node_modules). */
44
+ pkg: string;
45
+ /** Manifest filename under root. Default "nano.app.json". */
46
+ manifestFile?: string;
47
+ io: GenIO;
48
+ }
49
+
50
+ export interface AddConnectorResult {
51
+ /** The pack id (`nano-ide.ext.json` `id`) written to each worker's `connector`. */
52
+ packId: string;
53
+ /** Workers considered, with whether one was already wired (idempotent). */
54
+ wired: { taskType: string; alreadyPresent: boolean }[];
55
+ /** The named connection created/reused, if the pack needs credentials. */
56
+ connection?: string;
57
+ /** Env vars the operator must set for the enabled workers to run. */
58
+ requiredEnv: string[];
59
+ }
60
+
61
+ const MANIFEST_FILE = "nano-ide.ext.json";
62
+
63
+ function joinPath(a: string, b: string): string {
64
+ // Trim both slash styles (as toolkit/gen.ts joinPath does) so a Windows-style
65
+ // root does not produce an invalid path.
66
+ return `${a.replace(/[/\\]+$/, "")}/${b.replace(/^[/\\]+/, "")}`;
67
+ }
68
+
69
+ /** A stable, slug-ish connection name for a pack id (kebab, no leading scope). */
70
+ function connectionNameFor(packId: string): string {
71
+ const base = packId.includes("/") ? packId.slice(packId.lastIndexOf("/") + 1) : packId;
72
+ return base.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || packId;
73
+ }
74
+
75
+ /**
76
+ * Read the installed pack manifest, then wire its workers into the app manifest.
77
+ * Idempotent: re-running never duplicates a `workers[]`/`connections[]` entry.
78
+ * The app manifest object is mutated in place and written back.
79
+ */
80
+ export async function addConnector(opts: AddConnectorOptions): Promise<AddConnectorResult> {
81
+ const { root, pkg, io } = opts;
82
+ const manifestFile = opts.manifestFile ?? "nano.app.json";
83
+
84
+ const packManifestPath = joinPath(joinPath(joinPath(root, "node_modules"), pkg), MANIFEST_FILE);
85
+ if (!(await io.exists(packManifestPath))) {
86
+ throw new Error(
87
+ `"${pkg}" is not an Urban connector: no ${MANIFEST_FILE} found at ${packManifestPath}. ` +
88
+ `Install the package first (\`npm install ${pkg}\`), and check it is a connector pack.`,
89
+ );
90
+ }
91
+ let pack: PackManifest;
92
+ try {
93
+ pack = JSON.parse(await io.readText(packManifestPath)) as PackManifest;
94
+ } catch (err) {
95
+ throw new Error(`failed to parse ${packManifestPath}: ${(err as Error).message}`);
96
+ }
97
+ if (typeof pack.id !== "string" || !pack.id) {
98
+ throw new Error(`${packManifestPath} has no string "id" (a pack must declare its id).`);
99
+ }
100
+ const specs = Array.isArray(pack.workers) ? pack.workers : [];
101
+ if (specs.length === 0) {
102
+ throw new Error(`connector pack "${pack.id}" declares no workers[] — nothing to enable.`);
103
+ }
104
+ // `type` comes from JSON; a malformed pack must fail loudly rather than wire an
105
+ // invalid worker entry (or manufacture a confusing conflict).
106
+ for (const spec of specs) {
107
+ if (typeof spec.type !== "string" || spec.type.length === 0) {
108
+ throw new Error(
109
+ `connector pack "${pack.id}" has a worker with a missing/non-string "type" in ${packManifestPath}.`,
110
+ );
111
+ }
112
+ }
113
+
114
+ const appManifestPath = joinPath(root, manifestFile);
115
+ if (!(await io.exists(appManifestPath))) {
116
+ throw new Error(`no ${manifestFile} at ${appManifestPath} — run this inside an Urban app.`);
117
+ }
118
+ let app: {
119
+ workers?: { taskType?: string; connector?: string; connection?: string; handler?: string; llm?: string }[];
120
+ connections?: Record<string, Record<string, unknown>>;
121
+ [k: string]: unknown;
122
+ };
123
+ try {
124
+ app = JSON.parse(await io.readText(appManifestPath));
125
+ } catch (err) {
126
+ throw new Error(`failed to parse ${appManifestPath}: ${(err as Error).message}`);
127
+ }
128
+ if (app === null || typeof app !== "object" || Array.isArray(app)) {
129
+ throw new Error(`${appManifestPath} is not a JSON object.`);
130
+ }
131
+ if (app.workers !== undefined && !Array.isArray(app.workers)) {
132
+ throw new Error(`${appManifestPath} "workers" must be an array.`);
133
+ }
134
+ if (
135
+ app.connections !== undefined &&
136
+ (app.connections === null || typeof app.connections !== "object" || Array.isArray(app.connections))
137
+ ) {
138
+ throw new Error(`${appManifestPath} "connections" must be an object.`);
139
+ }
140
+ const workers = (app.workers ??= []);
141
+
142
+ // Required env across the pack's workers (env-pointer fields without a default).
143
+ const envFields: PackConfigField[] = [];
144
+ const requiredEnvSet = new Set<string>();
145
+ for (const spec of specs) {
146
+ for (const f of spec.configFields ?? []) {
147
+ if (f.env) {
148
+ envFields.push(f);
149
+ if (f.default === undefined || f.default === "") requiredEnvSet.add(f.env);
150
+ }
151
+ }
152
+ }
153
+
154
+ // Create/reuse a named connection when the pack carries credential config, so the
155
+ // required env pointers are documented in the manifest (secrets stay templates).
156
+ let connection: string | undefined;
157
+ if (envFields.length > 0) {
158
+ connection = connectionNameFor(pack.id);
159
+ const connections = (app.connections ??= {});
160
+ const existing = connections[connection];
161
+ if (existing === undefined) {
162
+ const conn: Record<string, unknown> = { type: pack.id };
163
+ for (const f of envFields) {
164
+ if (!(f.key in conn) && f.env) conn[f.key] = `\${${f.env}}`;
165
+ }
166
+ connections[connection] = conn;
167
+ } else {
168
+ // A pre-existing connection of this name must be a compatible object of the
169
+ // same pack `type`; otherwise wiring workers to it would produce an invalid
170
+ // or misleading manifest. Fail loudly instead of silently reusing it.
171
+ if (existing === null || typeof existing !== "object" || Array.isArray(existing)) {
172
+ throw new Error(`connection "${connection}" already exists and is not an object.`);
173
+ }
174
+ const conn = existing as Record<string, unknown>;
175
+ if (conn.type !== undefined && conn.type !== pack.id) {
176
+ throw new Error(
177
+ `connection "${connection}" already exists with type "${String(conn.type)}", ` +
178
+ `not "${pack.id}".`,
179
+ );
180
+ }
181
+ // Backfill the pack's `type` and any missing env-pointer templates.
182
+ if (conn.type === undefined) conn.type = pack.id;
183
+ for (const f of envFields) {
184
+ if (!(f.key in conn) && f.env) conn[f.key] = `\${${f.env}}`;
185
+ }
186
+ }
187
+ }
188
+
189
+ const wired: { taskType: string; alreadyPresent: boolean }[] = [];
190
+ for (const spec of specs) {
191
+ const existing = workers.find((w) => w.taskType === spec.type);
192
+ if (existing) {
193
+ // A same-taskType worker backed by a handler/llm or a different connector is
194
+ // a real conflict — enabling this pack would not take effect. Fail loudly
195
+ // instead of reporting a false success.
196
+ if (existing.connector !== pack.id) {
197
+ const backing = existing.handler
198
+ ? `handler "${existing.handler}"`
199
+ : existing.llm
200
+ ? `llm "${existing.llm}"`
201
+ : existing.connector
202
+ ? `connector "${existing.connector}"`
203
+ : "another backing";
204
+ throw new Error(
205
+ `worker "${spec.type}" is already declared with ${backing}; ` +
206
+ `remove it before enabling connector "${pack.id}" for that taskType.`,
207
+ );
208
+ }
209
+ wired.push({ taskType: spec.type, alreadyPresent: true });
210
+ // Heal an entry that references the pack but predates the connection.
211
+ if (connection && !existing.connection) {
212
+ existing.connection = connection;
213
+ }
214
+ continue;
215
+ }
216
+ const entry: { taskType: string; connector: string; connection?: string } = {
217
+ taskType: spec.type,
218
+ connector: pack.id,
219
+ };
220
+ if (connection) entry.connection = connection;
221
+ workers.push(entry);
222
+ wired.push({ taskType: spec.type, alreadyPresent: false });
223
+ }
224
+
225
+ await io.writeText(appManifestPath, JSON.stringify(app, null, 2) + "\n");
226
+
227
+ return {
228
+ packId: pack.id,
229
+ wired,
230
+ connection,
231
+ requiredEnv: [...requiredEnvSet],
232
+ };
233
+ }
@@ -115,3 +115,6 @@ export type {
115
115
  } from "./scaffold/workers.ts";
116
116
  export { scaffoldWorkers } from "./scaffold.ts";
117
117
  export type { ScaffoldOptions, ScaffoldRun, StubOutcome, StubStatus } from "./scaffold.ts";
118
+
119
+ export { addConnector } from "./addConnector.ts";
120
+ export type { AddConnectorOptions, AddConnectorResult } from "./addConnector.ts";