@ahena/cli 0.1.0-beta.1 → 0.1.0-beta.2

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 (4) hide show
  1. package/LICENSE +198 -9
  2. package/README.md +3 -2
  3. package/dist/ahena.mjs +416 -140
  4. package/package.json +2 -2
package/LICENSE CHANGED
@@ -1,12 +1,201 @@
1
- Copyright (c) 2026 Cao-Tech LLC. All rights reserved.
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
2
4
 
3
- The Ahena CLI ("@ahena/cli") is proprietary software. You may install and run it to use the
4
- Ahena service, subject to the Ahena Terms of Service at https://ahena.io/terms. No other rights
5
- are granted: you may not copy, modify, distribute, sublicense or sell it, or reverse engineer
6
- it except as the law allows despite this restriction.
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
6
 
8
- Code that the CLI generates into your project (for example src/ahena/) is yours.
7
+ 1. Definitions.
9
8
 
10
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. IN NO EVENT
11
- SHALL CAO-TECH LLC BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING FROM THE SOFTWARE
12
- OR ITS USE, EXCEPT AS THE AHENA TERMS OF SERVICE PROVIDE.
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Cao-Tech LLC
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md CHANGED
@@ -22,5 +22,6 @@ Or run any command without installing: `npx @ahena/cli <command>`.
22
22
 
23
23
  Requires Node.js 22.18 or newer. Docs: https://ahena.io/docs
24
24
 
