@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.
- package/LICENSE +198 -9
- package/README.md +3 -2
- package/dist/ahena.mjs +416 -140
- package/package.json +2 -2
package/LICENSE
CHANGED
|
@@ -1,12 +1,201 @@
|
|
|
1
|
-
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
2
4
|
|
|
3
|
-
|
|
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
|
-
|
|
7
|
+
1. Definitions.
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
-
|
|
8390
|
-
|
|
8391
|
-
|
|
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
|
-
|
|
8401
|
-
|
|
8402
|
-
|
|
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
|
|
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
|
|
8620
|
-
throw new CliError("Refusing billable or destructive changes without explicit approval.",
|
|
8621
|
-
}
|
|
8622
|
-
|
|
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 ?? {},
|
|
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(
|
|
8665
|
-
const results = await writeGenerated(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
9193
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
10138
|
-
import { basename as basename4, join as
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
10246
|
-
const current = await
|
|
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
|
|
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(`
|
|
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
|
|
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:
|
|
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
|
|
10352
|
-
import { basename as basename6, join as
|
|
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 =
|
|
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 ===
|
|
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
|
|
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(
|
|
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(
|
|
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 =
|
|
10553
|
-
await mkdir5(
|
|
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
|
|
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 &&
|
|
10609
|
-
throw new CliError(`
|
|
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(
|
|
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
|
|
10758
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
8
|
+
"license": "Apache-2.0",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"bin": {
|
|
11
11
|
"ahena": "dist/ahena.mjs"
|