@topogram/cli 0.3.59 → 0.3.61

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.59",
3
+ "version": "0.3.61",
4
4
  "description": "Topogram CLI for checking Topogram workspaces and generating app bundles.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/cli.js CHANGED
@@ -153,6 +153,17 @@ const KNOWN_CLI_CONSUMER_WORKFLOWS = {
153
153
  "topogram-hello": "Topogram Package Verification",
154
154
  "topograms": "Catalog Verification"
155
155
  };
156
+ const KNOWN_CLI_CONSUMER_WORKFLOW_JOBS = {
157
+ "topograms": [
158
+ "Validate catalog",
159
+ "Smoke native starter",
160
+ "Smoke starter alias (hello-web)",
161
+ "Smoke starter alias (hello-api)",
162
+ "Smoke starter alias (hello-db)",
163
+ "Smoke starter alias (web-api)",
164
+ "Smoke starter alias (web-api-db)"
165
+ ]
166
+ };
156
167
  const PACKAGE_UPDATE_CLI_CHECK_SCRIPTS = [
157
168
  "cli:surface",
158
169
  "doctor",
@@ -3211,6 +3222,14 @@ function expectedConsumerWorkflowName(name) {
3211
3222
  return KNOWN_CLI_CONSUMER_WORKFLOWS[name] || null;
3212
3223
  }
3213
3224
 
3225
+ /**
3226
+ * @param {string} name
3227
+ * @returns {string[]}
3228
+ */
3229
+ function expectedConsumerWorkflowJobs(name) {
3230
+ return KNOWN_CLI_CONSUMER_WORKFLOW_JOBS[name] || [];
3231
+ }
3232
+
3214
3233
  /**
3215
3234
  * @param {string[]} args
3216
3235
  * @param {string} cwd
@@ -3351,16 +3370,18 @@ function waitForConsumerCi(consumer, options = {}) {
3351
3370
  /**
3352
3371
  * @param {{ name: string, root?: string|null, workflow?: string|null }} consumer
3353
3372
  * @param {{ strict?: boolean }} [options]
3354
- * @returns {{ checked: boolean, ok: boolean|null, expectedWorkflow: string|null, headSha: string|null, run: { databaseId?: number, workflowName?: string, status?: string, conclusion?: string, headSha?: string, url?: string }|null, diagnostics: Array<Record<string, any>> }}
3373
+ * @returns {{ checked: boolean, ok: boolean|null, expectedWorkflow: string|null, expectedJobs: string[], headSha: string|null, run: { databaseId?: number, workflowName?: string, status?: string, conclusion?: string, headSha?: string, url?: string, jobs?: Array<Record<string, any>> }|null, diagnostics: Array<Record<string, any>> }}
3355
3374
  */
3356
3375
  function inspectConsumerCi(consumer, options = {}) {
3357
3376
  const diagnostics = [];
3358
3377
  const expectedWorkflow = consumer.workflow || expectedConsumerWorkflowName(consumer.name);
3378
+ const expectedJobs = expectedConsumerWorkflowJobs(consumer.name);
3359
3379
  if (!consumer.root || !fs.existsSync(consumer.root)) {
3360
3380
  return {
3361
3381
  checked: false,
3362
3382
  ok: null,
3363
3383
  expectedWorkflow,
3384
+ expectedJobs,
3364
3385
  headSha: null,
3365
3386
  run: null,
3366
3387
  diagnostics: []
@@ -3388,6 +3409,7 @@ function inspectConsumerCi(consumer, options = {}) {
3388
3409
  checked: true,
3389
3410
  ok: false,
3390
3411
  expectedWorkflow,
3412
+ expectedJobs,
3391
3413
  headSha,
3392
3414
  run: null,
3393
3415
  diagnostics
@@ -3424,6 +3446,7 @@ function inspectConsumerCi(consumer, options = {}) {
3424
3446
  checked: true,
3425
3447
  ok: false,
3426
3448
  expectedWorkflow,
3449
+ expectedJobs,
3427
3450
  headSha,
3428
3451
  run: null,
3429
3452
  diagnostics
@@ -3454,6 +3477,7 @@ function inspectConsumerCi(consumer, options = {}) {
3454
3477
  checked: true,
3455
3478
  ok: false,
3456
3479
  expectedWorkflow,
3480
+ expectedJobs,
3457
3481
  headSha,
3458
3482
  run: null,
3459
3483
  diagnostics
@@ -3477,17 +3501,105 @@ function inspectConsumerCi(consumer, options = {}) {
3477
3501
  suggestedFix: "Wait for or fix the consumer verification workflow, then rerun release status."
3478
3502
  });
3479
3503
  }
3504
+ if (expectedJobs.length > 0 && run.databaseId) {
3505
+ const jobResult = inspectConsumerWorkflowJobs(consumer, run.databaseId, expectedJobs, options);
3506
+ if (jobResult.jobs) {
3507
+ run.jobs = jobResult.jobs;
3508
+ }
3509
+ diagnostics.push(...jobResult.diagnostics);
3510
+ } else if (expectedJobs.length > 0) {
3511
+ diagnostics.push({
3512
+ code: "release_consumer_ci_jobs_unavailable",
3513
+ severity: options.strict ? "error" : "warning",
3514
+ message: `${consumer.name} ${expectedWorkflow} run did not include a database id, so expected jobs could not be inspected.`,
3515
+ path: run.url || `attebury/${consumer.name}`,
3516
+ suggestedFix: "Rerun release status after GitHub exposes the workflow run id."
3517
+ });
3518
+ }
3519
+ const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
3480
3520
  return {
3481
3521
  checked: true,
3482
- ok: diagnostics.filter((diagnostic) => diagnostic.severity === "error").length === 0 &&
3522
+ ok: errorCount === 0 &&
3483
3523
  (!options.strict || (run.status === "completed" && run.conclusion === "success" && (!headSha || !run.headSha || run.headSha === headSha))),
3484
3524
  expectedWorkflow,
3525
+ expectedJobs,
3485
3526
  headSha,
3486
3527
  run,
3487
3528
  diagnostics
3488
3529
  };
3489
3530
  }
3490
3531
 
3532
+ /**
3533
+ * @param {{ name: string, root?: string|null }} consumer
3534
+ * @param {number|string} runId
3535
+ * @param {string[]} expectedJobs
3536
+ * @param {{ strict?: boolean }} [options]
3537
+ * @returns {{ jobs: Array<Record<string, any>>|null, diagnostics: Array<Record<string, any>> }}
3538
+ */
3539
+ function inspectConsumerWorkflowJobs(consumer, runId, expectedJobs, options = {}) {
3540
+ const diagnostics = [];
3541
+ const result = childProcess.spawnSync("gh", [
3542
+ "run",
3543
+ "view",
3544
+ String(runId),
3545
+ "--repo",
3546
+ `attebury/${consumer.name}`,
3547
+ "--json",
3548
+ "jobs"
3549
+ ], {
3550
+ cwd: consumer.root || process.cwd(),
3551
+ encoding: "utf8",
3552
+ env: { ...process.env, PATH: process.env.PATH || "" }
3553
+ });
3554
+ if (result.status !== 0) {
3555
+ diagnostics.push(commandDiagnostic({
3556
+ code: "release_consumer_ci_jobs_unavailable",
3557
+ severity: options.strict ? "error" : "warning",
3558
+ message: `Could not inspect expected jobs for ${consumer.name}.`,
3559
+ path: `attebury/${consumer.name}`,
3560
+ suggestedFix: "Check GitHub CLI auth/network access, then rerun release status.",
3561
+ result
3562
+ }));
3563
+ return { jobs: null, diagnostics };
3564
+ }
3565
+ let payload = {};
3566
+ try {
3567
+ payload = JSON.parse(String(result.stdout || "{}"));
3568
+ } catch (error) {
3569
+ diagnostics.push({
3570
+ code: "release_consumer_ci_jobs_unreadable",
3571
+ severity: options.strict ? "error" : "warning",
3572
+ message: `Could not parse ${consumer.name} workflow job status: ${messageFromError(error)}`,
3573
+ path: `attebury/${consumer.name}`,
3574
+ suggestedFix: "Rerun release status after GitHub CLI output is valid JSON."
3575
+ });
3576
+ }
3577
+ const jobs = Array.isArray(payload.jobs) ? payload.jobs : [];
3578
+ for (const expectedJob of expectedJobs) {
3579
+ const job = jobs.find((candidate) => candidate?.name === expectedJob);
3580
+ if (!job) {
3581
+ diagnostics.push({
3582
+ code: "release_consumer_ci_job_missing",
3583
+ severity: options.strict ? "error" : "warning",
3584
+ message: `${consumer.name} workflow is missing expected job '${expectedJob}'.`,
3585
+ path: `attebury/${consumer.name}`,
3586
+ suggestedFix: "Update the consumer workflow or the release-status expected job list, then rerun release status."
3587
+ });
3588
+ continue;
3589
+ }
3590
+ if (job.status !== "completed" || job.conclusion !== "success") {
3591
+ diagnostics.push({
3592
+ code: "release_consumer_ci_job_not_successful",
3593
+ severity: options.strict ? "error" : "warning",
3594
+ message: `${consumer.name} job '${expectedJob}' is ${job.status || "unknown"}/${job.conclusion || "unknown"}.`,
3595
+ path: job.url || `attebury/${consumer.name}`,
3596
+ suggestedFix: "Wait for or fix the expected workflow job, then rerun release status."
3597
+ });
3598
+ }
3599
+ }
3600
+ return { jobs, diagnostics };
3601
+ }
3602
+
3491
3603
  /**
3492
3604
  * @param {string} cwd
3493
3605
  * @returns {Array<{ name: string, root: string|null, path: string, version: string|null, found: boolean }>}
@@ -182,7 +182,8 @@ function buildContractsForContext(context) {
182
182
  }
183
183
  if (surface === "native" || surface === "ios_surface" || surface === "android_surface") {
184
184
  return {
185
- uiSurface: generateUiSurfaceContract(context.graph, { ...(context.options || {}), projectionId })
185
+ uiSurface: generateUiSurfaceContract(context.graph, { ...(context.options || {}), projectionId }),
186
+ api: generateApiContractGraph(context.graph, {})
186
187
  };
187
188
  }
188
189
  return {};
@@ -1,4 +1,5 @@
1
1
  import {
2
+ generateNativeBundle,
2
3
  generateServerBundle,
3
4
  generateWebBundle,
4
5
  getDefaultEnvironmentProjections,
@@ -49,6 +50,12 @@ function buildCompileCheckPlan(graph, options = {}) {
49
50
  command: "npm run build"
50
51
  }
51
52
  ]);
53
+ const nativeChecks = topology.nativeRuntimes.map((component, index) => ({
54
+ id: index === 0 ? "native_swift_build" : `native_swift_build_${component.id}`,
55
+ cwd: topology.nativeDir(component),
56
+ install: null,
57
+ command: "swift build"
58
+ }));
52
59
  return {
53
60
  type: "compile_check_plan",
54
61
  name: compileCheckName(graph, options),
@@ -65,7 +72,7 @@ function buildCompileCheckPlan(graph, options = {}) {
65
72
  generator: runtime.generator
66
73
  }))
67
74
  },
68
- checks: [...apiChecks, ...webChecks]
75
+ checks: [...apiChecks, ...webChecks, ...nativeChecks]
69
76
  };
70
77
  }
71
78
 
@@ -92,13 +99,14 @@ ${runtimeReference.environment.envExample || ""}
92
99
  function renderCompileCheckReadme(graph, options = {}) {
93
100
  return `# ${compileCheckName(graph, options).replace("Plan", "Bundle")}
94
101
 
95
- This bundle verifies that the generated server and web projects typecheck and build.
102
+ This bundle verifies that generated server, web, and native projects compile.
96
103
 
97
104
  ## Checks
98
105
 
99
106
  - server TypeScript check
100
107
  - web TypeScript check
101
108
  - web production build
109
+ - native Swift build
102
110
 
103
111
  ## Usage
104
112
 
@@ -124,15 +132,17 @@ function renderCompileCheckScript(plan) {
124
132
  ""
125
133
  ];
126
134
  if (plan.checks.length === 0) {
127
- lines.push('echo "No API or web runtimes are configured; compile check is a no-op."');
135
+ lines.push('echo "No API, web, or native runtimes are configured; compile check is a no-op."');
128
136
  }
129
137
  for (const check of plan.checks) {
130
138
  const label = check.id.includes("web")
131
139
  ? check.id.includes("build") ? "Building generated web" : "Checking generated web"
132
- : "Checking generated server";
140
+ : check.id.includes("native") ? "Building generated native app" : "Checking generated server";
133
141
  lines.push(`echo "${label} (${check.cwd})..."`);
134
- lines.push(`echo "Installing dependencies (${check.cwd})..."`);
135
- lines.push(`(cd "$ROOT_DIR/${check.cwd}" && ${check.install})`);
142
+ if (check.install) {
143
+ lines.push(`echo "Installing dependencies (${check.cwd})..."`);
144
+ lines.push(`(cd "$ROOT_DIR/${check.cwd}" && ${check.install})`);
145
+ }
136
146
  lines.push(`echo "Running ${check.command} (${check.cwd})..."`);
137
147
  lines.push(`(cd "$ROOT_DIR/${check.cwd}" && ${check.command})`);
138
148
  lines.push("");
@@ -158,6 +168,10 @@ export function generateCompileCheckBundle(graph, options = {}) {
158
168
  const webBundle = generateWebBundle(graph, component.projection.id, { ...options, component });
159
169
  mergeBundleFiles(files, topology.webDir(component), webBundle);
160
170
  }
171
+ for (const component of topology.nativeRuntimes) {
172
+ const nativeBundle = generateNativeBundle(graph, component.projection.id, { ...options, component });
173
+ mergeBundleFiles(files, topology.nativeDir(component), nativeBundle);
174
+ }
161
175
  return files;
162
176
  }
163
177
 
@@ -2,6 +2,7 @@ import { generateDbLifecyclePlan } from "../surfaces/databases/lifecycle-shared.
2
2
  import { getExampleImplementation } from "../../example-implementation.js";
3
3
  import {
4
4
  generateDbBundle,
5
+ generateNativeBundle,
5
6
  generateServerBundle,
6
7
  generateWebBundle,
7
8
  dbEnvVarsForComponent,
@@ -110,6 +111,7 @@ function buildEnvironmentPlan(graph, options = {}) {
110
111
  server: topology.primaryApi ? topology.serviceDir(topology.primaryApi) : null,
111
112
  web: topology.primaryWeb ? topology.webDir(topology.primaryWeb) : null,
112
113
  db: topology.primaryDb ? topology.dbDir(topology.primaryDb) : null,
114
+ native: topology.nativeRuntimes[0] ? topology.nativeDir(topology.nativeRuntimes[0]) : null,
113
115
  scripts: "scripts"
114
116
  },
115
117
  runtimes: {
@@ -130,6 +132,12 @@ function buildEnvironmentPlan(graph, options = {}) {
130
132
  dir: topology.webDir(component),
131
133
  uses_api: component.api
132
134
  })),
135
+ natives: topology.nativeRuntimes.map((component) => ({
136
+ id: component.id,
137
+ projection: component.projection.id,
138
+ dir: topology.nativeDir(component),
139
+ uses_api: component.api
140
+ })),
133
141
  databases: topology.dbRuntimes.map((component) => ({
134
142
  id: component.id,
135
143
  projection: component.projection.id,
@@ -273,6 +281,7 @@ function renderEnvironmentReadme(plan) {
273
281
  const hasDb = plan.runtimes.databases.length > 0;
274
282
  const hasApi = plan.runtimes.apis.length > 0;
275
283
  const hasWeb = plan.runtimes.webs.length > 0;
284
+ const hasNative = plan.runtimes.natives.length > 0;
276
285
  const localProcessNotes = !hasDb
277
286
  ? "- This bundle has no generated database surface."
278
287
  : plan.projections.db.engine === "sqlite"
@@ -294,7 +303,7 @@ ${localProcessNotes}
294
303
 
295
304
  This bundle packages the generated runtime into one local environment:
296
305
 
297
- ${hasApi ? "- `services/<api-id>/`: generated API service scaffolds\n" : ""}${hasWeb ? `- \`web/<web-id>/\`: generated ${plan.runtimeProfiles.web === "react" ? "Vite + React Router" : plan.runtimeProfiles.web === "vanilla" ? "vanilla HTML/CSS/JS" : "SvelteKit"} web scaffolds\n` : ""}${hasDb ? "- `db/<db-id>/`: generated DB lifecycle bundles\n" : ""}${plan.files.dockerCompose ? `- \`${plan.files.dockerCompose}\`: local Postgres container` : hasDb ? (plan.projections.db.engine === "sqlite" ? "- local SQLite file orchestration (no Docker files generated)" : "- local-process Postgres orchestration (no Docker files generated)") : "- no DB orchestration is generated"}
306
+ ${hasApi ? "- `services/<api-id>/`: generated API service scaffolds\n" : ""}${hasWeb ? `- \`web/<web-id>/\`: generated ${plan.runtimeProfiles.web === "react" ? "Vite + React Router" : plan.runtimeProfiles.web === "vanilla" ? "vanilla HTML/CSS/JS" : "SvelteKit"} web scaffolds\n` : ""}${hasNative ? "- `native/<native-id>/`: generated native app scaffolds\n" : ""}${hasDb ? "- `db/<db-id>/`: generated DB lifecycle bundles\n" : ""}${plan.files.dockerCompose ? `- \`${plan.files.dockerCompose}\`: local Postgres container` : hasDb ? (plan.projections.db.engine === "sqlite" ? "- local SQLite file orchestration (no Docker files generated)" : "- local-process Postgres orchestration (no Docker files generated)") : "- no DB orchestration is generated"}
298
307
 
299
308
  ## Quick Start
300
309
 
@@ -318,6 +327,7 @@ ${dockerSection}
318
327
 
319
328
  - ${hasApi && hasDb ? `The generated server expects ${plan.projections.db.engine === "sqlite" ? "SQLite plus Prisma." : "Postgres plus Prisma."}` : hasApi ? "The generated server is stateless." : "No server surface is generated."}
320
329
  - ${hasWeb && hasApi ? "The generated web app talks to `PUBLIC_TOPOGRAM_API_BASE_URL`." : hasWeb ? "The generated web app is standalone." : "No web surface is generated."}
330
+ - ${hasNative ? "Native app scaffolds use the same UI surface contracts as web surfaces." : "No native surface is generated."}
321
331
  - If \`.env\` is missing, generated scripts fall back to \`.env.example\`.
322
332
  - The DB lifecycle scripts remain the source of truth for greenfield bootstrap and brownfield migration.
323
333
  `;
@@ -635,6 +645,12 @@ export function generateEnvironmentBundle(graph, options = {}) {
635
645
  [topology.webDir(component)]: webBundle
636
646
  });
637
647
  }
648
+ for (const component of topology.nativeRuntimes) {
649
+ const nativeBundle = generateNativeBundle(graph, component.projection.id, { ...options, component });
650
+ mergeNamedBundles(files, {
651
+ [topology.nativeDir(component)]: nativeBundle
652
+ });
653
+ }
638
654
  for (const component of topology.dbRuntimes) {
639
655
  const dbBundle = generateDbBundle(graph, component.projection.id, { ...options, component });
640
656
  mergeNamedBundles(files, {
@@ -43,16 +43,19 @@ import { defaultProjectConfigForGraph, validateProjectConfig } from "../../proje
43
43
  * @property {RuntimeComponent[]} runtimes
44
44
  * @property {RuntimeComponent[]} apiRuntimes
45
45
  * @property {RuntimeComponent[]} webRuntimes
46
+ * @property {RuntimeComponent[]} nativeRuntimes
46
47
  * @property {RuntimeComponent[]} dbRuntimes
47
48
  * @property {RuntimeComponent[]} components Legacy alias for runtimes.
48
49
  * @property {RuntimeComponent[]} apiComponents Legacy alias for apiRuntimes.
49
50
  * @property {RuntimeComponent[]} webComponents Legacy alias for webRuntimes.
51
+ * @property {RuntimeComponent[]} nativeComponents Legacy alias for nativeRuntimes.
50
52
  * @property {RuntimeComponent[]} dbComponents Legacy alias for dbRuntimes.
51
53
  * @property {RuntimeComponent|null} primaryApi
52
54
  * @property {RuntimeComponent|null} primaryWeb
53
55
  * @property {RuntimeComponent|null} primaryDb
54
56
  * @property {(component: RuntimeComponent) => string} serviceDir
55
57
  * @property {(component: RuntimeComponent) => string} webDir
58
+ * @property {(component: RuntimeComponent) => string} nativeDir
56
59
  * @property {(component: RuntimeComponent) => string} dbDir
57
60
  */
58
61
 
@@ -371,6 +374,29 @@ export function generateWebBundle(graph, projectionId, options = {}) {
371
374
  }).files;
372
375
  }
373
376
 
377
+ /**
378
+ * @param {ResolvedGraph} graph
379
+ * @param {string} projectionId
380
+ * @param {RuntimeGenerationOptions} [options]
381
+ * @returns {any}
382
+ */
383
+ export function generateNativeBundle(graph, projectionId, options = {}) {
384
+ const topology = resolveRuntimeTopology(graph, options);
385
+ const runtime = options.runtime || options.component || topology.nativeRuntimes.find((entry) => entry.projection.id === projectionId);
386
+ if (!runtime) {
387
+ throw new Error(`No native runtime found for projection '${projectionId}'`);
388
+ }
389
+ return generateWithComponentGenerator({
390
+ graph,
391
+ projection: runtime.projection,
392
+ runtime,
393
+ component: runtime,
394
+ topology,
395
+ implementation: options.implementation || null,
396
+ options: { ...options, projectionId }
397
+ }).files;
398
+ }
399
+
374
400
  /**
375
401
  * @param {ResolvedGraph} graph
376
402
  * @param {string} projectionId
@@ -450,7 +476,7 @@ function decorateRuntimes(graph, config) {
450
476
  runtime.databaseRuntime = byId.get(runtime.database) || null;
451
477
  runtime.databaseComponent = runtime.databaseRuntime;
452
478
  }
453
- if (runtime.kind === "web_surface" && runtime.api) {
479
+ if (["web_surface", "ios_surface", "android_surface"].includes(runtime.kind) && runtime.api) {
454
480
  runtime.apiRuntime = byId.get(runtime.api) || null;
455
481
  runtime.apiComponent = runtime.apiRuntime;
456
482
  }
@@ -475,6 +501,7 @@ export function resolveRuntimeTopology(graph, options = {}) {
475
501
  const runtimes = decorateRuntimes(graph, config);
476
502
  const apiRuntimes = runtimes.filter((runtime) => runtime.kind === "api_service");
477
503
  const webRuntimes = runtimes.filter((runtime) => runtime.kind === "web_surface");
504
+ const nativeRuntimes = runtimes.filter((runtime) => runtime.kind === "ios_surface" || runtime.kind === "android_surface");
478
505
  const dbRuntimes = runtimes.filter((runtime) => runtime.kind === "database");
479
506
  const primaryApi = apiRuntimes[0] || null;
480
507
  const primaryWeb = webRuntimes[0] || null;
@@ -486,9 +513,11 @@ export function resolveRuntimeTopology(graph, options = {}) {
486
513
  components: runtimes,
487
514
  apiRuntimes,
488
515
  webRuntimes,
516
+ nativeRuntimes,
489
517
  dbRuntimes,
490
518
  apiComponents: apiRuntimes,
491
519
  webComponents: webRuntimes,
520
+ nativeComponents: nativeRuntimes,
492
521
  dbComponents: dbRuntimes,
493
522
  primaryApi,
494
523
  primaryWeb,
@@ -499,6 +528,9 @@ export function resolveRuntimeTopology(graph, options = {}) {
499
528
  webDir(component) {
500
529
  return `web/${component.id}`;
501
530
  },
531
+ nativeDir(component) {
532
+ return `native/${component.id}`;
533
+ },
502
534
  dbDir(component) {
503
535
  return `db/${component.id}`;
504
536
  }