rastack 0.0.62 → 0.0.64

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/CHANGELOG.md +14 -0
  2. package/admin/AdminApp.tsx +56 -3
  3. package/admin/index.tsx +8 -2
  4. package/admin/ui/icons.tsx +9 -0
  5. package/admin/ui/styles.ts +3 -0
  6. package/admin/useAdmin.ts +41 -4
  7. package/auth/provider.ts +71 -8
  8. package/dist/admin.js +17 -14
  9. package/dist/compile/analyze.js +11 -1
  10. package/dist/define/index.d.ts +8 -1
  11. package/dist/define/index.js +2 -2
  12. package/dist/define/manifest.js +7 -1
  13. package/dist/deploy/ci.d.ts +10 -3
  14. package/dist/deploy/ci.js +22 -11
  15. package/dist/deploy/config.d.ts +155 -5
  16. package/dist/deploy/config.js +216 -6
  17. package/dist/deploy/io.d.ts +57 -0
  18. package/dist/deploy/io.js +141 -0
  19. package/dist/deploy/workflows.d.ts +30 -0
  20. package/dist/deploy/workflows.js +117 -12
  21. package/dist/rastack-bootstrap.js +18 -47
  22. package/dist/rastack-ci.js +19 -1
  23. package/dist/rastack-dev.js +48 -2
  24. package/dist/rastack-init.d.ts +6 -3
  25. package/dist/rastack-init.js +231 -92
  26. package/dist/rastack.js +1 -0
  27. package/dist/templates/cloudformation/app.yaml +133 -2
  28. package/dist/wasm/rastack_wasm.js +1 -1
  29. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  30. package/package.json +1 -1
  31. package/provider/provider.tsx +23 -0
  32. package/provider/types.ts +9 -0
  33. package/src/compile/analyze.ts +11 -1
  34. package/src/define/index.ts +5 -1
  35. package/src/define/manifest.ts +7 -1
  36. package/src/deploy/ci.ts +50 -16
  37. package/src/deploy/config.ts +340 -7
  38. package/src/deploy/io.ts +179 -2
  39. package/src/deploy/workflows.ts +122 -12
  40. package/src/rastack-bootstrap.ts +24 -69
  41. package/src/rastack-ci.ts +20 -2
  42. package/src/rastack-dev.ts +61 -6
  43. package/src/rastack-init.ts +283 -115
  44. package/src/rastack.ts +1 -0
@@ -34,7 +34,44 @@ Parameters:
34
34
  GitHubBranch:
35
35
  Type: String
36
36
  Default: master
37
- Description: Branch of GitHubRepo allowed to deploy.
37
+ Description: Branch of GitHubRepo allowed to deploy (used when GitHubEnvironment is blank).
38
+
39
+ GitHubEnvironment:
40
+ Type: String
41
+ Default: ""
42
+ Description: >
43
+ GitHub Actions **Environment** (e.g. development / production) this stack's
44
+ deploy role trusts. When set, the OIDC trust is scoped to
45
+ `…:environment:<name>` — the subject a job that declares `environment:` gets
46
+ — instead of a branch ref, so the dev and prod deploys can share a branch
47
+ set yet only assume their own environment's role. Blank keeps the legacy
48
+ branch-ref trust (`…:ref:refs/heads/<GitHubBranch>`). `rastack init` sets it
49
+ to development / production for the dev / prod stacks.
50
+
51
+ CallbackUrls:
52
+ Type: CommaDelimitedList
53
+ Default: "http://localhost:4321"
54
+ Description: >
55
+ Allowed OAuth redirect (callback) URLs for the Hosted UI app client. The
56
+ local `rastack dev` origin is the default so a developer can sign in against
57
+ this environment's real Cognito pool while developing; add your deployed
58
+ frontend origin(s) too.
59
+
60
+ LogoutUrls:
61
+ Type: CommaDelimitedList
62
+ Default: "http://localhost:4321"
63
+ Description: Allowed sign-out redirect URLs for the Hosted UI app client.
64
+
65
+ ExternalApiUpstream:
66
+ Type: String
67
+ Default: ""
68
+ Description: >
69
+ Base URL (no trailing slash) the **external-API proxy** forwards to —
70
+ typically a UAT or prod backend. When set, the stack adds a
71
+ Cognito-authorized `ANY /proxy/{proxy+}` route to the HTTP API that forwards
72
+ to `<upstream>/{proxy}`, so a locally-running app (signed in against this
73
+ pool) reaches external APIs only through the proxy, never directly. Blank
74
+ omits the proxy route + authorizer entirely.
38
75
 
