@topogram/cli 0.3.57 → 0.3.59

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topogram/cli",
3
- "version": "0.3.57",
3
+ "version": "0.3.59",
4
4
  "description": "Topogram CLI for checking Topogram workspaces and generating app bundles.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -370,16 +370,26 @@ function componentScriptOptions() {
370
370
  };
371
371
  }
372
372
 
373
+ function runtimePortExpression(plan, runtimes, component, sharedEnvName) {
374
+ const runtimeEnvName = `${component.id.toUpperCase()}_PORT`;
375
+ const primaryRuntime = runtimes[0];
376
+ const fallback = primaryRuntime?.id === component.id
377
+ ? `\${${sharedEnvName}:-${component.port}}`
378
+ : `${component.port}`;
379
+ return `\${${runtimeEnvName}:-${fallback}}`;
380
+ }
381
+
373
382
  function renderEnvironmentServerDevScript(plan, component = plan.runtimes.apis[0], options = {}) {
374
383
  if (!component) {
375
384
  return renderEnvAwareShellScript(['echo "No API runtimes are configured."']);
376
385
  }
377
386
  const guardPortsScript = options.componentScript ? '"$ROOT_DIR/scripts/guard-ports.mjs"' : '"$SCRIPT_DIR/guard-ports.mjs"';
387
+ const serverPortExpression = runtimePortExpression(plan, plan.runtimes.apis, component, "SERVER_PORT");
378
388
  return renderEnvAwareShellScript([
379
389
  `node ${guardPortsScript} api`,
380
390
  "",
381
391
  ...apiDatabaseExportLines(component),
382
- `export PORT="\${${component.id.toUpperCase()}_PORT:-\${SERVER_PORT:-${component.port}}}"`,
392
+ `export PORT="${serverPortExpression}"`,
383
393
  `export TOPOGRAM_CORS_ORIGINS="\${TOPOGRAM_CORS_ORIGINS:-http://localhost:\${WEB_PORT:-${plan.ports.web}},http://127.0.0.1:\${WEB_PORT:-${plan.ports.web}}}"`,
384
394
  "",
385
395
  `cd "$ROOT_DIR/${component.dir}"`,
@@ -395,15 +405,16 @@ function renderEnvironmentWebDevScript(plan, component = plan.runtimes.webs[0],
395
405
  }
396
406
  const apiRuntime = plan.runtimes.apis.find((entry) => entry.id === component.uses_api) || plan.runtimes.apis[0];
397
407
  const guardPortsScript = options.componentScript ? '"$ROOT_DIR/scripts/guard-ports.mjs"' : '"$SCRIPT_DIR/guard-ports.mjs"';
408
+ const webPortExpression = runtimePortExpression(plan, plan.runtimes.webs, component, "WEB_PORT");
398
409
  return renderEnvAwareShellScript([
399
410
  `node ${guardPortsScript} web`,
400
411
  "",
401
412
  ...(apiRuntime ? [`export PUBLIC_TOPOGRAM_API_BASE_URL="\${PUBLIC_TOPOGRAM_API_BASE_URL:-http://localhost:\${${apiRuntime.id.toUpperCase()}_PORT:-\${SERVER_PORT:-${apiRuntime.port}}}}"`] : []),
402
- `export TOPOGRAM_CORS_ORIGINS="\${TOPOGRAM_CORS_ORIGINS:-http://localhost:\${${component.id.toUpperCase()}_PORT:-\${WEB_PORT:-${component.port}}},http://127.0.0.1:\${${component.id.toUpperCase()}_PORT:-\${WEB_PORT:-${component.port}}}}"`,
413
+ `export TOPOGRAM_CORS_ORIGINS="\${TOPOGRAM_CORS_ORIGINS:-http://localhost:${webPortExpression},http://127.0.0.1:${webPortExpression}}"`,
403
414
  "",
404
415
  `cd "$ROOT_DIR/${component.dir}"`,
405
416
  "npm install",
406
- `npm run dev -- --host "\${WEB_HOST:-127.0.0.1}" --port "\${${component.id.toUpperCase()}_PORT:-\${WEB_PORT:-${component.port}}}"`,
417
+ `npm run dev -- --host "\${WEB_HOST:-127.0.0.1}" --port "${webPortExpression}"`,
407
418
  ], options.componentScript ? componentScriptOptions() : {});
408
419
  }
409
420
 
@@ -450,8 +461,8 @@ ${startLines.length ? "wait" : ""}
450
461
 
451
462
  function renderEnvironmentGuardPortsScript(plan) {
452
463
  const ports = [
453
- ...plan.runtimes.apis.map((component) => ({ id: component.id, type: "api", env: `${component.id.toUpperCase()}_PORT`, fallbackEnv: "SERVER_PORT", port: component.port })),
454
- ...plan.runtimes.webs.map((component) => ({ id: component.id, type: "web", env: `${component.id.toUpperCase()}_PORT`, fallbackEnv: "WEB_PORT", port: component.port }))
464
+ ...plan.runtimes.apis.map((component, index) => ({ id: component.id, type: "api", env: `${component.id.toUpperCase()}_PORT`, fallbackEnv: index === 0 ? "SERVER_PORT" : null, port: component.port })),
465
+ ...plan.runtimes.webs.map((component, index) => ({ id: component.id, type: "web", env: `${component.id.toUpperCase()}_PORT`, fallbackEnv: index === 0 ? "WEB_PORT" : null, port: component.port }))
455
466
  ];
456
467
  return `#!/usr/bin/env node
457
468
  import net from "node:net";
@@ -461,7 +472,7 @@ const ports = ${JSON.stringify(ports, null, 2)};
461
472
  const expectedService = ${JSON.stringify(plan.runtimeReference.serviceName || "")};
462
473
 
463
474
  function effectivePort(entry) {
464
- return Number(process.env[entry.env] || process.env[entry.fallbackEnv] || entry.port);
475
+ return Number(process.env[entry.env] || (entry.fallbackEnv ? process.env[entry.fallbackEnv] : "") || entry.port);
465
476
  }
466
477
 
467
478
  function portInUse(port) {
@@ -26,13 +26,13 @@ export function generateSwiftUiApp(graph, options = {}) {
26
26
  const files = {};
27
27
 
28
28
  for (const name of swiftFiles) {
29
- files[`Sources/TodoSwiftUIApp/${name}`] = fs.readFileSync(path.join(runtimeDir, name), "utf8");
29
+ files[`Sources/TopogramSwiftUIApp/${name}`] = fs.readFileSync(path.join(runtimeDir, name), "utf8");
30
30
  }
31
31
 
32
32
  files["Package.swift"] = fs.readFileSync(path.join(__dirname, "swiftui-templates", "Package.swift.txt"), "utf8");
33
33
  files["README.md"] = fs.readFileSync(path.join(__dirname, "swiftui-templates", "README.generated.md"), "utf8");
34
- files["Sources/TodoSwiftUIApp/Resources/ui-surface-contract.json"] = contractJson;
35
- files["Sources/TodoSwiftUIApp/Resources/api-contracts.json"] = apiJson;
34
+ files["Sources/TopogramSwiftUIApp/Resources/ui-surface-contract.json"] = contractJson;
35
+ files["Sources/TopogramSwiftUIApp/Resources/api-contracts.json"] = apiJson;
36
36
 
37
37
  return files;
38
38
  }
@@ -2,15 +2,15 @@
2
2
  import PackageDescription
3
3
 
4
4
  let package = Package(
5
- name: "TodoSwiftUIApp",
5
+ name: "TopogramSwiftUIApp",
6
6
  platforms: [.iOS(.v17), .macOS(.v14)],
7
7
  products: [
8
- .executable(name: "TodoSwiftUIApp", targets: ["TodoSwiftUIApp"])
8
+ .executable(name: "TopogramSwiftUIApp", targets: ["TopogramSwiftUIApp"])
9
9
  ],
10
10
  targets: [
11
11
  .executableTarget(
12
- name: "TodoSwiftUIApp",
13
- path: "Sources/TodoSwiftUIApp",
12
+ name: "TopogramSwiftUIApp",
13
+ path: "Sources/TopogramSwiftUIApp",
14
14
  resources: [
15
15
  .copy("Resources/api-contracts.json"),
16
16
  .copy("Resources/ui-surface-contract.json")
@@ -1,4 +1,4 @@
1
- # Todo SwiftUI (generated)
1
+ # Topogram SwiftUI (generated)
2
2
 
3
3
  Apple SwiftUI client generated from the same **`buildWebRealization`** routed UI contract as the web stacks. Prefer the **`proj_ios_surface__swiftui`** projection when present; otherwise the generator falls back to a **`proj_web_surface__*`** projection (often **`proj_web_surface__sveltekit`**).
4
4
 
@@ -9,12 +9,12 @@ Apple SwiftUI client generated from the same **`buildWebRealization`** routed UI
9
9
 
10
10
  ## Run
11
11
 
12
- Open **`Package.swift`** in Xcode 15+ and run the **`TodoSwiftUIApp`** scheme on an iOS Simulator.
12
+ Open **`Package.swift`** in Xcode 15+ and run the **`TopogramSwiftUIApp`** scheme on an iOS Simulator.
13
13
 
14
- Configure the API base URL and demo auth token via scheme environment variables (mirror web):
14
+ Configure the API base URL and optional auth token via scheme environment variables (mirror web):
15
15
 
16
16
  - `PUBLIC_TOPOGRAM_API_BASE_URL` (default `http://localhost:3000`)
17
- - `PUBLIC_TOPOGRAM_DEMO_AUTH_TOKEN`
17
+ - `PUBLIC_TOPOGRAM_AUTH_TOKEN`
18
18
  - Optional JWT / permission env vars matching web `visibility.ts` (`PUBLIC_TOPOGRAM_AUTH_*`).
19
19
 
20
20
  ## Regenerate
@@ -1,12 +1,12 @@
1
1
  import SwiftUI
2
2
 
3
3
  /// Holds decoded ui-surface-contract.json for runtime-driven navigation (parity with web bundle).
4
- public final class TodoUiContract: ObservableObject {
4
+ public final class TopogramUiContract: ObservableObject {
5
5
  public let raw: [String: Any]
6
6
 
7
7
  public init(data: Data) throws {
8
8
  guard let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
9
- throw NSError(domain: "TodoUiContract", code: 1)
9
+ throw NSError(domain: "TopogramUiContract", code: 1)
10
10
  }
11
11
  self.raw = obj
12
12
  }
@@ -14,7 +14,7 @@ public final class TodoUiContract: ObservableObject {
14
14
  public static func loadBundled() throws -> Data {
15
15
  guard let url = Bundle.module.url(forResource: "ui-surface-contract", withExtension: "json"),
16
16
  let data = try? Data(contentsOf: url) else {
17
- throw NSError(domain: "TodoUiContract", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing ui-surface-contract.json"])
17
+ throw NSError(domain: "TopogramUiContract", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing ui-surface-contract.json"])
18
18
  }
19
19
  return data
20
20
  }
@@ -27,6 +27,12 @@ public final class TodoUiContract: ObservableObject {
27
27
  screens.first { ($0["id"] as? String) == id }
28
28
  }
29
29
 
30
+ public func firstScreen(kind: String, excluding excludedId: String? = nil) -> [String: Any]? {
31
+ screens.first {
32
+ ($0["kind"] as? String) == kind && ($0["id"] as? String) != excludedId
33
+ }
34
+ }
35
+
30
36
  public var tabItems: [NavigationItem] {
31
37
  guard let nav = raw["navigation"] as? [String: Any],
32
38
  let items = nav["items"] as? [[String: Any]] else {
@@ -63,10 +69,10 @@ public struct NavigationItem: Identifiable {
63
69
  }
64
70
 
65
71
  public struct RootTabView: View {
66
- @ObservedObject var contract: TodoUiContract
67
- let client: TodoAPIClient
72
+ @ObservedObject var contract: TopogramUiContract
73
+ let client: TopogramAPIClient
68
74
 
69
- public init(contract: TodoUiContract, client: TodoAPIClient) {
75
+ public init(contract: TopogramUiContract, client: TopogramAPIClient) {
70
76
  self.contract = contract
71
77
  self.client = client
72
78
  }
@@ -85,11 +91,11 @@ public struct RootTabView: View {
85
91
  }
86
92
 
87
93
  public struct DynamicScreenView: View {
88
- @ObservedObject var contract: TodoUiContract
89
- let client: TodoAPIClient
94
+ @ObservedObject var contract: TopogramUiContract
95
+ let client: TopogramAPIClient
90
96
  let rootScreenId: String
91
97
 
92
- public init(contract: TodoUiContract, client: TodoAPIClient, rootScreenId: String) {
98
+ public init(contract: TopogramUiContract, client: TopogramAPIClient, rootScreenId: String) {
93
99
  self.contract = contract
94
100
  self.client = client
95
101
  self.rootScreenId = rootScreenId
@@ -105,8 +111,8 @@ public struct DynamicScreenView: View {
105
111
  }
106
112
 
107
113
  struct ScreenSwitcher: View {
108
- @ObservedObject var contract: TodoUiContract
109
- let client: TodoAPIClient
114
+ @ObservedObject var contract: TopogramUiContract
115
+ let client: TopogramAPIClient
110
116
  let screen: [String: Any]
111
117
  let baseParams: [String: String]
112
118
 
@@ -136,8 +142,8 @@ struct ScreenSwitcher: View {
136
142
  // MARK: - List
137
143
 
138
144
  struct ListScreen: View {
139
- @ObservedObject var contract: TodoUiContract
140
- let client: TodoAPIClient
145
+ @ObservedObject var contract: TopogramUiContract
146
+ let client: TopogramAPIClient
141
147
  let screen: [String: Any]
142
148
  let baseParams: [String: String]
143
149
  @State private var rows: [[String: Any]] = []
@@ -148,10 +154,11 @@ struct ListScreen: View {
148
154
  let title = screen["title"] as? String ?? "List"
149
155
  let loadCap = (screen["loadCapability"] as? [String: Any])?["id"] as? String
150
156
  let screenId = screen["id"] as? String ?? ""
157
+ let filterFields = loadCap.map { client.queryFieldNames(for: $0) } ?? []
151
158
 
152
159
  VStack(alignment: .leading, spacing: 8) {
153
- if screenId == "task_list" {
154
- filterBar
160
+ if let loadCap, !filterFields.isEmpty {
161
+ filterBar(loadCap: loadCap, fields: filterFields)
155
162
  }
156
163
  if let errorText {
157
164
  Text(errorText).foregroundStyle(.red)
@@ -161,7 +168,7 @@ struct ListScreen: View {
161
168
  List(0 ..< rows.count, id: \.self) { i in
162
169
  let row = rows[i]
163
170
  NavigationLink {
164
- linkedDetail(row: row, listScreenId: screenId)
171
+ linkedDetail(row: row)
165
172
  } label: {
166
173
  VStack(alignment: .leading) {
167
174
  Text(rowTitle(row))
@@ -181,15 +188,10 @@ struct ListScreen: View {
181
188
  Visibility.canShowAction(pid, screen: screen, resource: nil) {
182
189
  ToolbarItem(placement: .primaryAction) {
183
190
  NavigationLink("Add") {
184
- linkedCreate(listScreenId: screenId)
191
+ linkedCreate(currentScreenId: screenId)
185
192
  }
186
193
  }
187
194
  }
188
- if screenId == "task_list", Visibility.canShowAction("cap_export_tasks", screen: screen, resource: nil) {
189
- ToolbarItem(placement: .automatic) {
190
- ExportTasksButton(client: client, contract: contract)
191
- }
192
- }
193
195
  }
194
196
  .task {
195
197
  if let loadCap {
@@ -199,18 +201,18 @@ struct ListScreen: View {
199
201
  }
200
202
 
201
203
  @ViewBuilder
202
- private var filterBar: some View {
204
+ private func filterBar(loadCap: String, fields: [String]) -> some View {
203
205
  VStack(alignment: .leading) {
204
206
  Text("Filters").font(.caption).foregroundStyle(.secondary)
205
207
  HStack {
206
- TextField("project_id", text: bindingFilter("project_id"))
207
- TextField("owner_id", text: bindingFilter("owner_id"))
208
- TextField("status", text: bindingFilter("status"))
208
+ ForEach(fields, id: \.self) { field in
209
+ TextField(field, text: bindingFilter(field))
210
+ }
209
211
  }
210
212
  .textFieldStyle(.roundedBorder)
211
213
  Button("Apply") {
212
214
  Task {
213
- await reload(loadCap: "cap_list_tasks")
215
+ await reload(loadCap: loadCap)
214
216
  }
215
217
  }
216
218
  }
@@ -237,29 +239,19 @@ struct ListScreen: View {
237
239
  }
238
240
 
239
241
  @ViewBuilder
240
- private func linkedDetail(row: [String: Any], listScreenId: String) -> some View {
241
- let rid = row["id"] as? String ?? ""
242
- if listScreenId.contains("task") {
243
- DetailScreen(contract: contract, client: client, screen: contract.screen(id: "task_detail") ?? [:], params: ["task_id": rid])
244
- } else if listScreenId.contains("project") {
245
- DetailScreen(contract: contract, client: client, screen: contract.screen(id: "project_detail") ?? [:], params: ["project_id": rid])
246
- } else if listScreenId.contains("user") {
247
- DetailScreen(contract: contract, client: client, screen: contract.screen(id: "user_detail") ?? [:], params: ["user_id": rid])
242
+ private func linkedDetail(row: [String: Any]) -> some View {
243
+ if let detail = contract.firstScreen(kind: "detail") {
244
+ DetailScreen(contract: contract, client: client, screen: detail, params: navigationParams(for: detail, row: row, client: client))
248
245
  } else {
249
- Text("Detail")
246
+ RowDetailFallback(row: row)
250
247
  }
251
248
  }
252
249
 
253
250
  @ViewBuilder
254
- private func linkedCreate(listScreenId: String) -> some View {
255
- switch listScreenId {
256
- case "project_list":
257
- FormScreen(client: client, contract: contract, screen: contract.screen(id: "project_create") ?? [:], params: [:])
258
- case "user_list":
259
- FormScreen(client: client, contract: contract, screen: contract.screen(id: "user_create") ?? [:], params: [:])
260
- case "task_list", "task_board", "task_calendar":
261
- FormScreen(client: client, contract: contract, screen: contract.screen(id: "task_create") ?? [:], params: [:])
262
- default:
251
+ private func linkedCreate(currentScreenId: String) -> some View {
252
+ if let form = contract.firstScreen(kind: "form", excluding: currentScreenId) ?? contract.firstScreen(kind: "wizard", excluding: currentScreenId) {
253
+ FormScreen(client: client, contract: contract, screen: form, params: [:])
254
+ } else {
263
255
  Text("Create")
264
256
  }
265
257
  }
@@ -279,8 +271,8 @@ private func rowSubtitle(_ row: [String: Any]) -> String {
279
271
  // MARK: - Detail
280
272
 
281
273
  struct DetailScreen: View {
282
- @ObservedObject var contract: TodoUiContract
283
- let client: TodoAPIClient
274
+ @ObservedObject var contract: TopogramUiContract
275
+ let client: TopogramAPIClient
284
276
  let screen: [String: Any]
285
277
  let params: [String: String]
286
278
  @State private var payload: [String: Any]?
@@ -310,7 +302,7 @@ struct DetailScreen: View {
310
302
  }
311
303
  .navigationTitle((screen["title"] as? String) ?? "Detail")
312
304
  .toolbar {
313
- ToolbarItemGroup(placement: .bottomBar) {
305
+ ToolbarItemGroup(placement: .automatic) {
314
306
  if let primary = (screen["actions"] as? [String: Any])?["primary"] as? [String: Any],
315
307
  let cid = primary["id"] as? String {
316
308
  NavigationLink("Edit") {
@@ -353,19 +345,12 @@ struct DetailScreen: View {
353
345
 
354
346
  @MainActor
355
347
  private func runSecondary(_ capabilityId: String) async {
356
- guard capabilityId == "cap_complete_task",
357
- let taskId = params["task_id"] ?? params["id"],
358
- let detail = payload else { return }
359
- let etag = String(describing: detail["updated_at"] ?? "")
360
348
  var headers: [String: String] = [:]
361
- if !etag.isEmpty { headers["If-Match"] = etag }
362
- let body: [String: Any] = [
363
- "task_id": taskId,
364
- "completed": true,
365
- "idempotency_key": UUID().uuidString
366
- ]
349
+ if let detail = payload, let updatedAt = detail["updated_at"] {
350
+ headers["If-Match"] = String(describing: updatedAt)
351
+ }
367
352
  do {
368
- _ = try await client.requestCapability(capabilityId, input: body, extraHeaders: headers)
353
+ _ = try await client.requestCapability(capabilityId, input: params, extraHeaders: headers)
369
354
  await load()
370
355
  } catch {
371
356
  errorText = String(describing: error)
@@ -374,13 +359,8 @@ struct DetailScreen: View {
374
359
 
375
360
  @MainActor
376
361
  private func runDestructive(_ capabilityId: String) async {
377
- guard let rid = params["task_id"] ?? params["project_id"] ?? params["user_id"] ?? params["id"] else { return }
378
- var input: [String: Any] = [:]
379
- if capabilityId.contains("task") { input["task_id"] = rid }
380
- if capabilityId.contains("project") { input["project_id"] = rid }
381
- if capabilityId.contains("user") { input["user_id"] = rid }
382
362
  do {
383
- _ = try await client.requestCapability(capabilityId, input: input)
363
+ _ = try await client.requestCapability(capabilityId, input: params)
384
364
  } catch {
385
365
  errorText = String(describing: error)
386
366
  }
@@ -388,16 +368,22 @@ struct DetailScreen: View {
388
368
 
389
369
  @ViewBuilder
390
370
  private func editForm(cid: String) -> some View {
391
- let sid = cid.contains("project") ? "project_edit" : cid.contains("user") ? "user_edit" : "task_edit"
392
- FormScreen(client: client, contract: contract, screen: contract.screen(id: sid) ?? [:], params: params)
371
+ if let form = contract.screens.first(where: { screen in
372
+ guard let submit = (screen["submitCapability"] as? [String: Any])?["id"] as? String else { return false }
373
+ return submit == cid
374
+ }) ?? contract.firstScreen(kind: "form") {
375
+ FormScreen(client: client, contract: contract, screen: form, params: params)
376
+ } else {
377
+ Text("Edit")
378
+ }
393
379
  }
394
380
  }
395
381
 
396
382
  // MARK: - Form
397
383
 
398
384
  struct FormScreen: View {
399
- let client: TodoAPIClient
400
- @ObservedObject var contract: TodoUiContract
385
+ let client: TopogramAPIClient
386
+ @ObservedObject var contract: TopogramUiContract
401
387
  let screen: [String: Any]
402
388
  let params: [String: String]
403
389
  @State private var values: [String: String] = [:]
@@ -434,23 +420,16 @@ struct FormScreen: View {
434
420
  }
435
421
 
436
422
  private func preload() async {
437
- guard let sid = screen["id"] as? String else { return }
438
- if sid.contains("edit") {
439
- let getCap = sid.contains("task") ? "cap_get_task" : sid.contains("project") ? "cap_get_project" : "cap_get_user"
440
- var p = params
441
- if sid.contains("task") { p["task_id"] = params["task_id"] ?? params["id"] ?? "" }
442
- if sid.contains("project") { p["project_id"] = params["project_id"] ?? params["id"] ?? "" }
443
- if sid.contains("user") { p["user_id"] = params["user_id"] ?? params["id"] ?? "" }
444
- do {
445
- let json = try await client.requestCapability(getCap, input: p)
446
- if let dict = json as? [String: Any] {
447
- for key in dict.keys {
448
- values[key] = String(describing: dict[key] ?? "")
449
- }
423
+ guard let loadCap = (screen["loadCapability"] as? [String: Any])?["id"] as? String else { return }
424
+ do {
425
+ let json = try await client.requestCapability(loadCap, input: params)
426
+ if let dict = json as? [String: Any] {
427
+ for key in dict.keys {
428
+ values[key] = String(describing: dict[key] ?? "")
450
429
  }
451
- } catch {
452
- message = String(describing: error)
453
430
  }
431
+ } catch {
432
+ message = String(describing: error)
454
433
  }
455
434
  }
456
435
 
@@ -466,7 +445,7 @@ struct FormScreen: View {
466
445
  input[k] = v
467
446
  }
468
447
  var headers: [String: String] = [:]
469
- if submitId == "cap_update_task", let etag = values["updated_at"] {
448
+ if let etag = values["updated_at"], !etag.isEmpty {
470
449
  headers["If-Match"] = etag
471
450
  }
472
451
  do {
@@ -478,59 +457,11 @@ struct FormScreen: View {
478
457
  }
479
458
  }
480
459
 
481
- extension TodoAPIClient {
482
- func bodyFieldNames(for capabilityId: String) -> [String] {
483
- guard let contract = contracts[capabilityId] as? [String: Any],
484
- let rc = contract["requestContract"] as? [String: Any],
485
- let transport = rc["transport"] as? [String: Any],
486
- let body = transport["body"] as? [[String: Any]] else {
487
- return []
488
- }
489
- return body.compactMap { $0["name"] as? String }
490
- }
491
- }
492
-
493
- struct ExportTasksButton: View {
494
- let client: TodoAPIClient
495
- @ObservedObject var contract: TodoUiContract
496
- @State private var jobId: String?
497
-
498
- var body: some View {
499
- Group {
500
- if let jobId {
501
- NavigationLink("Export job") {
502
- JobStatusScreen(
503
- client: client,
504
- contract: contract,
505
- screen: contract.screen(id: "task_exports") ?? [:],
506
- params: ["job_id": jobId]
507
- )
508
- }
509
- } else {
510
- Button("Export") {
511
- Task { await run() }
512
- }
513
- }
514
- }
515
- }
516
-
517
- private func run() async {
518
- do {
519
- let json = try await client.requestCapability("cap_export_tasks", input: [:])
520
- if let dict = json as? [String: Any], let id = dict["job_id"] as? String {
521
- jobId = id
522
- }
523
- } catch {
524
- jobId = nil
525
- }
526
- }
527
- }
528
-
529
460
  // MARK: - Board & Calendar
530
461
 
531
462
  struct BoardScreen: View {
532
- @ObservedObject var contract: TodoUiContract
533
- let client: TodoAPIClient
463
+ @ObservedObject var contract: TopogramUiContract
464
+ let client: TopogramAPIClient
534
465
  let screen: [String: Any]
535
466
  let baseParams: [String: String]
536
467
  @State private var rows: [[String: Any]] = []
@@ -549,8 +480,8 @@ struct BoardScreen: View {
549
480
  DetailScreen(
550
481
  contract: contract,
551
482
  client: client,
552
- screen: contract.screen(id: "task_detail") ?? [:],
553
- params: ["task_id": row["id"] as? String ?? ""]
483
+ screen: contract.firstScreen(kind: "detail") ?? [:],
484
+ params: navigationParams(for: contract.firstScreen(kind: "detail") ?? [:], row: row, client: client)
554
485
  )
555
486
  } label: {
556
487
  Text(rowTitle(row))
@@ -578,8 +509,8 @@ struct BoardScreen: View {
578
509
  }
579
510
 
580
511
  struct CalendarScreen: View {
581
- @ObservedObject var contract: TodoUiContract
582
- let client: TodoAPIClient
512
+ @ObservedObject var contract: TopogramUiContract
513
+ let client: TopogramAPIClient
583
514
  let screen: [String: Any]
584
515
  let baseParams: [String: String]
585
516
  @State private var rows: [[String: Any]] = []
@@ -602,8 +533,8 @@ struct CalendarScreen: View {
602
533
  DetailScreen(
603
534
  contract: contract,
604
535
  client: client,
605
- screen: contract.screen(id: "task_detail") ?? [:],
606
- params: ["task_id": row["id"] as? String ?? ""]
536
+ screen: contract.firstScreen(kind: "detail") ?? [:],
537
+ params: navigationParams(for: contract.firstScreen(kind: "detail") ?? [:], row: row, client: client)
607
538
  )
608
539
  } label: {
609
540
  Text(rowTitle(row))
@@ -630,8 +561,8 @@ struct CalendarScreen: View {
630
561
  }
631
562
 
632
563
  struct JobStatusScreen: View {
633
- let client: TodoAPIClient
634
- @ObservedObject var contract: TodoUiContract
564
+ let client: TopogramAPIClient
565
+ @ObservedObject var contract: TopogramUiContract
635
566
  let screen: [String: Any]
636
567
  let params: [String: String]
637
568
  @State private var job: [String: Any]?
@@ -672,11 +603,45 @@ struct JobStatusScreen: View {
672
603
  }
673
604
 
674
605
  private func download(_ capabilityId: String) async {
675
- let jobId = params["job_id"] ?? ""
676
606
  do {
677
- _ = try await client.requestCapability(capabilityId, input: ["job_id": jobId])
607
+ _ = try await client.requestCapability(capabilityId, input: params)
678
608
  } catch {
679
609
  // ignore download errors in stub
680
610
  }
681
611
  }
682
612
  }
613
+
614
+ struct RowDetailFallback: View {
615
+ let row: [String: Any]
616
+
617
+ var body: some View {
618
+ List(Array(row.keys.sorted()), id: \.self) { key in
619
+ HStack {
620
+ Text(key).foregroundStyle(.secondary)
621
+ Spacer()
622
+ Text(String(describing: row[key] ?? ""))
623
+ }
624
+ }
625
+ .navigationTitle(rowTitle(row))
626
+ }
627
+ }
628
+
629
+ private func navigationParams(for screen: [String: Any], row: [String: Any], client: TopogramAPIClient) -> [String: String] {
630
+ guard let cap = (screen["loadCapability"] as? [String: Any])?["id"] as? String else {
631
+ return row.compactMapValues { $0 as? String }
632
+ }
633
+ let pathFields = client.pathFieldNames(for: cap)
634
+ if pathFields.isEmpty {
635
+ return row.compactMapValues { $0 as? String }
636
+ }
637
+ var params: [String: String] = [:]
638
+ let fallbackId = row["id"].map { String(describing: $0) } ?? ""
639
+ for field in pathFields {
640
+ if let value = row[field] {
641
+ params[field] = String(describing: value)
642
+ } else if !fallbackId.isEmpty {
643
+ params[field] = fallbackId
644
+ }
645
+ }
646
+ return params
647
+ }
@@ -1,13 +1,13 @@
1
1
  import Foundation
2
2
 
3
3
  /// Dynamic capability client mirroring `src/lib/api/client.ts` (requestCapability over bundled api-contracts.json).
4
- public final class TodoAPIClient: @unchecked Sendable {
4
+ public final class TopogramAPIClient: @unchecked Sendable {
5
5
  private let session: URLSession
6
6
  let contracts: [String: Any]
7
7
  public init(session: URLSession = .shared, contractsData: Data) throws {
8
8
  self.session = session
9
9
  guard let root = try JSONSerialization.jsonObject(with: contractsData) as? [String: Any] else {
10
- throw TodoAPIError.invalidContracts
10
+ throw TopogramAPIError.invalidContracts
11
11
  }
12
12
  self.contracts = root
13
13
  }
@@ -15,7 +15,7 @@ public final class TodoAPIClient: @unchecked Sendable {
15
15
  public static func loadBundledContracts() throws -> Data {
16
16
  guard let url = Bundle.module.url(forResource: "api-contracts", withExtension: "json"),
17
17
  let data = try? Data(contentsOf: url) else {
18
- throw TodoAPIError.missingResource("api-contracts.json")
18
+ throw TopogramAPIError.missingResource("api-contracts.json")
19
19
  }
20
20
  return data
21
21
  }
@@ -26,13 +26,34 @@ public final class TodoAPIClient: @unchecked Sendable {
26
26
  }
27
27
 
28
28
  private func authToken() -> String {
29
- ProcessInfo.processInfo.environment["PUBLIC_TOPOGRAM_DEMO_AUTH_TOKEN"] ?? ""
29
+ ProcessInfo.processInfo.environment["PUBLIC_TOPOGRAM_AUTH_TOKEN"] ?? ""
30
+ }
31
+
32
+ private func transportFields(for capabilityId: String, section: String) -> [[String: Any]] {
33
+ guard let contract = contracts[capabilityId] as? [String: Any],
34
+ let requestContract = contract["requestContract"] as? [String: Any],
35
+ let transport = requestContract["transport"] as? [String: Any] else {
36
+ return []
37
+ }
38
+ return transport[section] as? [[String: Any]] ?? []
39
+ }
40
+
41
+ public func pathFieldNames(for capabilityId: String) -> [String] {
42
+ transportFields(for: capabilityId, section: "path").compactMap { $0["name"] as? String }
43
+ }
44
+
45
+ public func queryFieldNames(for capabilityId: String) -> [String] {
46
+ transportFields(for: capabilityId, section: "query").compactMap { $0["name"] as? String }
47
+ }
48
+
49
+ public func bodyFieldNames(for capabilityId: String) -> [String] {
50
+ transportFields(for: capabilityId, section: "body").compactMap { $0["name"] as? String }
30
51
  }
31
52
 
32
53
  private func buildPath(contract: [String: Any], input: [String: Any]) throws -> String {
33
54
  guard let endpoint = contract["endpoint"] as? [String: Any],
34
55
  let rawPath = endpoint["path"] as? String else {
35
- throw TodoAPIError.invalidContracts
56
+ throw TopogramAPIError.invalidContracts
36
57
  }
37
58
  var path = rawPath
38
59
  let requestContract = contract["requestContract"] as? [String: Any]
@@ -78,15 +99,15 @@ public final class TodoAPIClient: @unchecked Sendable {
78
99
  extraHeaders: [String: String] = [:]
79
100
  ) async throws -> Any {
80
101
  guard let contract = contracts[capabilityId] as? [String: Any] else {
81
- throw TodoAPIError.unknownCapability(capabilityId)
102
+ throw TopogramAPIError.unknownCapability(capabilityId)
82
103
  }
83
104
  guard let endpoint = contract["endpoint"] as? [String: Any],
84
105
  let method = endpoint["method"] as? String else {
85
- throw TodoAPIError.invalidContracts
106
+ throw TopogramAPIError.invalidContracts
86
107
  }
87
108
  let path = try buildPath(contract: contract, input: input)
88
109
  guard let url = URL(string: path, relativeTo: URL(string: apiBase()))?.absoluteURL else {
89
- throw TodoAPIError.badURL(path)
110
+ throw TopogramAPIError.badURL(path)
90
111
  }
91
112
  var request = URLRequest(url: url)
92
113
  request.httpMethod = method
@@ -121,11 +142,11 @@ public final class TodoAPIClient: @unchecked Sendable {
121
142
  let downloadable = endpoint["download"] as? [[String: Any]] ?? []
122
143
  let (data, response) = try await session.data(for: request)
123
144
  guard let http = response as? HTTPURLResponse else {
124
- throw TodoAPIError.invalidResponse
145
+ throw TopogramAPIError.invalidResponse
125
146
  }
126
147
  guard (200 ..< 300).contains(http.statusCode) else {
127
148
  let text = String(data: data, encoding: .utf8) ?? ""
128
- throw TodoAPIError.http(http.statusCode, text)
149
+ throw TopogramAPIError.http(http.statusCode, text)
129
150
  }
130
151
  if http.statusCode == 204 {
131
152
  return NSNull()
@@ -146,7 +167,7 @@ public final class TodoAPIClient: @unchecked Sendable {
146
167
  }
147
168
  }
148
169
 
149
- public enum TodoAPIError: Error {
170
+ public enum TopogramAPIError: Error {
150
171
  case invalidContracts
151
172
  case missingResource(String)
152
173
  case unknownCapability(String)
@@ -1,7 +1,7 @@
1
1
  import SwiftUI
2
2
 
3
3
  @main
4
- struct TodoSwiftUIApp: App {
4
+ struct TopogramSwiftUIApp: App {
5
5
  @StateObject private var contractHolder = ContractHolder()
6
6
 
7
7
  var body: some Scene {
@@ -13,7 +13,7 @@ struct TodoSwiftUIApp: App {
13
13
  Text("Failed to load Topogram UI contract: \(err)")
14
14
  .padding()
15
15
  } else {
16
- ProgressView("Loading Topogram Todo…")
16
+ ProgressView("Loading Topogram UI…")
17
17
  }
18
18
  }
19
19
  .task {
@@ -25,16 +25,16 @@ struct TodoSwiftUIApp: App {
25
25
 
26
26
  @MainActor
27
27
  final class ContractHolder: ObservableObject {
28
- @Published var contract: TodoUiContract?
29
- @Published var client: TodoAPIClient?
28
+ @Published var contract: TopogramUiContract?
29
+ @Published var client: TopogramAPIClient?
30
30
  @Published var error: String?
31
31
 
32
32
  func bootstrap() async {
33
33
  do {
34
- let uiData = try TodoUiContract.loadBundled()
35
- let apiData = try TodoAPIClient.loadBundledContracts()
36
- let ui = try TodoUiContract(data: uiData)
37
- let cli = try TodoAPIClient(contractsData: apiData)
34
+ let uiData = try TopogramUiContract.loadBundled()
35
+ let apiData = try TopogramAPIClient.loadBundledContracts()
36
+ let ui = try TopogramUiContract(data: uiData)
37
+ let cli = try TopogramAPIClient(contractsData: apiData)
38
38
  contract = ui
39
39
  client = cli
40
40
  } catch {
@@ -108,11 +108,12 @@ public enum Visibility {
108
108
  }
109
109
 
110
110
  private static func currentPrincipal(overrides: PrincipalOverride?) -> Principal? {
111
- let token = env("PUBLIC_TOPOGRAM_DEMO_AUTH_TOKEN")
111
+ let token = env("PUBLIC_TOPOGRAM_AUTH_TOKEN")
112
112
  let jwtPrincipal = token.isEmpty ? nil : principalFromJwt(token)
113
113
  let envClaims = parseClaims(env("PUBLIC_TOPOGRAM_AUTH_CLAIMS"))
114
+ let envUserId = env("PUBLIC_TOPOGRAM_AUTH_USER_ID")
114
115
  let userId = overrides?.userId
115
- ?? env("PUBLIC_TOPOGRAM_AUTH_USER_ID")
116
+ ?? (!envUserId.isEmpty ? envUserId : nil)
116
117
  ?? jwtPrincipal?.userId
117
118
  ?? ""
118
119
 
@@ -305,128 +305,128 @@ function validateOutputConfig(errors, config) {
305
305
  }
306
306
 
307
307
  /**
308
- * @param {any} component
308
+ * @param {any} runtime
309
309
  * @returns {string}
310
310
  */
311
- function componentLabel(component) {
312
- return component?.id ? `Runtime '${component.id}'` : "Topology runtime";
311
+ function runtimeLabel(runtime) {
312
+ return runtime?.id ? `Runtime '${runtime.id}'` : "Topology runtime";
313
313
  }
314
314
 
315
315
  /**
316
316
  * @param {ValidationError[]} errors
317
- * @param {any} component
317
+ * @param {any} runtime
318
318
  * @param {Set<string>} seenIds
319
319
  * @returns {boolean}
320
320
  */
321
- function validateComponentShape(errors, component, seenIds) {
322
- if (!component || typeof component !== "object" || Array.isArray(component)) {
321
+ function validateRuntimeShape(errors, runtime, seenIds) {
322
+ if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) {
323
323
  pushError(errors, "Topology runtime must be an object");
324
324
  return false;
325
325
  }
326
- if (typeof component.id !== "string" || !IDENTIFIER_PATTERN.test(component.id)) {
327
- pushError(errors, `${componentLabel(component)} id must match ${IDENTIFIER_PATTERN}`);
328
- } else if (seenIds.has(component.id)) {
329
- pushError(errors, `Duplicate topology runtime id '${component.id}'`);
326
+ if (typeof runtime.id !== "string" || !IDENTIFIER_PATTERN.test(runtime.id)) {
327
+ pushError(errors, `${runtimeLabel(runtime)} id must match ${IDENTIFIER_PATTERN}`);
328
+ } else if (seenIds.has(runtime.id)) {
329
+ pushError(errors, `Duplicate topology runtime id '${runtime.id}'`);
330
330
  } else {
331
- seenIds.add(component.id);
331
+ seenIds.add(runtime.id);
332
332
  }
333
- if (component.type != null) {
334
- pushError(errors, `${componentLabel(component)} ${renameDiagnostic("'type'", "'kind'", `"kind": "api_service"`)}`);
333
+ if (runtime.type != null) {
334
+ pushError(errors, `${runtimeLabel(runtime)} ${renameDiagnostic("'type'", "'kind'", `"kind": "api_service"`)}`);
335
335
  }
336
- if (component.database != null) {
337
- pushError(errors, `${componentLabel(component)} ${renameDiagnostic("'database'", "'uses_database'", `"uses_database": "app_db"`)}`);
336
+ if (runtime.database != null) {
337
+ pushError(errors, `${runtimeLabel(runtime)} ${renameDiagnostic("'database'", "'uses_database'", `"uses_database": "app_db"`)}`);
338
338
  }
339
- if (component.api != null) {
340
- pushError(errors, `${componentLabel(component)} ${renameDiagnostic("'api'", "'uses_api'", `"uses_api": "app_api"`)}`);
339
+ if (runtime.api != null) {
340
+ pushError(errors, `${runtimeLabel(runtime)} ${renameDiagnostic("'api'", "'uses_api'", `"uses_api": "app_api"`)}`);
341
341
  }
342
- if (!["api_service", "web_surface", "ios_surface", "android_surface", "database"].includes(component.kind)) {
343
- pushError(errors, `${componentLabel(component)} kind must be api_service, web_surface, ios_surface, android_surface, or database`);
342
+ if (!["api_service", "web_surface", "ios_surface", "android_surface", "database"].includes(runtime.kind)) {
343
+ pushError(errors, `${runtimeLabel(runtime)} kind must be api_service, web_surface, ios_surface, android_surface, or database`);
344
344
  }
345
- if (typeof component.projection !== "string" || component.projection.length === 0) {
346
- pushError(errors, `${componentLabel(component)} projection must be a non-empty string`);
345
+ if (typeof runtime.projection !== "string" || runtime.projection.length === 0) {
346
+ pushError(errors, `${runtimeLabel(runtime)} projection must be a non-empty string`);
347
347
  }
348
- if (!component.generator || typeof component.generator !== "object") {
349
- pushError(errors, `${componentLabel(component)} generator must be an object`);
348
+ if (!runtime.generator || typeof runtime.generator !== "object") {
349
+ pushError(errors, `${runtimeLabel(runtime)} generator must be an object`);
350
350
  } else {
351
- if (typeof component.generator.id !== "string" || component.generator.id.length === 0) {
352
- pushError(errors, `${componentLabel(component)} generator.id must be a non-empty string`);
351
+ if (typeof runtime.generator.id !== "string" || runtime.generator.id.length === 0) {
352
+ pushError(errors, `${runtimeLabel(runtime)} generator.id must be a non-empty string`);
353
353
  }
354
- if (typeof component.generator.version !== "string" || component.generator.version.length === 0) {
355
- pushError(errors, `${componentLabel(component)} generator.version must be a non-empty string`);
354
+ if (typeof runtime.generator.version !== "string" || runtime.generator.version.length === 0) {
355
+ pushError(errors, `${runtimeLabel(runtime)} generator.version must be a non-empty string`);
356
356
  }
357
- if (component.generator.package != null && (typeof component.generator.package !== "string" || component.generator.package.length === 0)) {
358
- pushError(errors, `${componentLabel(component)} generator.package must be a non-empty string when provided`);
357
+ if (runtime.generator.package != null && (typeof runtime.generator.package !== "string" || runtime.generator.package.length === 0)) {
358
+ pushError(errors, `${runtimeLabel(runtime)} generator.package must be a non-empty string when provided`);
359
359
  }
360
360
  }
361
- if (component.port != null && (!Number.isInteger(component.port) || component.port <= 0 || component.port > 65535)) {
362
- pushError(errors, `${componentLabel(component)} port must be an integer from 1 to 65535`);
361
+ if (runtime.port != null && (!Number.isInteger(runtime.port) || runtime.port <= 0 || runtime.port > 65535)) {
362
+ pushError(errors, `${runtimeLabel(runtime)} port must be an integer from 1 to 65535`);
363
363
  }
364
364
  return true;
365
365
  }
366
366
 
367
367
  /**
368
368
  * @param {ValidationError[]} errors
369
- * @param {RuntimeTopologyRuntime} component
369
+ * @param {RuntimeTopologyRuntime} runtime
370
370
  * @param {Map<string, Record<string, any>>} projections
371
371
  * @param {{ configDir?: string|null, rootDir?: string|null }} [options]
372
372
  * @returns {void}
373
373
  */
374
- function validateComponentCompatibility(errors, component, projections, options = {}) {
375
- const projection = projections.get(component.projection);
374
+ function validateRuntimeCompatibility(errors, runtime, projections, options = {}) {
375
+ const projection = projections.get(runtime.projection);
376
376
  if (!projection) {
377
- pushError(errors, `${componentLabel(component)} references missing projection '${component.projection}'`);
377
+ pushError(errors, `${runtimeLabel(runtime)} references missing projection '${runtime.projection}'`);
378
378
  return;
379
379
  }
380
380
 
381
- const resolvedManifest = resolveGeneratorManifestForBinding(component.generator, options);
381
+ const resolvedManifest = resolveGeneratorManifestForBinding(runtime.generator, options);
382
382
  const manifest = resolvedManifest.manifest;
383
383
  if (!manifest) {
384
384
  const details = resolvedManifest.errors.length > 0 ? `: ${resolvedManifest.errors.join("; ")}` : "";
385
- pushError(errors, `${componentLabel(component)} for projection '${projection.id}' uses unknown generator '${component.generator?.id}' version '${component.generator?.version || "unknown"}'${details}`);
385
+ pushError(errors, `${runtimeLabel(runtime)} for projection '${projection.id}' uses unknown generator '${runtime.generator?.id}' version '${runtime.generator?.version || "unknown"}'${details}`);
386
386
  return;
387
387
  }
388
388
  const manifestValidation = validateGeneratorManifest(manifest);
389
389
  if (!manifestValidation.ok) {
390
390
  for (const message of manifestValidation.errors) {
391
- pushError(errors, `${componentLabel(component)} generator manifest invalid: ${message}`);
391
+ pushError(errors, `${runtimeLabel(runtime)} generator manifest invalid: ${message}`);
392
392
  }
393
393
  }
394
394
  if (manifest.planned) {
395
- pushError(errors, `${componentLabel(component)} for projection '${projection.id}' uses planned generator '${manifest.id}@${manifest.version}', which is not implemented yet`);
395
+ pushError(errors, `${runtimeLabel(runtime)} for projection '${projection.id}' uses planned generator '${manifest.id}@${manifest.version}', which is not implemented yet`);
396
396
  }
397
- if (manifest.version !== component.generator.version) {
398
- pushError(errors, `${componentLabel(component)} for projection '${projection.id}' generator '${manifest.id}' version '${component.generator.version}' is unsupported; expected '${manifest.version}'`);
397
+ if (manifest.version !== runtime.generator.version) {
398
+ pushError(errors, `${runtimeLabel(runtime)} for projection '${projection.id}' generator '${manifest.id}' version '${runtime.generator.version}' is unsupported; expected '${manifest.version}'`);
399
399
  }
400
- if (!isGeneratorCompatible(manifest, component.kind, projection)) {
401
- pushError(errors, `${componentLabel(component)} for projection '${projection.id}' generator '${manifest.id}@${manifest.version}' is incompatible with runtime kind '${component.kind}' and projection type '${projection.type || "api_contract"}'`);
400
+ if (!isGeneratorCompatible(manifest, runtime.kind, projection)) {
401
+ pushError(errors, `${runtimeLabel(runtime)} for projection '${projection.id}' generator '${manifest.id}@${manifest.version}' is incompatible with runtime kind '${runtime.kind}' and projection type '${projection.type || "api_contract"}'`);
402
402
  }
403
403
  }
404
404
 
405
405
  /**
406
406
  * @param {ValidationError[]} errors
407
- * @param {RuntimeTopologyRuntime[]} components
407
+ * @param {RuntimeTopologyRuntime[]} runtimes
408
408
  * @returns {void}
409
409
  */
410
- function validateTopologyReferences(errors, components) {
411
- const byId = new Map(components.map((component) => [component.id, component]));
410
+ function validateTopologyReferences(errors, runtimes) {
411
+ const byId = new Map(runtimes.map((runtime) => [runtime.id, runtime]));
412
412
  const usedPorts = new Map();
413
- for (const component of components) {
414
- if (component.port != null) {
415
- const existing = usedPorts.get(component.port);
413
+ for (const runtime of runtimes) {
414
+ if (runtime.port != null) {
415
+ const existing = usedPorts.get(runtime.port);
416
416
  if (existing) {
417
- pushError(errors, `Port ${component.port} is used by both '${existing}' and '${component.id}'`);
417
+ pushError(errors, `Port ${runtime.port} is used by both '${existing}' and '${runtime.id}'`);
418
418
  } else {
419
- usedPorts.set(component.port, component.id);
419
+ usedPorts.set(runtime.port, runtime.id);
420
420
  }
421
421
  }
422
- if (component.kind === "api_service") {
423
- if (component.uses_database && byId.get(component.uses_database)?.kind !== "database") {
424
- pushError(errors, `${componentLabel(component)} references missing database runtime '${component.uses_database}'`);
422
+ if (runtime.kind === "api_service") {
423
+ if (runtime.uses_database && byId.get(runtime.uses_database)?.kind !== "database") {
424
+ pushError(errors, `${runtimeLabel(runtime)} references missing database runtime '${runtime.uses_database}'`);
425
425
  }
426
426
  }
427
- if (["web_surface", "ios_surface", "android_surface"].includes(component.kind)) {
428
- if (component.uses_api && byId.get(component.uses_api)?.kind !== "api_service") {
429
- pushError(errors, `${componentLabel(component)} references missing api runtime '${component.uses_api}'`);
427
+ if (["web_surface", "ios_surface", "android_surface"].includes(runtime.kind)) {
428
+ if (runtime.uses_api && byId.get(runtime.uses_api)?.kind !== "api_service") {
429
+ pushError(errors, `${runtimeLabel(runtime)} references missing api runtime '${runtime.uses_api}'`);
430
430
  }
431
431
  }
432
432
  }
@@ -455,8 +455,8 @@ export function validateProjectConfig(config, graph = null, options = {}) {
455
455
  pushError(errors, "topogram.project.json topology.runtimes must be an array");
456
456
  } else {
457
457
  const seenIds = new Set();
458
- for (const component of config.topology.runtimes) {
459
- validateComponentShape(errors, component, seenIds);
458
+ for (const runtime of config.topology.runtimes) {
459
+ validateRuntimeShape(errors, runtime, seenIds);
460
460
  }
461
461
  const generatorPolicy = validateProjectGeneratorPolicy(config, options);
462
462
  for (const error of generatorPolicy.errors) {
@@ -464,8 +464,8 @@ export function validateProjectConfig(config, graph = null, options = {}) {
464
464
  }
465
465
  if (graph) {
466
466
  const projections = projectionById(graph);
467
- for (const component of config.topology.runtimes) {
468
- validateComponentCompatibility(errors, component, projections, options);
467
+ for (const runtime of config.topology.runtimes) {
468
+ validateRuntimeCompatibility(errors, runtime, projections, options);
469
469
  }
470
470
  validateTopologyReferences(errors, config.topology.runtimes);
471
471
  }