25
- Proprietary software © Cao-Tech LLC. You may install and use it with an Ahena account under
26
- the Ahena Terms (https://ahena.io/terms); see LICENSE.
25
+ Licensed under the Apache License 2.0 (see LICENSE), © 2026 Cao-Tech LLC. Using the hosted
26
+ Ahena service (api.ahena.io, app.ahena.io) is subject to the Ahena Terms of Service:
27
+ https://ahena.io/terms
package/dist/ahena.mjs CHANGED
@@ -184,6 +184,14 @@ var AhenaClient = class _AhenaClient {
184
184
  addMember(org, input2) {
185
185
  return this.request("POST", `/v1/orgs/${seg(org)}/members`, input2);
186
186
  }
187
+ /** Owners and admins; only owners give or take away the owner role, and the last owner stays. */
188
+ updateMemberRole(org, userId, role) {
189
+ return this.request("PATCH", `/v1/orgs/${seg(org)}/members/${seg(userId)}`, { role });
190
+ }
191
+ /** Removes someone (owners and admins), or leaves the organization when `userId` is your own. */
192
+ removeMember(org, userId) {
193
+ return this.request("DELETE", `/v1/orgs/${seg(org)}/members/${seg(userId)}`);
194
+ }
187
195
  listInvitations(org) {
188
196
  return this.request("GET", `/v1/orgs/${seg(org)}/invitations`);
189
197
  }
@@ -261,6 +269,14 @@ var AhenaClient = class _AhenaClient {
261
269
  createAgentSession(input2 = {}) {
262
270
  return this.request("POST", "/v1/me/agent-sessions", input2);
263
271
  }
272
+ /** Your own sessions that still work (browsers, CLIs, agents, CI tokens). Never their tokens. */
273
+ listSessions() {
274
+ return this.request("GET", "/v1/me/sessions");
275
+ }
276
+ /** Signs out another of your sessions. Not the current one (sign out) or a CI token (project settings). */
277
+ revokeSession(id) {
278
+ return this.request("DELETE", `/v1/me/sessions/${seg(id)}`);
279
+ }
264
280
  cancelPlan(org, project2, id) {
265
281
  return this.request("POST", `/v1/orgs/${seg(org)}/projects/${seg(project2)}/plans/${seg(id)}/cancel`, {});
266
282
  }
@@ -415,9 +431,44 @@ var CliError = class extends Error {
415
431
  this.name = "CliError";
416
432
  }
417
433
  };
434
+ var PERMISSION_TEXT = {
435
+ "provider.manage": "connect providers and change their configuration",
436
+ "provider.manage.production": "connect providers and change them in production",
437
+ "secrets.write": "set secrets",
438
+ "secrets.write.production": "set production secrets",
439
+ "secrets.reveal": "reveal secret values",
440
+ "secrets.reveal.production": "reveal production secret values",
441
+ "operations.destructive": "approve billable and destructive changes",
442
+ "environment.create": "create environments",
443
+ "project.create": "create projects",
444
+ "project.update": "change project settings",
445
+ "project.delete": "delete projects",
446
+ "members.manage": "manage members",
447
+ "billing.manage": "manage billing",
448
+ "org.update": "change organization settings",
449
+ "org.delete": "delete the organization",
450
+ "doctor.fix": "apply Doctor fixes"
451
+ };
452
+ var ROLE_TEXT = { owner: "an owner", admin: "an admin (or owner)", developer: "a developer (or above)", viewer: "a viewer (or above)" };
453
+ function permissionMessage(details) {
454
+ const d = details ?? {};
455
+ if (typeof d.permission !== "string")
456
+ return null;
457
+ const what = PERMISSION_TEXT[d.permission] ?? d.permission;
458
+ const who = typeof d.role === "string" ? ROLE_TEXT[d.role] ?? d.role : null;
459
+ const env = typeof d.environment === "string" ? d.environment : null;
460
+ const why = env && d.permission.endsWith(".production") ? typeof d.treatedAsProduction === "string" ? ` (${env} counts as production because it ${d.treatedAsProduction})` : ` (${env} is a production environment)` : "";
461
+ const message = `You don't have permission to do that: ${who ? `only ${who} can ${what}` : `it needs the ${d.permission} permission`}${why}.`;
462
+ return { message, hint: `${who ? `Ask ${who.replace(/ \(.*\)$/, "")} of this organization to do it, or to change your role. ` : ""}Needs: ${d.permission}.` };
463
+ }
418
464
  function describeError(err) {
419
465
  if (err instanceof CliError)
420
466
  return { message: err.message, ...err.hint ? { hint: err.hint } : {}, exitCode: err.exitCode };
467
+ if (err instanceof AhenaApiError && err.status === 403) {
468
+ const explained = err.details?.reason === void 0 ? permissionMessage(err.details) : null;
469
+ if (explained)
470
+ return { message: explained.message, ...explained.hint ? { hint: explained.hint } : {}, exitCode: 1 };
471
+ }
421
472
  if (err instanceof AhenaApiError) {
422
473
  const hint = err.status === 401 ? "Run `ahena login` to sign in again." : err.code === "network_error" ? "Is the Ahena API running? Set AHENA_API_URL if it lives somewhere else." : err.requestId ? `Request id: ${err.requestId}` : void 0;
423
474
  return { message: err.message, ...hint ? { hint } : {}, exitCode: err.status === 401 ? 3 : 1 };
@@ -517,18 +568,24 @@ function renderOutcomes(out, st, outcomes, prefix = "") {
517
568
 
518
569
  // dist/context.js
519
570
  var DEFAULT_API_URL = "https://api.ahena.io";
571
+ function notInteractive(question) {
572
+ return new CliError(`Can't ask "${question}": this isn't an interactive terminal.`, "Pass --yes to accept the defaults (see --help for the flags that answer each question), or run it in a terminal.");
573
+ }
520
574
  function defaultContext(version2) {
575
+ const canPrompt = Boolean(process.stdin.isTTY);
576
+ const ask = (message, prompt) => canPrompt ? prompt() : Promise.reject(notInteractive(message));
521
577
  return {
522
578
  out: processOutput,
523
579
  st: stylist(processOutput),
524
580
  env: process.env,
525
581
  cwd: process.cwd(),
526
582
  store: fileCredentialStore(process.env.AHENA_CREDENTIALS_FILE || defaultCredentialsPath()),
583
+ canPrompt,
527
584
  prompts: {
528
- input: (o) => input(o),
529
- password: (o) => password({ ...o, mask: "\u2022" }),
530
- confirm: (o) => confirm(o),
531
- select: (o) => select(o)
585
+ input: (o) => ask(o.message, () => input(o)),
586
+ password: (o) => ask(o.message, () => password({ ...o, mask: "\u2022" })),
587
+ confirm: (o) => ask(o.message, () => confirm(o)),
588
+ select: (o) => ask(o.message, () => select(o))
532
589
  },
533
590
  fetch: globalThis.fetch,
534
591
  localFetch: globalThis.fetch,
@@ -592,9 +649,6 @@ async function authedClient(ctx, apiUrlFlag, headers) {
592
649
  // dist/run.js
593
650
  import { CommanderError } from "commander";
594
651
 
595
- // dist/program.js
596
- import { Command } from "commander";
597
-
598
652
  // dist/commands/auth.js
599
653
  async function login(ctx, opts) {
600
654
  const { out, st } = ctx;
@@ -616,7 +670,7 @@ async function login(ctx, opts) {
616
670
  out.write("");
617
671
  out.write(` Your one-time code: ${st.bold(st.accent(auth3.userCode))}`);
618
672
  out.write("");
619
- out.write(` Approve this sign-in at ${st.accent(auth3.verificationUri)}`);
673
+ out.write(` Approve this sign-in at ${st.accent(isBrowserUrl(auth3.verificationUriComplete) ? auth3.verificationUriComplete : auth3.verificationUri)}`);
620
674
  out.write(st.dim(" Check that the code shown in your browser matches the one above."));
621
675
  out.write("");
622
676
  if (opts.browser !== false && out.interactive && isBrowserUrl(auth3.verificationUriComplete)) {
@@ -688,6 +742,55 @@ function isBrowserUrl(value) {
688
742
  }
689
743
  }
690
744
 
745
+ // dist/approval.js
746
+ var AWAITING_APPROVAL_EXIT = 4;
747
+ var POLL_MS = 2e3;
748
+ var isApprovalRequired = (err) => err instanceof AhenaApiError && err.status === 403 && err.details?.reason === "approval_required";
749
+ async function waitForBrowserApproval(ctx, client2, approval, opts = {}) {
750
+ const { st } = ctx;
751
+ const say = (text) => opts.json ? ctx.out.error(text) : ctx.out.write(text);
752
+ say("");
753
+ const declinedAt = approval.previouslyDeclinedAt;
754
+ if (declinedAt)
755
+ say(st.warn(`These changes were declined in the dashboard (${declinedAt.slice(0, 16).replace("T", " ")} UTC). This asks for approval again.`));
756
+ say(`These changes need your approval in the Ahena dashboard: ${st.accent(approval.url)}`);
757
+ if (opts.wait === false) {
758
+ throw new CliError("Waiting for your approval in the dashboard. Nothing was changed yet.", `Approve it at ${approval.url}, then run: ${opts.resume ?? "the same command again"}`, AWAITING_APPROVAL_EXIT);
759
+ }
760
+ if (ctx.out.interactive && isBrowserUrl(approval.url))
761
+ await ctx.openUrl(approval.url);
762
+ say(st.dim(" Waiting for your decision in the browser. Nothing is applied without it."));
763
+ const deadline = Date.parse(approval.expiresAt) + 6e4;
764
+ let status2 = approval.status;
765
+ while (status2 === "pending" && Date.now() < deadline) {
766
+ await ctx.sleep(POLL_MS);
767
+ status2 = (await client2.getApproval(approval.id)).approval.status;
768
+ }
769
+ if (status2 === "approved") {
770
+ say(`${st.ok("\u2713")} Approved in the dashboard.`);
771
+ return;
772
+ }
773
+ if (status2 === "declined")
774
+ throw new CliError("The changes were declined in the dashboard. Nothing was changed.");
775
+ if (status2 === "used")
776
+ throw new CliError("That approval was already used. Nothing was changed.", "Run the command again to request a new one.");
777
+ throw new CliError("The approval request expired before anyone approved it. Nothing was changed.", "Run the command again to request a new one.");
778
+ }
779
+ async function applyWithApproval(ctx, client2, ref, provider, input2, opts = {}) {
780
+ try {
781
+ return await client2.apply(ref, provider, input2);
782
+ } catch (err) {
783
+ if (!isApprovalRequired(err) || input2.approvalId)
784
+ throw err;
785
+ }
786
+ const { approval } = await client2.requestApproval(ref, provider, { desired: input2.desired, fingerprint: input2.fingerprint, approve: input2.approve });
787
+ await waitForBrowserApproval(ctx, client2, approval, { ...opts, resume: `${opts.resume ?? "the same command again"} --approval ${approval.id}` });
788
+ return client2.apply(ref, provider, { ...input2, approvalId: approval.id });
789
+ }
790
+
791
+ // dist/program.js
792
+ import { Command } from "commander";
793
+
691
794
  // dist/secret-name.js
692
795
  var SECRET_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,127}$/;
693
796
 
@@ -985,7 +1088,8 @@ async function unsetSecret(ctx, environment, name, opts) {
985
1088
  }
986
1089
 
987
1090
  // dist/commands/project.js
988
- import { basename as basename3 } from "node:path";
1091
+ import { readFile as readFile10 } from "node:fs/promises";
1092
+ import { basename as basename3, join as join10 } from "node:path";
989
1093
 
990
1094
  // ../recipes/dist/recipes.js
991
1095
  var https = (domain2) => `https://${domain2}`;
@@ -1329,6 +1433,21 @@ var TRANSITIONS = {
1329
1433
  };
1330
1434
 
1331
1435
  // ../provider-sdk/dist/doctor.js
1436
+ var sentences = (text) => (text ?? "").split(/(?<=[.!?])\s+/).map((x) => x.trim()).filter(Boolean);
1437
+ var sentenceKey = (sentence) => sentence.toLowerCase().replace(/[.!?\s]+$/, "");
1438
+ function tidyProblem(problem) {
1439
+ const title = new Set(sentences(problem.title).map(sentenceKey));
1440
+ const action = sentences(problem.action).map(sentenceKey);
1441
+ const kept = sentences(problem.detail).filter((s, i, all) => {
1442
+ const key = sentenceKey(s);
1443
+ return !title.has(key) && !action.some((a) => a.startsWith(key)) && all.findIndex((x) => sentenceKey(x) === key) === i;
1444
+ });
1445
+ const said = /* @__PURE__ */ new Set([...title, ...kept.map(sentenceKey)]);
1446
+ const tidy = { ...problem, detail: kept.join(" ") };
1447
+ if (problem.action !== void 0)
1448
+ tidy.action = sentences(problem.action).filter((s) => !said.has(sentenceKey(s))).join(" ");
1449
+ return tidy;
1450
+ }
1332
1451
  var ProblemError = class extends Error {
1333
1452
  problem;
1334
1453
  constructor(problem) {
@@ -4766,8 +4885,8 @@ var FirebaseApi = class {
4766
4885
  createIosApp(projectId, bundleId, displayName) {
4767
4886
  return this.call("POST", `${MGMT}/projects/${assertProject(projectId)}/iosApps`, SCOPES.write, operation, { bundleId, displayName });
4768
4887
  }
4769
- createAndroidApp(projectId, packageName, displayName) {
4770
- return this.call("POST", `${MGMT}/projects/${assertProject(projectId)}/androidApps`, SCOPES.write, operation, { packageName, displayName });
4888
+ createAndroidApp(projectId, packageName2, displayName) {
4889
+ return this.call("POST", `${MGMT}/projects/${assertProject(projectId)}/androidApps`, SCOPES.write, operation, { packageName: packageName2, displayName });
4771
4890
  }
4772
4891
  getOperation(name) {
4773
4892
  if (!/^operations\/[A-Za-z0-9._:-]+$/.test(name))
@@ -6214,49 +6333,6 @@ async function localSecurityChecks(root) {
6214
6333
  return checks;
6215
6334
  }
6216
6335
 
6217
- // dist/approval.js
6218
- var AWAITING_APPROVAL_EXIT = 4;
6219
- var POLL_MS = 2e3;
6220
- var isApprovalRequired = (err) => err instanceof AhenaApiError && err.status === 403 && err.details?.reason === "approval_required";
6221
- async function waitForBrowserApproval(ctx, client2, approval, opts = {}) {
6222
- const { st } = ctx;
6223
- const say = (text) => opts.json ? ctx.out.error(text) : ctx.out.write(text);
6224
- say("");
6225
- say(`These changes need your approval in the Ahena dashboard: ${st.accent(approval.url)}`);
6226
- if (opts.wait === false) {
6227
- throw new CliError("Waiting for your approval in the dashboard. Nothing was changed yet.", `Approve it at ${approval.url}, then run: ${opts.resume ?? "the same command again"}`, AWAITING_APPROVAL_EXIT);
6228
- }
6229
- if (ctx.out.interactive && isBrowserUrl(approval.url))
6230
- await ctx.openUrl(approval.url);
6231
- say(st.dim(" Waiting for your decision in the browser. Nothing is applied without it."));
6232
- const deadline = Date.parse(approval.expiresAt) + 6e4;
6233
- let status2 = approval.status;
6234
- while (status2 === "pending" && Date.now() < deadline) {
6235
- await ctx.sleep(POLL_MS);
6236
- status2 = (await client2.getApproval(approval.id)).approval.status;
6237
- }
6238
- if (status2 === "approved") {
6239
- say(`${st.ok("\u2713")} Approved in the dashboard.`);
6240
- return;
6241
- }
6242
- if (status2 === "declined")
6243
- throw new CliError("The changes were declined in the dashboard. Nothing was changed.");
6244
- if (status2 === "used")
6245
- throw new CliError("That approval was already used. Nothing was changed.", "Run the command again to request a new one.");
6246
- throw new CliError("The approval request expired before anyone approved it. Nothing was changed.", "Run the command again to request a new one.");
6247
- }
6248
- async function applyWithApproval(ctx, client2, ref, provider, input2, opts = {}) {
6249
- try {
6250
- return await client2.apply(ref, provider, input2);
6251
- } catch (err) {
6252
- if (!isApprovalRequired(err) || input2.approvalId)
6253
- throw err;
6254
- }
6255
- const { approval } = await client2.requestApproval(ref, provider, { desired: input2.desired, fingerprint: input2.fingerprint, approve: input2.approve });
6256
- await waitForBrowserApproval(ctx, client2, approval, { ...opts, resume: `${opts.resume ?? "the same command again"} --approval ${approval.id}` });
6257
- return client2.apply(ref, provider, { ...input2, approvalId: approval.id });
6258
- }
6259
-
6260
6336
  // dist/generate.js
6261
6337
  import { createHash as createHash2 } from "node:crypto";
6262
6338
  import { mkdir as mkdir2, readFile as readFile6, readdir as readdir2, writeFile as writeFile5 } from "node:fs/promises";
@@ -8112,7 +8188,7 @@ export function assertEnv(env: Record<string, string | undefined> = process.env)
8112
8188
  }
8113
8189
 
8114
8190
  // dist/commands/providers.js
8115
- import { readFile as readFile8 } from "node:fs/promises";
8191
+ import { readFile as readFile9 } from "node:fs/promises";
8116
8192
 
8117
8193
  // dist/discover.js
8118
8194
  import { access as access2, readFile as readFile7, readdir as readdir3 } from "node:fs/promises";
@@ -8314,8 +8390,74 @@ function configFromDiscovery(d) {
8314
8390
  ...Object.fromEntries(d.providers.map((p) => [p.capability, { provider: p.provider }]))
8315
8391
  };
8316
8392
  }
8393
+ var APP_DEPENDENCIES = [
8394
+ { pkg: "next", framework: "Next.js" },
8395
+ { pkg: "@remix-run/node", framework: "Remix" },
8396
+ { pkg: "@remix-run/react", framework: "Remix" },
8397
+ { pkg: "@sveltejs/kit", framework: "SvelteKit" },
8398
+ { pkg: "nuxt", framework: "Nuxt" },
8399
+ { pkg: "hono", framework: "Hono" },
8400
+ { pkg: "express", framework: "Express" }
8401
+ ];
8402
+ var appFramework = (pkg) => APP_DEPENDENCIES.find((d) => pkg?.dependencies?.[d.pkg] ?? pkg?.devDependencies?.[d.pkg]);
8403
+ var AppPackageError = class extends Error {
8404
+ hint;
8405
+ constructor(message, hint) {
8406
+ super(message);
8407
+ this.hint = hint;
8408
+ this.name = "AppPackageError";
8409
+ }
8410
+ };
8411
+ async function appPackageDir(root, override) {
8412
+ if (override !== void 0) {
8413
+ const clean = posix2.normalize(override.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, ""));
8414
+ if (clean.startsWith("..") || posix2.isAbsolute(clean))
8415
+ throw new AppPackageError(`--dir ${override} is outside this project.`, "Pass a package directory inside the project, e.g. --dir apps/web.");
8416
+ const dir = clean === "." ? "" : clean;
8417
+ const pkg = await readPackage(join7(root, dir));
8418
+ if (!pkg)
8419
+ throw new AppPackageError(`${dir || "."} has no package.json.`, "Pass the directory of a package in this project, e.g. --dir apps/web.");
8420
+ return { dir, framework: appFramework(pkg)?.framework ?? null };
8421
+ }
8422
+ const rootPkg = await readPackage(root);
8423
+ const own = appFramework(rootPkg);
8424
+ if (own)
8425
+ return { dir: "", framework: own.framework };
8426
+ const workspaces = await workspaceDirs(root, await workspaceGlobs(root, rootPkg));
8427
+ const apps = [];
8428
+ for (const dir of workspaces) {
8429
+ const found = appFramework(await readPackage(join7(root, dir)));
8430
+ if (found)
8431
+ apps.push({ dir, rank: APP_DEPENDENCIES.indexOf(found), framework: found.framework });
8432
+ }
8433
+ if (!apps.length)
8434
+ return { dir: "", framework: null };
8435
+ const best = Math.min(...apps.map((a) => a.rank));
8436
+ let picks = apps.filter((a) => a.rank === best);
8437
+ if (picks.length > 1 && picks.some((a) => a.dir.startsWith("apps/")))
8438
+ picks = picks.filter((a) => a.dir.startsWith("apps/"));
8439
+ if (picks.length > 1) {
8440
+ throw new AppPackageError(`Several packages in this workspace are ${picks[0].framework} apps: ${picks.map((a) => a.dir).join(", ")}.`, `Say which one gets the generated code: --dir ${picks[0].dir}`);
8441
+ }
8442
+ return { dir: picks[0].dir, framework: picks[0].framework };
8443
+ }
8444
+
8445
+ // dist/detect.js
8446
+ import { access as access3, readFile as readFile8 } from "node:fs/promises";
8447
+ import { join as join8 } from "node:path";
8448
+ var exists3 = (p) => access3(p).then(() => true, () => false);
8449
+ async function detectFramework(root) {
8450
+ let nextjs = false;
8451
+ try {
8452
+ const pkg = JSON.parse(await readFile8(join8(root, "package.json"), "utf8"));
8453
+ nextjs = Boolean(pkg.dependencies?.next ?? pkg.devDependencies?.next);
8454
+ } catch {
8455
+ }
8456
+ return { framework: nextjs ? "nextjs" : "node", appDir: await exists3(join8(root, "src/app")) ? "src/app" : "app" };
8457
+ }
8317
8458
 
8318
8459
  // dist/commands/providers.js
8460
+ import { join as join9 } from "node:path";
8319
8461
  async function setup2(ctx, opts) {
8320
8462
  const linked = await requireProject(ctx.cwd);
8321
8463
  const { client: client2 } = await authedClient(ctx, opts.apiUrl);
@@ -8359,6 +8501,9 @@ async function connect(ctx, provider, opts) {
8359
8501
  for (const f of secretFields) {
8360
8502
  const fromEnv = ctx.env[f.key];
8361
8503
  const value = fromEnv ?? (piped !== null ? piped.trim() : f.required ? await ctx.prompts.password({ message: `${manifest.name} ${f.label}` }) : "");
8504
+ if (!value && f.required) {
8505
+ throw new CliError(`No ${manifest.name} ${f.label.toLowerCase()} was given${fromEnv !== void 0 ? ` (${f.key} is empty)` : piped !== null ? " (stdin was empty)" : ""}.`, `Pipe it on stdin, set ${f.key} in the environment, or run in a terminal to be asked.`);
8506
+ }
8362
8507
  if (value)
8363
8508
  values[f.key] = value;
8364
8509
  }
@@ -8386,20 +8531,24 @@ async function connect(ctx, provider, opts) {
8386
8531
  const settings = Object.fromEntries(manifest.credentialFields.filter((f) => !f.secret && values[f.key]).map((f) => [f.key, values[f.key]]));
8387
8532
  const check = await local.validate({ environment: { id: env.id, name: env.name, kind: env.kind }, credentials: secrets, settings, fetch: ctx.localFetch, signal: AbortSignal.timeout(15e3) });
8388
8533
  if (!check.ok) {
8389
- ctx.out.write(` ${check.problem?.title ?? "Couldn't verify it."} ${check.problem?.detail ?? ""}`);
8390
- if (check.problem?.action)
8391
- ctx.out.write(ctx.st.dim(` ${check.problem.action}`));
8534
+ const p = check.problem ? tidyProblem(check.problem) : null;
8535
+ ctx.out.write(` ${[p?.title ?? "Couldn't verify it.", p?.detail].filter(Boolean).join(" ")}`);
8536
+ if (p?.action)
8537
+ ctx.out.write(ctx.st.dim(` ${p.action}`));
8392
8538
  throw new CliError(`${manifest.name} isn't connected.`);
8393
8539
  }
8394
- ctx.out.write(ctx.st.dim(`Verified on this machine: ${check.account?.name ?? manifest.name}.`));
8540
+ ctx.out.write(ctx.st.dim(`Verified on this machine: ${check.account?.name ?? manifest.name}. Ahena's server can't reach it, so its health comes from ahena doctor run here.`));
8395
8541
  }
8396
8542
  const { connection } = await client2.connect(ref, provider, values);
8397
8543
  const { st, out } = ctx;
8398
8544
  out.write(`${stateBadge(st, connection.tone, connection.stateLabel)} ${st.bold(manifest.name)} \u2192 ${env.name}${connection.account ? st.dim(` (${connection.account.name ?? connection.account.id})`) : ""}`);
8399
8545
  if (connection.problem) {
8400
- out.write(` ${connection.problem.title} ${connection.problem.detail}`);
8401
- if (connection.problem.action)
8402
- out.write(st.dim(` ${connection.problem.action}`));
8546
+ const p = tidyProblem(connection.problem);
8547
+ out.write(` ${[p.title, p.detail].filter(Boolean).join(" ")}`);
8548
+ if (p.action)
8549
+ out.write(st.dim(` ${p.action}`));
8550
+ if (connection.state === "not_connected")
8551
+ throw new CliError(`${manifest.name} isn't connected: Ahena didn't keep that credential.`);
8403
8552
  throw new CliError(`${manifest.name} isn't connected yet.`);
8404
8553
  }
8405
8554
  for (const perm of manifest.permissions ?? []) {
@@ -8568,7 +8717,7 @@ async function configure6(ctx, provider, opts) {
8568
8717
  let fromFile;
8569
8718
  if (opts.desired) {
8570
8719
  try {
8571
- fromFile = JSON.parse(await readFile8(opts.desired, "utf8"));
8720
+ fromFile = JSON.parse(await readFile9(opts.desired, "utf8"));
8572
8721
  } catch (err) {
8573
8722
  throw new CliError(`Couldn't read ${opts.desired}: ${err instanceof Error ? err.message : String(err)}`);
8574
8723
  }
@@ -8615,15 +8764,20 @@ async function reviewAndApply(ctx, client2, ref, envName, provider, desired, opt
8615
8764
  out.write(st.warn(`${risky.length} change${risky.length === 1 ? "" : "s"} may cost money or can't be undone:`));
8616
8765
  for (const a of risky)
8617
8766
  out.write(st.warn(` \u2022 ${a.description}${a.costNotice ? ` \u2014 ${a.costNotice}` : ""}`));
8767
+ const kind = risky.some((a) => a.classification === "DESTRUCTIVE") ? risky.some((a) => a.classification === "BILLABLE") ? "billable and destructive" : "destructive" : "billable";
8768
+ const leftOut = `Left out: ${provider.charAt(0).toUpperCase()}${provider.slice(1)}'s changes. Approve the ${kind} change${risky.length === 1 ? "" : "s"} to apply this provider's changes.`;
8618
8769
  if (opts.yes && !opts.allowBillable) {
8619
- if (!opts.skipUnapprovedRisky || !safe.length && !confirm2.length) {
8620
- throw new CliError("Refusing billable or destructive changes without explicit approval.", "Re-run with --allow-billable, or run interactively.");
8621
- }
8622
- out.write(st.warn(" Skipping these: pass --allow-billable to include them."));
8623
- } else if (opts.yes || await ctx.prompts.confirm({ message: "Approve the billable/destructive changes listed above?", default: false })) {
8624
- for (const a of risky)
8625
- approve.add(a.index);
8770
+ if (!opts.skipUnapprovedRisky)
8771
+ throw new CliError("Refusing billable or destructive changes without explicit approval.", `${leftOut} Re-run with --allow-billable, or run interactively.`);
8772
+ out.write(st.warn(` ${leftOut} Pass --allow-billable to include them.`));
8773
+ return 0;
8626
8774
  }
8775
+ if (!opts.yes && !await ctx.prompts.confirm({ message: "Approve the billable/destructive changes listed above?", default: false })) {
8776
+ out.write(st.warn(` ${leftOut}`));
8777
+ throw new CliError("Nothing was changed.");
8778
+ }
8779
+ for (const a of risky)
8780
+ approve.add(a.index);
8627
8781
  }
8628
8782
  if (confirm2.length || risky.length && [...approve].some((i) => risky.some((r2) => r2.index === i))) {
8629
8783
  if (!opts.yes && !await ctx.prompts.confirm({ message: "Apply?", default: false }))
@@ -8649,35 +8803,60 @@ var STATUS_LABEL = {
8649
8803
  "would-write": "would write"
8650
8804
  };
8651
8805
  var INSTALL = { supabase: "@supabase/supabase-js", cloudflare: "@aws-sdk/client-s3 @aws-sdk/s3-request-presigner", resend: "resend", stripe: "stripe", firebase: "firebase-admin", openai: "openai", anthropic: "@anthropic-ai/sdk", ollama: "ollama" };
8806
+ var GENERATE_FRAMEWORKS = ["nextjs", "node"];
8807
+ async function generationTarget(linked, dir) {
8808
+ try {
8809
+ const target = await appPackageDir(linked.root, dir);
8810
+ return { ...target, root: join9(linked.root, target.dir) };
8811
+ } catch (err) {
8812
+ if (err instanceof AppPackageError)
8813
+ throw new CliError(err.message, err.hint);
8814
+ throw err;
8815
+ }
8816
+ }
8652
8817
  async function generateLayer(client2, linked, env, provider, opts) {
8653
8818
  const USABLE = ["connected", "healthy", "warning", "configuration_required", "action_required"];
8819
+ if (opts.framework !== void 0 && !GENERATE_FRAMEWORKS.includes(opts.framework)) {
8820
+ throw new CliError(`Ahena doesn't generate code for "${opts.framework}".`, `--framework takes ${GENERATE_FRAMEWORKS.join(" or ")} (node works for any other JavaScript or TypeScript server).`);
8821
+ }
8822
+ if (provider !== void 0) {
8823
+ if (!localDefinition(provider))
8824
+ throw new CliError(`Ahena doesn't support a provider called "${provider}".`, `Connected to ${env.name}: ${env.connections.filter((c) => USABLE.includes(c.state)).map((c) => c.providerId).join(", ") || "none"}`);
8825
+ if (!env.connections.some((c) => c.providerId === provider && USABLE.includes(c.state)))
8826
+ throw new CliError(`${provider} isn't connected to ${env.name}.`, `Connect it first: ahena connect ${provider} -e ${env.slug}`);
8827
+ }
8654
8828
  const targets = provider ? [provider] : env.connections.filter((c) => USABLE.includes(c.state)).map((c) => c.providerId);
8655
8829
  if (!targets.length)
8656
8830
  throw new CliError(`No providers are connected to ${env.name}.`, `Connect one with ahena connect <provider> -e ${env.slug}.`);
8831
+ const target = await generationTarget(linked, opts.dir);
8832
+ const framework = opts.framework ?? (target.framework === "Next.js" || linked.config.framework === "nextjs" || (await detectFramework(target.root)).framework === "nextjs" ? "nextjs" : "node");
8657
8833
  const ref = { org: linked.config.organization, project: linked.config.project, environment: env.slug };
8658
8834
  const byProvider = [];
8659
8835
  for (const id of targets) {
8660
- const options = { ...(await intentFor(id, linked, env.slug)).generateOptions ?? {}, ...opts.framework ? { framework: opts.framework } : {} };
8836
+ const options = { ...(await intentFor(id, linked, env.slug)).generateOptions ?? {}, framework };
8661
8837
  const { files: files2 } = await client2.generate(ref, id, options);
8662
8838
  byProvider.push({ provider: id, files: files2 });
8663
8839
  }
8664
- const files = await composeIntegrationLayer(linked.root, { project: linked.config.project, organization: linked.config.organization, environment: env.slug, byProvider, ...linked.config.features ? { features: linked.config.features } : {} });
8665
- const results = await writeGenerated(linked.root, files, { ...opts.force ? { force: true } : {}, ...opts.dryRun ? { dryRun: true } : {} });
8666
- return { results, install: targets.map((t) => INSTALL[t]).filter((p) => Boolean(p)) };
8840
+ const files = await composeIntegrationLayer(target.root, { project: linked.config.project, organization: linked.config.organization, environment: env.slug, byProvider, ...linked.config.features ? { features: linked.config.features } : {} });
8841
+ const results = await writeGenerated(target.root, files, { ...opts.force ? { force: true } : {}, ...opts.dryRun ? { dryRun: true } : {} });
8842
+ return { results, install: targets.map((t) => INSTALL[t]).filter((p) => Boolean(p)), dir: target.dir, root: target.root };
8667
8843
  }
8668
8844
  async function generate9(ctx, provider, opts) {
8669
8845
  const { client: client2, linked, env } = await setup2(ctx, opts);
8670
8846
  const { st, out } = ctx;
8671
- const { results, install } = await generateLayer(client2, linked, env, provider, opts);
8847
+ const { results, install, dir, root } = await generateLayer(client2, linked, env, provider, opts);
8848
+ if (dir)
8849
+ out.write(st.dim(`Generating into ${dir} (the app package in this workspace; choose another with --dir).`));
8850
+ const shown = (path) => dir ? `${dir}/${path}` : path;
8672
8851
  for (const r of results) {
8673
8852
  const label = STATUS_LABEL[r.status];
8674
- out.write(`${r.status === "kept-your-edits" ? st.warn(label) : r.status === "unchanged" ? st.dim(label) : st.ok(label)} ${r.path}${r.status === "kept-your-edits" ? st.warn(" (you edited this; kept. Use --force to replace)") : r.status === "unchanged" ? st.dim(" (unchanged)") : ""}`);
8853
+ out.write(`${r.status === "kept-your-edits" ? st.warn(label) : r.status === "unchanged" ? st.dim(label) : st.ok(label)} ${shown(r.path)}${r.status === "kept-your-edits" ? st.warn(" (you edited this; kept. Use --force to replace)") : r.status === "unchanged" ? st.dim(" (unchanged)") : ""}`);
8675
8854
  }
8676
- const present = new Set((await discoverProject(linked.root)).dependencies);
8855
+ const present = new Set((await discoverProject(root)).dependencies);
8677
8856
  const missing = install.flatMap((i) => i.split(" ")).filter((p) => !present.has(p));
8678
8857
  if (missing.length)
8679
- out.write(st.dim(`Install the client librar${missing.length === 1 ? "y" : "ies"}: npm install ${missing.join(" ")}`));
8680
- out.write(st.dim('Use it: import { ahena } from "./src/ahena/client";'));
8858
+ out.write(st.dim(`Install the client librar${missing.length === 1 ? "y" : "ies"}${dir ? ` in ${dir}` : ""}: npm install ${missing.join(" ")}`));
8859
+ out.write(st.dim(`Use it: import { ahena } from "./src/ahena/client";${dir ? ` (from ${dir})` : ""}`));
8681
8860
  }
8682
8861
  async function dnsFromProvider(ctx, client2, ref, linked, source, target, withDmarc) {
8683
8862
  if (target !== "cloudflare")
@@ -8916,13 +9095,18 @@ async function diff(ctx, opts) {
8916
9095
  allowRisky = Boolean(opts.allowBillable);
8917
9096
  else
8918
9097
  allowRisky = await ctx.prompts.confirm({ message: `${risky.length} change${risky.length === 1 ? "" : "s"} may cost money or can't be undone. Include ${risky.length === 1 ? "it" : "them"}?`, default: false });
8919
- if (!allowRisky)
8920
- out.write(st.warn(`Skipping ${risky.length} billable/destructive change${risky.length === 1 ? "" : "s"}${opts.yes ? " (pass --allow-billable to include them)" : ""}.`));
9098
+ if (!allowRisky) {
9099
+ for (const p of changing.filter((x) => x.plan.actions.some((a) => a.classification === "BILLABLE" || a.classification === "DESTRUCTIVE"))) {
9100
+ out.write(st.warn(`Left out: ${titleCase(p.provider)} (${p.envName}). Approve the billable or destructive change to apply this provider's changes${opts.yes ? " (pass --allow-billable)" : ""}.`));
9101
+ }
9102
+ }
8921
9103
  }
8922
9104
  const touched = /* @__PURE__ */ new Map();
8923
9105
  let failures = 0;
8924
9106
  for (const p of changing) {
8925
- const approve = p.plan.actions.filter((a) => allowRisky || a.classification !== "BILLABLE" && a.classification !== "DESTRUCTIVE").map((a) => a.index);
9107
+ if (!allowRisky && p.plan.actions.some((a) => a.classification === "BILLABLE" || a.classification === "DESTRUCTIVE"))
9108
+ continue;
9109
+ const approve = p.plan.actions.map((a) => a.index);
8926
9110
  if (!approve.length)
8927
9111
  continue;
8928
9112
  try {
@@ -9157,7 +9341,9 @@ async function plan6(ctx, opts) {
9157
9341
  renderPlan(ctx, p);
9158
9342
  ctx.out.write("");
9159
9343
  }
9160
- ctx.out.write(`Apply: ${created.map((p) => ctx.st.accent(`ahena apply ${p.id}`)).join(" ")}`);
9344
+ const open2 = created.filter((p) => ["READY", "AWAITING_APPROVAL", "APPROVED"].includes(p.status) && p.operations.some((o) => o.kind === "provider" && o.status === "pending"));
9345
+ if (open2.length)
9346
+ ctx.out.write(`Apply: ${open2.map((p) => ctx.st.accent(`ahena apply ${p.id}`)).join(" ")}`);
9161
9347
  }
9162
9348
  function report(ctx, p, secrets) {
9163
9349
  const { out, st } = ctx;
@@ -9189,8 +9375,10 @@ ${st.dim(` ${o.error}`)}` : ""}`);
9189
9375
  }
9190
9376
  for (const o of by("skipped"))
9191
9377
  out.write(` ${st.dim("\xB7")} ${titleCase2(o.provider)}: ${o.description}${o.error ? st.dim(` (${o.error})`) : ""}`);
9192
- if (by("skipped").some((o) => o.error === "Not approved."))
9193
- out.write(st.dim(` Not approved changes stay pending: run ahena apply -e ${p.environment.slug} again to approve them.`));
9378
+ if (by("skipped").some((o) => o.error?.startsWith("Not approved"))) {
9379
+ const risky = by("skipped").some((o) => RISKY.has(o.classification));
9380
+ out.write(st.dim(` Changes that weren't approved didn't run: run ahena apply -e ${p.environment.slug} again${risky ? " and include the billable/destructive ones (--yes --allow-billable without prompts)" : " to approve them"}.`));
9381
+ }
9194
9382
  for (const s of secrets)
9195
9383
  out.write(` ${st.ok("\u2713")} Stored ${st.bold(s.name)} encrypted ${st.dim(`(${s.description})`)}`);
9196
9384
  const manual = by("manual");
@@ -9243,7 +9431,7 @@ async function apply(ctx, id, opts) {
9243
9431
  const runnable = target.operations.filter((o) => o.kind === "provider" && o.status === "pending");
9244
9432
  const ready = target.operations.some((o) => o.kind === "provider" && o.status === "approved");
9245
9433
  if (!runnable.length && !ready) {
9246
- if (!opts.json)
9434
+ if (!opts.json && target.operations.length)
9247
9435
  ctx.out.write(ctx.st.dim("Nothing Ahena can apply in this plan."));
9248
9436
  continue;
9249
9437
  }
@@ -9279,7 +9467,15 @@ async function approveTarget(ctx, client2, org, project2, target, runnable, opts
9279
9467
  if (risky.length) {
9280
9468
  includeRisky = opts.yes ? Boolean(opts.allowBillable) : await ctx.prompts.confirm({ message: `${risky.length} change${risky.length === 1 ? "" : "s"} may cost money or can't be undone. Include ${risky.length === 1 ? "it" : "them"}?`, default: false });
9281
9469
  }
9282
- if (!includeRisky && risky.length === runnable.length) {
9470
+ const held = new Set(includeRisky ? [] : risky.map((o) => o.provider));
9471
+ if (held.size && !opts.json) {
9472
+ for (const provider of held) {
9473
+ const left = risky.filter((o) => o.provider === provider);
9474
+ const kind = left.some((o) => o.classification === "DESTRUCTIVE") ? left.some((o) => o.classification === "BILLABLE") ? "billable and destructive" : "destructive" : "billable";
9475
+ ctx.out.write(ctx.st.warn(`Left out: ${titleCase2(provider)}'s changes. Approve the ${kind} change${left.length === 1 ? "" : "s"} to apply this provider's changes (${left.map((o) => o.description).join("; ")})${opts.yes ? ": pass --allow-billable" : ""}.`));
9476
+ }
9477
+ }
9478
+ if (runnable.every((o) => held.has(o.provider))) {
9283
9479
  await client2.cancelPlan(org, project2, target.id).catch(() => void 0);
9284
9480
  if (!opts.json) {
9285
9481
  ctx.out.write(`Nothing applied: ${risky.map((o) => `${titleCase2(o.provider)}: ${o.description}`).join("; ")} needs your approval.`);
@@ -9306,6 +9502,17 @@ async function approveTarget(ctx, client2, org, project2, target, runnable, opts
9306
9502
 
9307
9503
  // dist/commands/project.js
9308
9504
  var NEW_ORG = "__new__";
9505
+ async function packageName(dir) {
9506
+ try {
9507
+ const name = JSON.parse(await readFile10(join10(dir, "package.json"), "utf8")).name;
9508
+ if (typeof name !== "string")
9509
+ return void 0;
9510
+ const bare = name.replace(/^@[^/]+\//, "").trim();
9511
+ return bare && slugSchema.safeParse(slugify(bare)).success ? bare : void 0;
9512
+ } catch {
9513
+ return void 0;
9514
+ }
9515
+ }
9309
9516
  async function chooseOrganization(ctx, client2, flag) {
9310
9517
  const { organizations } = await client2.listOrganizations();
9311
9518
  if (flag) {
@@ -9333,6 +9540,9 @@ async function init(ctx, opts) {
9333
9540
  const existing = await findConfigFile(ctx.cwd);
9334
9541
  if (existing)
9335
9542
  throw new CliError(`This directory is already part of an Ahena project (${existing}).`, "Run `ahena status` to see it.");
9543
+ if (ctx.canPrompt === false && !opts.yes) {
9544
+ throw new CliError("ahena init asks questions, and this isn't an interactive terminal.", "Pass --yes to accept the defaults, plus --recipe <id> or --import to choose the stack, e.g. ahena init --yes --import");
9545
+ }
9336
9546
  let recipe = opts.recipe ? requireRecipe(opts.recipe) : void 0;
9337
9547
  const guided = !opts.yes && !opts.recipe && ctx.out.interactive;
9338
9548
  if (recipe)
@@ -9342,7 +9552,8 @@ async function init(ctx, opts) {
9342
9552
  out.write(st.dim("The control plane for your application stack."));
9343
9553
  out.write("");
9344
9554
  const org = await chooseOrganization(ctx, client2, opts.org);
9345
- const name = opts.name ?? (opts.yes ? basename3(ctx.cwd) : await ctx.prompts.input({ message: "Project name", default: basename3(ctx.cwd), validate: (v) => v.trim() ? true : "Required" }));
9555
+ const suggested = await packageName(ctx.cwd) ?? basename3(ctx.cwd);
9556
+ const name = opts.name ?? (opts.yes ? suggested : await ctx.prompts.input({ message: "Project name", default: suggested, validate: (v) => v.trim() ? true : "Required" }));
9346
9557
  const slug = opts.project ?? slugify(name);
9347
9558
  if (!slugSchema.safeParse(slug).success)
9348
9559
  throw new CliError(`"${slug}" isn't a valid project slug.`, "Use lowercase letters, numbers and dashes.");
@@ -9687,7 +9898,7 @@ function createAhenaMcpServer({ client: client2, workspace: ws, allowWrite = fal
9687
9898
  planId,
9688
9899
  proposed,
9689
9900
  expiresAt: approval.expiresAt,
9690
- message: `Nothing was changed. These changes must be approved by the developer in the Ahena dashboard. Ask the developer to approve it in the Ahena dashboard: ${approval.url}, then call apply_plan { "plan_id": "${planId}" } again.`
9901
+ message: `Nothing was changed. ${approval.previouslyDeclinedAt ? "The developer declined these changes earlier; this is a new request, so tell them that. " : ""}These changes must be approved by the developer in the Ahena dashboard. Ask the developer to approve it in the Ahena dashboard: ${approval.url}, then call apply_plan { "plan_id": "${planId}" } again.`
9691
9902
  });
9692
9903
  const askDashboard = async (slug, env, provider, desired, proposed) => {
9693
9904
  const { plan: stored } = await client2.createPlan(ref(slug), { title: `${provider} in ${env.name}, requested by an AI agent`, providers: [{ provider, desired }] });
@@ -10134,27 +10345,11 @@ async function mcp(ctx, opts) {
10134
10345
  }
10135
10346
 
10136
10347
  // dist/commands/features.js
10137
- import { readFile as readFile10, readdir as readdir4, writeFile as writeFile7, mkdir as mkdir4 } from "node:fs/promises";
10138
- import { basename as basename4, join as join9 } from "node:path";
10139
-
10140
- // dist/detect.js
10141
- import { access as access3, readFile as readFile9 } from "node:fs/promises";
10142
- import { join as join8 } from "node:path";
10143
- var exists3 = (p) => access3(p).then(() => true, () => false);
10144
- async function detectFramework(root) {
10145
- let nextjs = false;
10146
- try {
10147
- const pkg = JSON.parse(await readFile9(join8(root, "package.json"), "utf8"));
10148
- nextjs = Boolean(pkg.dependencies?.next ?? pkg.devDependencies?.next);
10149
- } catch {
10150
- }
10151
- return { framework: nextjs ? "nextjs" : "node", appDir: await exists3(join8(root, "src/app")) ? "src/app" : "app" };
10152
- }
10153
-
10154
- // dist/commands/features.js
10348
+ import { readFile as readFile11, readdir as readdir4, writeFile as writeFile7, mkdir as mkdir4 } from "node:fs/promises";
10349
+ import { basename as basename4, join as join11 } from "node:path";
10155
10350
  var stamp = (d) => d.toISOString().replace(/[-:T]/g, "").slice(0, 14);
10156
10351
  async function writeMigrations(root, migrations2, now, dryRun) {
10157
- const dir = join9(root, "supabase", "migrations");
10352
+ const dir = join11(root, "supabase", "migrations");
10158
10353
  const present = await readdir4(dir).catch(() => []);
10159
10354
  const out = [];
10160
10355
  let t = now.getTime();
@@ -10168,7 +10363,7 @@ async function writeMigrations(root, migrations2, now, dryRun) {
10168
10363
  t += 1e3;
10169
10364
  if (!dryRun) {
10170
10365
  await mkdir4(dir, { recursive: true });
10171
- await writeFile7(join9(dir, name), m.sql, { flag: "wx" });
10366
+ await writeFile7(join11(dir, name), m.sql, { flag: "wx" });
10172
10367
  }
10173
10368
  out.push({ path: `supabase/migrations/${name}`, status: dryRun ? "would-write" : "created" });
10174
10369
  }
@@ -10194,7 +10389,7 @@ async function saveFeatures(ctx, linked, features, yes) {
10194
10389
  ctx.out.write(ctx.st.warn(`Add features: ${JSON.stringify(features)} to ${basename4(linked.configPath)} so teammates and CI know which packs are installed.`));
10195
10390
  return;
10196
10391
  }
10197
- const before = await readFile10(linked.configPath, "utf8");
10392
+ const before = await readFile11(linked.configPath, "utf8");
10198
10393
  const next = renderConfig({ ...linked.config, features });
10199
10394
  if (before === next)
10200
10395
  return;
@@ -10225,25 +10420,26 @@ async function addFeatures(ctx, ids, opts) {
10225
10420
  if (problems.length)
10226
10421
  throw new CliError(`Can't add ${ids.join(", ")} yet:
10227
10422
  ${problems.map((p) => ` \u2022 ${p}`).join("\n")}`, "Update ahena.config.ts (or start from a recipe: ahena recipe saas --apply), then run this again.");
10228
- const { framework, appDir } = await detectFramework(linked.root);
10423
+ const target = await generationTarget(linked);
10424
+ const { framework, appDir } = await detectFramework(target.root);
10229
10425
  const plan7 = planFeatures(packs, { framework, appDir }, installed);
10230
10426
  const { out, st } = ctx;
10231
10427
  const added = packs.filter((p) => !installed.includes(p.id));
10232
10428
  out.write(`${st.bold(`Adding ${packs.map((p) => p.id).join(", ")}`)} ${st.dim(`(${framework === "nextjs" ? `Next.js, routes in ${appDir}/api/ahena` : "helpers only, no routes"})`)}`);
10233
10429
  out.write("");
10234
10430
  const migrations2 = await writeMigrations(linked.root, plan7.migrations, /* @__PURE__ */ new Date(), Boolean(opts.dryRun));
10235
- const files = await writeGenerated(linked.root, plan7.files, { ...opts.force ? { force: true } : {}, ...opts.dryRun ? { dryRun: true } : {} });
10431
+ const files = (await writeGenerated(target.root, plan7.files, { ...opts.force ? { force: true } : {}, ...opts.dryRun ? { dryRun: true } : {} })).map((f) => ({ ...f, path: target.dir ? `${target.dir}/${f.path}` : f.path }));
10236
10432
  for (const r of [...migrations2, ...files]) {
10237
10433
  const note = r.status === "kept-your-edits" ? st.warn(" (you edited this; kept. Use --force to replace)") : r.status === "exists" ? st.dim(" (already created; migrations are never rewritten)") : r.status === "unchanged" ? st.dim(" (unchanged)") : "";
10238
10434
  out.write(`${r.status === "kept-your-edits" ? st.warn(LABEL[r.status]) : r.status === "unchanged" || r.status === "exists" ? st.dim(LABEL[r.status]) : st.ok(LABEL[r.status])} ${r.path}${note}`);
10239
10435
  }
10240
- if (files.some((f) => f.path === "src/ahena/payments/events.ts" && f.status === "kept-your-edits")) {
10436
+ if (files.some((f) => f.path.endsWith("src/ahena/payments/events.ts") && f.status === "kept-your-edits")) {
10241
10437
  out.write(st.warn(` Wire the packs' handleStripeEvent into src/ahena/payments/events.ts yourself (${plan7.packs.filter((p) => p.stripeEvents).map((p) => `features/${p.id}`).join(", ")}).`));
10242
10438
  }
10243
10439
  if (!opts.dryRun) {
10244
10440
  if (plan7.env.length) {
10245
- const path = join9(linked.root, ".env.example");
10246
- const current = await readFile10(path, "utf8").catch(() => "") ?? "";
10441
+ const path = join11(target.root, ".env.example");
10442
+ const current = await readFile11(path, "utf8").catch(() => "") ?? "";
10247
10443
  await writeFile7(path, mergeEnvExample(current, "features", plan7.env.map((e) => `# ${e.comment}
10248
10444
  ${e.name}=`).join("\n")));
10249
10445
  }
@@ -10262,9 +10458,31 @@ ${e.name}=`).join("\n")));
10262
10458
  }
10263
10459
 
10264
10460
  // dist/commands/intent.js
10265
- import { readFile as readFile11, writeFile as writeFile8 } from "node:fs/promises";
10461
+ import { readFile as readFile12, writeFile as writeFile8 } from "node:fs/promises";
10266
10462
  import { basename as basename5 } from "node:path";
10267
10463
  var isCapability = (name) => isCapabilityKey(name);
10464
+ var isPlainObject = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
10465
+ function fillMissing(section2, settings, prefix = "") {
10466
+ const merged = { ...section2 };
10467
+ const added = [];
10468
+ for (const [key, value] of Object.entries(settings)) {
10469
+ const path = `${prefix}${key}`;
10470
+ if (merged[key] === void 0) {
10471
+ merged[key] = value;
10472
+ if (isPlainObject(value))
10473
+ added.push(...leaves(value, `${path}.`));
10474
+ else
10475
+ added.push(path);
10476
+ } else if (isPlainObject(merged[key]) && isPlainObject(value)) {
10477
+ const inner = fillMissing(merged[key], value, `${path}.`);
10478
+ merged[key] = inner.merged;
10479
+ added.push(...inner.added);
10480
+ }
10481
+ }
10482
+ return { merged, added };
10483
+ }
10484
+ var leaves = (value, prefix) => Object.entries(value).flatMap(([k, v]) => isPlainObject(v) ? leaves(v, `${prefix}${k}.`) : [`${prefix}${k}`]);
10485
+ var valueAt = (obj, path) => path.split(".").reduce((o, k) => isPlainObject(o) ? o[k] : void 0, obj);
10268
10486
  async function addCapabilities(ctx, names, opts) {
10269
10487
  let linked = await requireProject(ctx.cwd);
10270
10488
  const { out, st } = ctx;
@@ -10272,11 +10490,20 @@ async function addCapabilities(ctx, names, opts) {
10272
10490
  const use = parseUse(opts.use);
10273
10491
  let config = linked.config;
10274
10492
  const intents = [];
10493
+ const filled = /* @__PURE__ */ new Map();
10275
10494
  for (const capability3 of names) {
10276
10495
  try {
10277
10496
  const intent = planCapability(config, capability3, { inputs: opts.domain ? { domain: opts.domain } : {}, ...use[capability3] ? { use: use[capability3] } : {}, registry: localRegistry() });
10278
10497
  intents.push(intent);
10279
10498
  config = intent.application.config;
10499
+ const section2 = config[capability3];
10500
+ if (intent.alreadyDeclared && isPlainObject(section2)) {
10501
+ const { merged, added } = fillMissing(section2, intent.choice.settings);
10502
+ if (added.length) {
10503
+ config = { ...config, [capability3]: merged };
10504
+ filled.set(capability3, added);
10505
+ }
10506
+ }
10280
10507
  } catch (err) {
10281
10508
  if (err instanceof RecipeSelectionError)
10282
10509
  throw new CliError(err.message);
@@ -10295,8 +10522,12 @@ async function addCapabilities(ctx, names, opts) {
10295
10522
  if (!i.alreadyDeclared)
10296
10523
  for (const [k, v] of settings)
10297
10524
  out.write(st.dim(` ${k}: ${JSON.stringify(v)}`));
10525
+ for (const path of filled.get(i.capability) ?? [])
10526
+ out.write(`${st.ok("+")} Declare ${st.bold(`${i.capability}.${path}`)}: ${JSON.stringify(valueAt(config[i.capability], path))}`);
10527
+ if (i.needs.length)
10528
+ out.write(st.dim(` What ${i.capability} takes end to end (the plan below shows what Ahena changes in ${env} now):`));
10298
10529
  for (const n of i.needs)
10299
- out.write(` \u2022 ${n}`);
10530
+ out.write(st.dim(` \u2022 ${n}`));
10300
10531
  for (const t of i.choice.todo)
10301
10532
  out.write(st.warn(` ! ${t}`));
10302
10533
  if (i.choice.cost)
@@ -10305,16 +10536,16 @@ async function addCapabilities(ctx, names, opts) {
10305
10536
  out.write(st.dim(` Backend code for it: ahena add ${i.suggestedPacks.join(" ")}`));
10306
10537
  }
10307
10538
  out.write("");
10308
- const changed = intents.some((i) => !i.alreadyDeclared);
10539
+ const changed = intents.some((i) => !i.alreadyDeclared) || filled.size > 0;
10309
10540
  if (changed) {
10310
- const before = await readFile11(linked.configPath, "utf8");
10541
+ const before = await readFile12(linked.configPath, "utf8");
10311
10542
  const reformat = before !== renderConfig(linked.config);
10312
10543
  if (!linked.configPath.endsWith(".ts"))
10313
10544
  throw new CliError(`Add these sections to ${basename5(linked.configPath)} yourself, then run ahena apply.`);
10314
10545
  if (!opts.yes) {
10315
10546
  if (reformat)
10316
10547
  out.write(st.warn(`${basename5(linked.configPath)} will be rewritten in Ahena's format (comments are lost).`));
10317
- if (!await ctx.prompts.confirm({ message: `Declare ${names.join(", ")} in ${basename5(linked.configPath)}?`, default: true }))
10548
+ if (!await ctx.prompts.confirm({ message: `${intents.every((i) => i.alreadyDeclared) ? "Update" : "Declare"} ${names.join(", ")} in ${basename5(linked.configPath)}?`, default: true }))
10318
10549
  throw new CliError("Nothing was changed.");
10319
10550
  }
10320
10551
  await writeFile8(linked.configPath, renderConfig(config));
@@ -10339,6 +10570,16 @@ async function addCapabilities(ctx, names, opts) {
10339
10570
  }
10340
10571
  out.write("");
10341
10572
  await apply(ctx, void 0, { ...opts.apiUrl ? { apiUrl: opts.apiUrl } : {}, env, ...opts.yes ? { yes: true } : {}, ...opts.allowBillable ? { allowBillable: true } : {}, ...opts.wait === false ? { wait: false } : {} });
10573
+ const elsewhere = /* @__PURE__ */ new Set();
10574
+ for (const i of intents) {
10575
+ const declared = [...filled.get(i.capability) ?? [], ...i.alreadyDeclared ? [] : leaves(i.choice.settings, "")];
10576
+ for (const path of declared)
10577
+ for (const part of path.split("."))
10578
+ if (part !== env && project2.environments.some((e) => e.slug === part))
10579
+ elsewhere.add(part);
10580
+ }
10581
+ for (const slug of elsewhere)
10582
+ out.write(`Declared for ${slug} too: apply it there with ${st.accent(`ahena apply -e ${slug}`)}`);
10342
10583
  const packs = [...new Set(intents.flatMap((i) => i.suggestedPacks))];
10343
10584
  if (packs.length && !opts.yes && await ctx.prompts.confirm({ message: `Also add the backend code for it (${packs.join(", ")})?`, default: false })) {
10344
10585
  out.write("");
@@ -10348,8 +10589,8 @@ async function addCapabilities(ctx, names, opts) {
10348
10589
 
10349
10590
  // dist/commands/switch.js
10350
10591
  import { execFile as execFile2 } from "node:child_process";
10351
- import { access as access4, mkdir as mkdir5, readdir as readdir5, readFile as readFile12, writeFile as writeFile9 } from "node:fs/promises";
10352
- import { basename as basename6, join as join10, relative as relative3 } from "node:path";
10592
+ import { access as access4, mkdir as mkdir5, readdir as readdir5, readFile as readFile13, writeFile as writeFile9 } from "node:fs/promises";
10593
+ import { basename as basename6, join as join12, relative as relative3 } from "node:path";
10353
10594
  import { promisify as promisify2 } from "node:util";
10354
10595
  var ALIASES = { "cloudflare-r2": "cloudflare" };
10355
10596
  var norm = (id) => ALIASES[id] ?? id;
@@ -10370,14 +10611,14 @@ async function findRawUsages(root, capability3, from) {
10370
10611
  const hits = [];
10371
10612
  const walk = async (dir) => {
10372
10613
  for (const entry2 of await readdir5(dir, { withFileTypes: true }).catch(() => [])) {
10373
- const full = join10(dir, entry2.name);
10614
+ const full = join12(dir, entry2.name);
10374
10615
  const rel = relative3(root, full);
10375
10616
  if (entry2.isDirectory()) {
10376
- if (["node_modules", ".git", ".next", "dist", "build", ".ahena"].includes(entry2.name) || rel === join10("src", "ahena"))
10617
+ if (["node_modules", ".git", ".next", "dist", "build", ".ahena"].includes(entry2.name) || rel === join12("src", "ahena"))
10377
10618
  continue;
10378
10619
  await walk(full);
10379
10620
  } else if (/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(entry2.name)) {
10380
- const lines = (await readFile12(full, "utf8")).split("\n");
10621
+ const lines = (await readFile13(full, "utf8")).split("\n");
10381
10622
  lines.forEach((line, i) => {
10382
10623
  if (patterns.some((p) => p.test(line)))
10383
10624
  hits.push(`${rel}:${i + 1}`);
@@ -10386,7 +10627,7 @@ async function findRawUsages(root, capability3, from) {
10386
10627
  }
10387
10628
  };
10388
10629
  for (const top of ["src", "app", "lib", "pages", "server"])
10389
- await walk(join10(root, top));
10630
+ await walk(join12(root, top));
10390
10631
  return hits.slice(0, 50);
10391
10632
  }
10392
10633
  var LABEL2 = {
@@ -10532,7 +10773,7 @@ async function switchProvider(ctx, capability3, fromRaw, toRaw, opts) {
10532
10773
  let tests = null;
10533
10774
  let testsOk = true;
10534
10775
  if (opts.test) {
10535
- const pm = await access4(join10(linked.root, "pnpm-lock.yaml")).then(() => "pnpm", () => null) ?? await access4(join10(linked.root, "yarn.lock")).then(() => "yarn", () => null) ?? "npm";
10776
+ const pm = await access4(join12(linked.root, "pnpm-lock.yaml")).then(() => "pnpm", () => null) ?? await access4(join12(linked.root, "yarn.lock")).then(() => "yarn", () => null) ?? "npm";
10536
10777
  try {
10537
10778
  await promisify2(execFile2)(pm, ["test"], { cwd: linked.root, timeout: 10 * 6e4, maxBuffer: 10 * 1024 * 1024 });
10538
10779
  tests = `\`${pm} test\` passed`;
@@ -10549,8 +10790,8 @@ ${tail}
10549
10790
  }
10550
10791
  }
10551
10792
  const name = `${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}-${capability3}-${from}-to-${to}.md`;
10552
- const reportPath = join10(linked.root, "docs", "ahena", "switches", name);
10553
- await mkdir5(join10(linked.root, "docs", "ahena", "switches"), { recursive: true });
10793
+ const reportPath = join12(linked.root, "docs", "ahena", "switches", name);
10794
+ await mkdir5(join12(linked.root, "docs", "ahena", "switches"), { recursive: true });
10554
10795
  await writeFile9(reportPath, reportMarkdown({ capability: capability3, from, to, environments: envs.map((e) => e.slug), assessment, done, doctor: doctor5, tests }));
10555
10796
  out.write("");
10556
10797
  for (const d of done)
@@ -10571,7 +10812,7 @@ ${tail}
10571
10812
 
10572
10813
  // dist/commands/ci.js
10573
10814
  import { appendFile, mkdir as mkdir6, writeFile as writeFile10 } from "node:fs/promises";
10574
- import { dirname as dirname5, join as join11, relative as relative4 } from "node:path";
10815
+ import { dirname as dirname5, join as join13, relative as relative4 } from "node:path";
10575
10816
  function categorize(check) {
10576
10817
  const s = `${check.id} ${check.title}`.toLowerCase();
10577
10818
  if (/drift/.test(s))
@@ -10605,8 +10846,11 @@ async function deployCheck(ctx, opts) {
10605
10846
  try {
10606
10847
  project2 = (await client2.getProject(linked.config.organization, linked.config.project)).project;
10607
10848
  } catch (err) {
10608
- if (err instanceof AhenaApiError && (err.status === 404 || err.status === 401)) {
10609
- throw new CliError(`Can't read ${linked.config.organization}/${linked.config.project} with this token.`, "In CI, set AHENA_TOKEN to a token from `ahena ci token create`.");
10849
+ if (err instanceof AhenaApiError && err.status === 401) {
10850
+ throw new CliError(`Ahena didn't accept this token${ctx.env.AHENA_TOKEN ? " (AHENA_TOKEN)" : ""}: it was revoked, has expired, or isn't a valid token.`, "Create a new one with `ahena ci token create` and update the AHENA_TOKEN secret.");
10851
+ }
10852
+ if (err instanceof AhenaApiError && err.status === 404) {
10853
+ throw new CliError(`Can't read ${linked.config.organization}/${linked.config.project} with this token.`, "CI tokens read only the project they were created for. In CI, set AHENA_TOKEN to a token from `ahena ci token create` run in this project.");
10610
10854
  }
10611
10855
  throw err;
10612
10856
  }
@@ -10672,14 +10916,15 @@ async function deployCheck(ctx, opts) {
10672
10916
  out.write(ready ? `${st.ok("\u2713")} ${env.name} is ready to deploy.` : `${st.bad("\u2715")} Not ready: ${blockers.length} blocker${blockers.length === 1 ? "" : "s"}. Run \`ahena doctor -e ${slug}\` for details.`);
10673
10917
  }
10674
10918
  if (!ready)
10675
- throw new CliError("Deploy check failed.", void 0, 1);
10919
+ throw new CliError(format === "github" && !opts.json ? "" : "Deploy check failed.", void 0, 1);
10676
10920
  }
10921
+ var MAX_CI_TOKEN_DAYS = 365;
10677
10922
  async function createCiToken(ctx, opts) {
10678
10923
  const linked = await requireProject(ctx.cwd);
10679
10924
  const { client: client2 } = await authedClient(ctx, opts.apiUrl);
10680
10925
  const days = opts.days ? Number(opts.days) : 90;
10681
- if (!Number.isInteger(days))
10682
- throw new CliError("--days must be a whole number.");
10926
+ if (!Number.isInteger(days) || days < 1 || days > MAX_CI_TOKEN_DAYS)
10927
+ throw new CliError(`--days must be a whole number from 1 to ${MAX_CI_TOKEN_DAYS}: a CI token lasts at most a year.`, "Create a new token before it expires; `ahena ci token list` shows when each one does.");
10683
10928
  const res = await client2.createCiToken(linked.config.organization, linked.config.project, { label: opts.label ?? "CI", days });
10684
10929
  if (opts.raw) {
10685
10930
  ctx.out.write(res.token);
@@ -10748,21 +10993,51 @@ jobs:
10748
10993
  AHENA_API_URL: ${opts.apiUrl}` : ""}
10749
10994
  `;
10750
10995
  }
10996
+ function reachableFromCi(raw) {
10997
+ let url;
10998
+ try {
10999
+ url = new URL(raw);
11000
+ } catch {
11001
+ return false;
11002
+ }
11003
+ if (url.protocol !== "https:")
11004
+ return false;
11005
+ const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
11006
+ if (host === "localhost" || /\.(localhost|local|internal|lan|home\.arpa|test|invalid)$/.test(host) || !host.includes(".") && !host.includes(":"))
11007
+ return false;
11008
+ if (host.includes(":") && (host === "::1" || /^(fe80|fc|fd)/.test(host)))
11009
+ return false;
11010
+ const v4 = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(host);
11011
+ if (v4) {
11012
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
11013
+ if (a === 127 || a === 10 || a === 0 || a === 169 && b === 254 || a === 192 && b === 168 || a === 172 && b >= 16 && b <= 31 || a === 100 && b >= 64 && b <= 127)
11014
+ return false;
11015
+ }
11016
+ return true;
11017
+ }
10751
11018
  async function initCi(ctx, opts) {
10752
11019
  const linked = await requireProject(ctx.cwd);
10753
11020
  const environment = opts.environment ?? "production";
10754
11021
  if (!/^[a-z0-9-]{1,48}$/.test(environment))
10755
11022
  throw new CliError(`"${environment}" isn't an environment slug.`);
10756
11023
  const stored = await ctx.store.load();
10757
- const apiUrl = opts.apiUrl ?? ctx.env.AHENA_API_URL ?? stored?.apiUrl;
10758
- const path = join11(linked.root, ".github", "workflows", "ahena-deploy-check.yml");
11024
+ const { out, st } = ctx;
11025
+ let apiUrl = opts.apiUrl ?? ctx.env.AHENA_API_URL ?? stored?.apiUrl;
11026
+ if (apiUrl && !reachableFromCi(apiUrl)) {
11027
+ if (opts.apiUrl)
11028
+ throw new CliError(`CI runners can't reach ${opts.apiUrl}: it's a local or private address.`, "Pass the public https:// URL of your Ahena API, or leave --api-url out to use Ahena's.");
11029
+ out.write(st.warn(`This CLI talks to ${apiUrl}, which a CI runner can't reach. The workflow uses Ahena's API (${DEFAULT_API_URL}); pass --api-url <public https URL> to use another.`));
11030
+ apiUrl = void 0;
11031
+ }
11032
+ if (apiUrl && apiUrl.replace(/\/+$/, "") === DEFAULT_API_URL)
11033
+ apiUrl = void 0;
11034
+ const path = join13(linked.root, ".github", "workflows", "ahena-deploy-check.yml");
10759
11035
  await mkdir6(dirname5(path), { recursive: true });
10760
11036
  await writeFile10(path, githubWorkflow({ environment, ...apiUrl ? { apiUrl } : {}, cliVersion: ctx.version }), { flag: opts.force ? "w" : "wx" }).catch((err) => {
10761
11037
  if (err.code === "EEXIST")
10762
11038
  throw new CliError(`${relative4(linked.root, path)} already exists.`, "Use --force to replace it.");
10763
11039
  throw err;
10764
11040
  });
10765
- const { out, st } = ctx;
10766
11041
  out.write(`${st.ok("\u2713")} Wrote ${relative4(linked.root, path)}`);
10767
11042
  out.write("Next:");
10768
11043
  out.write(` ${st.accent("ahena ci token create --raw | gh secret set AHENA_TOKEN")}`);
@@ -10820,7 +11095,7 @@ function buildProgram(ctx) {
10820
11095
  return addFeatures(ctx, items, o);
10821
11096
  });
10822
11097
  program.command("switch <capability> <from> <to>").description("Switch a capability to another provider, e.g. ahena switch ai ollama openai -e production").option("-e, --env <environment>", "Only this environment (default: every environment using <from>)").option("--model <model>", "With ai: the model to use with the new provider").option("--dry-run", "Classify and report without changing anything").option("--test", "Run your project's tests afterwards").option("-y, --yes", "Don't ask for confirmation").action((capability3, from, to, o) => switchProvider(ctx, capability3, from, to, { ...g(), ...o }));
10823
- program.command("generate [provider]").description("Generate the integration layer into src/ahena/ (all connected providers, or one)").option(...envOpt).option("--framework <name>", "nextjs | node").option("--force", "Replace files you edited since the last generate").option("--dry-run", "Show what would be written").action((provider, o) => generate9(ctx, provider, { ...g(), ...o }));
11098
+ program.command("generate [provider]").description("Generate the integration layer into src/ahena/ (all connected providers, or one)").option(...envOpt).option("--framework <name>", "nextjs | node (default: detected from the app package)").option("--dir <path>", "Workspace package to generate into (default: the app package, e.g. apps/web in a monorepo)").option("--force", "Replace files you edited since the last generate").option("--dry-run", "Show what would be written").action((provider, o) => generate9(ctx, provider, { ...g(), ...o }));
10824
11099
  return program;
10825
11100
  }
10826
11101
 
@@ -10833,7 +11108,8 @@ async function run2(ctx, argv) {
10833
11108
  if (err instanceof CommanderError)
10834
11109
  return err.exitCode;
10835
11110
  const { message, hint, exitCode } = describeError(err);
10836
- ctx.out.error(`${ctx.st.bad("\u2715")} ${message}`);
11111
+ if (message)
11112
+ ctx.out.error(`${exitCode === AWAITING_APPROVAL_EXIT ? ctx.st.warn("\u2026") : ctx.st.bad("\u2715")} ${message}`);
10837
11113
  if (hint)
10838
11114
  ctx.out.error(ctx.st.dim(` ${hint}`));
10839
11115
  return exitCode;
package/package.json CHANGED
@@ -3,9 +3,9 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.0-beta.1",
6
+ "version": "0.1.0-beta.2",
7
7
  "description": "Ahena: the control plane for your application stack. Connect, configure, verify and diagnose Supabase, Cloudflare, Resend, Stripe, Firebase and AI providers with your own accounts.",
8
- "license": "SEE LICENSE IN LICENSE",
8
+ "license": "Apache-2.0",
9
9
  "type": "module",
10
10
  "bin": {
11
11
  "ahena": "dist/ahena.mjs"