39
76
  ArtifactsBucket:
40
77
  Type: String
@@ -87,6 +124,10 @@ Conditions:
87
124
  DeriveOIDCArn: !Equals [!Ref OIDCProviderArn, ""]
88
125
  ShouldTrailDataEvents: !Equals [!Ref EnableDataEventTrail, "true"]
89
126
  UseAdvancedSecurity: !Not [!Equals [!Ref AdvancedSecurity, "OFF"]]
127
+ # Scope the deploy-role trust to a GitHub Actions Environment when one is given.
128
+ TrustEnvironment: !Not [!Equals [!Ref GitHubEnvironment, ""]]
129
+ # Provision the external-API proxy route only when an upstream is configured.
130
+ HasProxy: !Not [!Equals [!Ref ExternalApiUpstream, ""]]
90
131
 
91
132
  Resources:
92
133
 
@@ -290,6 +331,20 @@ Resources:
290
331
  - ALLOW_USER_SRP_AUTH
291
332
  - ALLOW_REFRESH_TOKEN_AUTH
292
333
  PreventUserExistenceErrors: ENABLED
334
+ # Hosted UI (OAuth 2.0 Authorization Code + PKCE) — the flow rastack/auth's
335
+ # <CognitoAuthProvider> drives. Enabling it here lets a locally-running app
336
+ # (`rastack dev`) sign in against this environment's real Cognito pool.
337
+ SupportedIdentityProviders:
338
+ - COGNITO
339
+ AllowedOAuthFlowsUserPoolClient: true
340
+ AllowedOAuthFlows:
341
+ - code
342
+ AllowedOAuthScopes:
343
+ - openid
344
+ - email
345
+ - profile
346
+ CallbackURLs: !Ref CallbackUrls
347
+ LogoutURLs: !Ref LogoutUrls
293
348
  TokenValidityUnits:
294
349
  AccessToken: minutes
295
350
  IdToken: minutes
@@ -299,6 +354,15 @@ Resources:
299
354
  RefreshTokenValidity: 30
300
355
  EnableTokenRevocation: true
301
356
 
357
+ # Hosted UI domain — the browser sign-in surface. Prefix is globally unique via
358
+ # the account id. Both the deployed frontend and local `rastack dev` redirect
359
+ # here to authenticate against this environment's pool.
360
+ UserPoolDomain:
361
+ Type: AWS::Cognito::UserPoolDomain
362
+ Properties:
363
+ Domain: !Sub "${NamePrefix}-${AppName}-${AWS::AccountId}"
364
+ UserPoolId: !Ref UserPool
365
+
302
366
  IdentityPool:
303
367
  Type: AWS::Cognito::IdentityPool
304
368
  Properties:
@@ -494,6 +558,49 @@ Resources:
494
558
  Principal: apigateway.amazonaws.com
495
559
  SourceArn: !Sub "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpApi}/*/*"
496
560
 
561
+ # -- External-API proxy (optional) --------------------------------------------
562
+ # When ExternalApiUpstream is set, a Cognito-authorized `ANY /proxy/{proxy+}`
563
+ # route forwards to `<upstream>/{proxy}`. HTTP API prefers the more specific
564
+ # route, so `/proxy/*` goes here and everything else still hits the Rust Lambda
565
+ # via `$default`. The JWT authorizer means only callers holding a valid token
566
+ # from THIS environment's pool can use the proxy — so a local dev app signs in
567
+ # against the dev pool, then reaches UAT/prod externals only through here.
568
+ ProxyJwtAuthorizer:
569
+ Type: AWS::ApiGatewayV2::Authorizer
570
+ Condition: HasProxy
571
+ Properties:
572
+ ApiId: !Ref HttpApi
573
+ AuthorizerType: JWT
574
+ Name: !Sub "${NamePrefix}-${AppName}-cognito"
575
+ IdentitySource:
576
+ - "$request.header.Authorization"
577
+ JwtConfiguration:
578
+ Audience:
579
+ - !Ref UserPoolClient
580
+ Issuer: !Sub "https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPool}"
581
+
582
+ ProxyIntegration:
583
+ Type: AWS::ApiGatewayV2::Integration
584
+ Condition: HasProxy
585
+ Properties:
586
+ ApiId: !Ref HttpApi
587
+ IntegrationType: HTTP_PROXY
588
+ IntegrationMethod: ANY
589
+ # `{proxy}` is the greedy path variable captured by the route; the caller's
590
+ # Authorization header is forwarded so the upstream can authorize too.
591
+ IntegrationUri: !Sub "${ExternalApiUpstream}/{proxy}"
592
+ PayloadFormatVersion: "1.0"
593
+
594
+ ProxyRoute:
595
+ Type: AWS::ApiGatewayV2::Route
596
+ Condition: HasProxy
597
+ Properties:
598
+ ApiId: !Ref HttpApi
599
+ RouteKey: "ANY /proxy/{proxy+}"
600
+ Target: !Sub "integrations/${ProxyIntegration}"
601
+ AuthorizationType: JWT
602
+ AuthorizerId: !Ref ProxyJwtAuthorizer
603
+
497
604
  # -- Deploy role (GitHub OIDC) — code only ------------------------------------
498
605
  # CI assumes this to ship compiled code. It can UpdateFunctionCode on THIS one
499
606
  # function and read this app's SSM params — nothing else — with an explicit
@@ -518,7 +625,13 @@ Resources:
518
625
  StringEquals:
519
626
  token.actions.githubusercontent.com:aud: sts.amazonaws.com
520
627
  StringLike:
521
- token.actions.githubusercontent.com:sub: !Sub "repo:${GitHubOwner}/${GitHubRepo}:ref:refs/heads/${GitHubBranch}"
628
+ # An environment-gated GitHub job's OIDC subject is
629
+ # `repo:O/R:environment:<env>`, not `…:ref:refs/heads/<branch>`,
630
+ # so the trust must match the environment when one is set.
631
+ token.actions.githubusercontent.com:sub: !If
632
+ - TrustEnvironment
633
+ - !Sub "repo:${GitHubOwner}/${GitHubRepo}:environment:${GitHubEnvironment}"
634
+ - !Sub "repo:${GitHubOwner}/${GitHubRepo}:ref:refs/heads/${GitHubBranch}"
522
635
  Policies:
523
636
  - PolicyName: update-function-code-only
524
637
  PolicyDocument:
@@ -602,6 +715,14 @@ Resources:
602
715
  Type: String
603
716
  Value: !Sub "https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPool}/.well-known/jwks.json"
604
717
 
718
+ ParamHostedUiUrl:
719
+ Type: AWS::SSM::Parameter
720
+ DependsOn: UserPoolDomain
721
+ Properties:
722
+ Name: !Sub "/rastack/${AppName}/hosted-ui-url"
723
+ Type: String
724
+ Value: !Sub "https://${NamePrefix}-${AppName}-${AWS::AccountId}.auth.${AWS::Region}.amazoncognito.com"
725
+
605
726
  Outputs:
606
727
  ApiUrl:
607
728
  Description: Base URL of the deployed API (set as the frontend's remote base).
@@ -627,9 +748,19 @@ Outputs:
627
748
  JwksUrl:
628
749
  Description: JWKS to bake into the Lambda bundle at build time.
629
750
  Value: !Sub "https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPool}/.well-known/jwks.json"
751
+ UserPoolDomain:
752
+ Description: Cognito Hosted UI domain prefix (scheme-less).
753
+ Value: !Ref UserPoolDomain
754
+ HostedUiUrl:
755
+ Description: Cognito Hosted UI base URL — browser sign-in / `rastack dev` auth.
756
+ Value: !Sub "https://${NamePrefix}-${AppName}-${AWS::AccountId}.auth.${AWS::Region}.amazoncognito.com"
630
757
  WarehouseBucketName:
631
758
  Description: S3 Iceberg warehouse — the system of record.
632
759
  Value: !Ref WarehouseBucket
633
760
  AuditLogBucketName:
634
761
  Description: S3 access logs + CloudTrail data events for the warehouse.
635
762
  Value: !Ref AuditLogBucket
763
+ ProxyUrl:
764
+ Condition: HasProxy
765
+ Description: Base URL of the Cognito-authorized external-API proxy (when enabled).
766
+ Value: !Sub "https://${HttpApi}.execute-api.${AWS::Region}.amazonaws.com/proxy"
@@ -375,7 +375,7 @@ function __wbg_get_imports() {
375
375
  return ret;
376
376
  },
377
377
  __wbindgen_cast_0000000000000001: function(arg0, arg1) {
378
- // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 88, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
378
+ // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 90, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
379
379
  const ret = makeMutClosure(arg0, arg1, wasm_bindgen_ac0f9960a408a037___convert__closures_____invoke___wasm_bindgen_ac0f9960a408a037___JsValue__core_9b3796e30d99ddb7___result__Result_____wasm_bindgen_ac0f9960a408a037___JsError___true_);
380
380
  return ret;
381
381
  },
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rastack",
3
- "version": "0.0.62",
3
+ "version": "0.0.64",
4
4
  "description": "",
5
5
  "main": "runtime.ts",
6
6
  "types": "runtime.ts",
@@ -170,6 +170,11 @@ export function RAStackProvider({
170
170
  const existing = await persistence.load();
171
171
  if (existing) {
172
172
  await engine.importWarehouse(existing);
173
+ } else if (config.seedWarehouse) {
174
+ // Seed from a bundled warehouse blob (exportWarehouse format) rather
175
+ // than loose Iceberg files.
176
+ const seed = await config.seedWarehouse();
177
+ if (seed) await engine.importWarehouse(seed);
173
178
  } else {
174
179
  const base = config.warehouseBaseUrl ?? "/data/warehouse/";
175
180
  const resources = manifestResources(manifest);
@@ -208,6 +213,24 @@ export function RAStackProvider({
208
213
  }, 400);
209
214
  persistRef.current = persist;
210
215
 
216
+ // Persist at the *engine* level, not just in the axios adapter. Consumers
217
+ // that talk to the engine directly (the admin console calls
218
+ // `engine.handle("DELETE", …)` straight, bypassing the generated hooks /
219
+ // axios seam) must still have their writes saved to IndexedDB — otherwise
220
+ // every reload re-seeds from the committed warehouse and silently
221
+ // resurrects deleted rows. Wrapping `handle` here makes persistence fire
222
+ // for both paths (the adapter's `onWrite` below then coalesces into the
223
+ // same debounce, so an app-hook write persists exactly once).
224
+ const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
225
+ const rawHandle = engine.handle.bind(engine);
226
+ engine.handle = ((method: string, path: string, query?: any, body?: any) =>
227
+ Promise.resolve(rawHandle(method, path, query, body)).then((res: any) => {
228
+ if (MUTATING.has(String(method).toUpperCase()) && res && res.status < 300) {
229
+ persist();
230
+ }
231
+ return res;
232
+ })) as typeof engine.handle;
233
+
211
234
  engineRef.current = engine;
212
235
  configureApi({
213
236
  baseURL: "",
package/provider/types.ts CHANGED
@@ -152,6 +152,15 @@ export interface RAStackProviderConfig {
152
152
  fileSource?: import("../cache").WarehouseFileSource;
153
153
  /** `s3` mode: how to load/save the warehouse blob from object storage. */
154
154
  warehouse?: WarehouseSource;
155
+ /**
156
+ * Seed `local` mode from a **bundled warehouse blob** — the format
157
+ * `exportWarehouse()` produces and `importWarehouse()` consumes — instead of
158
+ * walking loose Iceberg files under `warehouseBaseUrl`. Use this when the app
159
+ * commits a single warehouse blob asset (e.g. bundled JSON) rather than a
160
+ * directory of parquet files. Takes precedence over `warehouseBaseUrl` /
161
+ * `fileSource` seeding, and is ignored once a persisted local copy exists.
162
+ */
163
+ seedWarehouse?: () => Uint8Array | null | Promise<Uint8Array | null>;
155
164
  /**
156
165
  * Override how the WASM module is loaded. Supply this from app code (where
157
166
  * your bundler can resolve the generated bundle) if the default loader can't
@@ -179,7 +179,17 @@ function analyzeField(
179
179
  const app = literalTypeProp(fieldType, "__app", checker, initializer);
180
180
  const model = literalTypeProp(fieldType, "__model", checker, initializer);
181
181
  if (app && model) {
182
- return { name, type: "fk", relation: { app, model } };
182
+ const fk: FieldModel = { name, type: "fk", relation: { app, model } };
183
+ // `s.ref(() => X, { null: true })` — a nullable relation (a shared secret
184
+ // with no project, an unassigned task). The options object is the ref
185
+ // call's second argument, read the same way scalar constraints are.
186
+ if (ts.isCallExpression(initializer)) {
187
+ const opts = literalToValue(initializer.arguments[1]);
188
+ if (opts && typeof opts === "object" && opts.null === true) {
189
+ fk.null = true;
190
+ }
191
+ }
192
+ return fk;
183
193
  }
184
194
  return undefined;
185
195
  }
@@ -66,6 +66,9 @@ export interface Ref<App extends string, Model extends string> {
66
66
  readonly __model: Model;
67
67
  /** The authored `() => Resource` thunk — carried at runtime. */
68
68
  readonly target?: () => ResourceType<App, Model, any>;
69
+ /** Optional FK constraints — `null: true` makes the relation nullable
70
+ * (e.g. a shared secret with no project, or an unassigned task). */
71
+ readonly options?: { null?: boolean };
69
72
  }
70
73
 
71
74
  export interface StringOptions {
@@ -153,8 +156,9 @@ export const s = {
153
156
  */
154
157
  ref<A extends string, M extends string>(
155
158
  thunk: () => ResourceType<A, M, any>,
159
+ options?: { null?: boolean },
156
160
  ): Ref<A, M> {
157
- return { __rastackRef: true, target: thunk } as Ref<A, M>;
161
+ return { __rastackRef: true, target: thunk, options } as Ref<A, M>;
158
162
  },
159
163
  };
160
164
 
@@ -121,11 +121,17 @@ function runtimeField(
121
121
  `manifestFromResources: ${res.app}.${res.model}.${name} is an s.ref(...) whose target thunk did not return a resource() value`,
122
122
  );
123
123
  }
124
- return {
124
+ const fk: FieldModel = {
125
125
  name,
126
126
  type: "fk",
127
127
  relation: { app: target.app, model: target.model },
128
128
  };
129
+ // A nullable FK (`s.ref(() => X, { null: true })`) — e.g. a shared secret
130
+ // with no project — so the engine allows and stores a null relation.
131
+ if ((field.options as { null?: boolean } | undefined)?.null === true) {
132
+ fk.null = true;
133
+ }
134
+ return fk;
129
135
  }
130
136
 
131
137
  if (typeof field.__rastackScalar !== "string") return undefined;
package/src/deploy/ci.ts CHANGED
@@ -16,7 +16,12 @@
16
16
  * the same plan works before and after they are wired into the config.
17
17
  */
18
18
 
19
- import { DeployConfig } from "./config";
19
+ import {
20
+ DeployConfig,
21
+ DeployOutputs,
22
+ EnvName,
23
+ resolveEnvironment,
24
+ } from "./config";
20
25
 
21
26
  export type CiPhase = "check" | "build" | "deploy";
22
27
 
@@ -55,27 +60,47 @@ export interface CiPlanOptions {
55
60
  bin?: string;
56
61
  /** Skip the build steps in `deploy` (code already built this job). */
57
62
  skipBuild?: boolean;
58
- /** Override the function name (else from cfg.outputs, else resolved live). */
63
+ /** Override the function name (else from the env's outputs, else resolved live). */
59
64
  functionName?: string;
60
- /** Override the JWKS URL (else from cfg.outputs, else resolved live). */
65
+ /** Override the JWKS URL (else from the env's outputs, else resolved live). */
61
66
  jwksUrl?: string;
67
+ /**
68
+ * Which environment (dev/prod) to build+ship. When set, the stack name,
69
+ * outputs, and app label come from that environment — so `rastack ci deploy
70
+ * --env dev` targets the dev stack (dev's Cognito JWKS, dev's function). When
71
+ * unset the legacy single-stack fields (`cfg.stackName` / `cfg.outputs`) apply.
72
+ */
73
+ env?: EnvName;
74
+ }
75
+
76
+ /** The stack name + outputs + app label the plan targets, honouring `opts.env`. */
77
+ function ciTarget(
78
+ cfg: DeployConfig,
79
+ opts: CiPlanOptions,
80
+ ): { stackName: string; outputs?: DeployOutputs; app: string } {
81
+ if (opts.env) {
82
+ const e = resolveEnvironment(cfg, opts.env);
83
+ return { stackName: e.stackName, outputs: e.outputs, app: e.appName };
84
+ }
85
+ return { stackName: cfg.stackName, outputs: cfg.outputs, app: cfg.app };
62
86
  }
63
87
 
64
88
  /**
65
- * A stack Output value: the literal from `cfg.outputs` if wired, else a shell
66
- * command substitution that resolves it live from CloudFormation. Keeping the
67
- * fallback inline (rather than a separate step) means the plan is a flat list of
68
- * self-contained commands that read correctly in any order.
89
+ * A stack Output value: the literal if wired, else a shell command substitution
90
+ * that resolves it live from CloudFormation. Keeping the fallback inline (rather
91
+ * than a separate step) means the plan is a flat list of self-contained commands
92
+ * that read correctly in any order.
69
93
  */
70
94
  function stackOutput(
71
- cfg: DeployConfig,
95
+ stackName: string,
96
+ region: string,
72
97
  outputKey: string,
73
98
  literal?: string,
74
99
  ): string {
75
100
  if (literal) return literal;
76
101
  return (
77
- `$(aws cloudformation describe-stacks --stack-name ${cfg.stackName} ` +
78
- `--region ${cfg.region} ` +
102
+ `$(aws cloudformation describe-stacks --stack-name ${stackName} ` +
103
+ `--region ${region} ` +
79
104
  `--query "Stacks[0].Outputs[?OutputKey=='${outputKey}'].OutputValue" ` +
80
105
  `--output text)`
81
106
  );
@@ -107,11 +132,13 @@ export function buildSteps(
107
132
  opts: CiPlanOptions = {},
108
133
  ): CiStep[] {
109
134
  const stage = stageDir(cfg);
135
+ const target = ciTarget(cfg, opts);
110
136
  const archFlag = cfg.lambda.arch === "arm64" ? "--arm64" : "--x86-64";
111
137
  const jwks = stackOutput(
112
- cfg,
138
+ target.stackName,
139
+ cfg.region,
113
140
  "JwksUrl",
114
- opts.jwksUrl ?? cfg.outputs?.jwksUrl,
141
+ opts.jwksUrl ?? target.outputs?.jwksUrl,
115
142
  );
116
143
  return [
117
144
  compileStep(cfg),
@@ -146,12 +173,19 @@ export function deploySteps(
146
173
  ): CiStep[] {
147
174
  const stage = stageDir(cfg);
148
175
  const zip = `${stage}/bootstrap.zip`;
176
+ const target = ciTarget(cfg, opts);
149
177
  const fn = stackOutput(
150
- cfg,
178
+ target.stackName,
179
+ cfg.region,
151
180
  "FunctionName",
152
- opts.functionName ?? cfg.outputs?.functionName,
181
+ opts.functionName ?? target.outputs?.functionName,
182
+ );
183
+ const apiUrl = stackOutput(
184
+ target.stackName,
185
+ cfg.region,
186
+ "ApiUrl",
187
+ target.outputs?.apiUrl,
153
188
  );
154
- const apiUrl = stackOutput(cfg, "ApiUrl", cfg.outputs?.apiUrl);
155
189
  const steps: CiStep[] = opts.skipBuild ? [] : buildSteps(cfg, opts);
156
190
  steps.push(
157
191
  {
@@ -167,7 +201,7 @@ export function deploySteps(
167
201
  },
168
202
  {
169
203
  name: "Report the API URL",
170
- run: `echo "Deployed ${cfg.app} → ${apiUrl}"`,
204
+ run: `echo "Deployed ${target.app} → ${apiUrl}"`,
171
205
  },
172
206
  );
173
207
  return steps;