@myapihq/cli 2.3.1 → 2.4.0
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/dist/commands/account.d.ts +1 -1
- package/dist/commands/account.js +1 -0
- package/dist/commands/authproduct.js +7 -3
- package/dist/commands/billing.js +7 -2
- package/dist/commands/container.js +4 -3
- package/dist/commands/database.js +4 -3
- package/dist/commands/domain.js +39 -14
- package/dist/commands/email/message.js +6 -3
- package/dist/commands/email/template.js +3 -3
- package/dist/commands/fn.js +4 -3
- package/dist/commands/funnel.js +33 -13
- package/dist/commands/git.js +108 -35
- package/dist/commands/image.js +7 -4
- package/dist/commands/keys-validation.test.js +5 -0
- package/dist/commands/keys.js +6 -2
- package/dist/commands/llm.js +14 -10
- package/dist/commands/login-validation.test.d.ts +1 -0
- package/dist/commands/login-validation.test.js +43 -0
- package/dist/commands/login.d.ts +14 -0
- package/dist/commands/login.js +447 -0
- package/dist/commands/org.d.ts +1 -1
- package/dist/commands/org.js +24 -7
- package/dist/commands/queue.js +4 -3
- package/dist/commands/setup.js +6 -1
- package/dist/commands/storage.js +1 -1
- package/dist/commands/workflow.js +5 -4
- package/dist/completion.js +3 -3
- package/dist/config.js +3 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +62 -0
- package/dist/errors.test.d.ts +1 -0
- package/dist/errors.test.js +28 -0
- package/dist/exposes.test.js +1 -0
- package/dist/flags.js +5 -3
- package/dist/flags.test.js +1 -1
- package/dist/helpers.d.ts +1 -0
- package/dist/helpers.js +22 -0
- package/dist/index.js +10 -51
- package/dist/skills/my-api-hq/SKILL.md +2 -2
- package/dist/skills/my-domain-api/SKILL.md +5 -5
- package/dist/skills/my-email-verify-api/SKILL.md +6 -4
- package/dist/skills/my-funnel-api/SKILL.md +5 -4
- package/dist/skills/my-git-api/SKILL.md +14 -7
- package/dist/skills/my-image-api/SKILL.md +1 -1
- package/dist/skills/my-storage-api/SKILL.md +5 -5
- package/dist/skills/my-webhook-api/SKILL.md +1 -1
- package/dist/skills/my-workflow-api/SKILL.md +1 -1
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +13 -1
- package/package.json +3 -2
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// `myapi login` — browser-based operator sign-in (Google via my-auth-api).
|
|
2
|
+
//
|
|
3
|
+
// Flow (decision-operator-login-google-2026-07-24): open the hosted login page
|
|
4
|
+
// of the MyAPI operator tenant on my-auth-api, complete Google (or email-code)
|
|
5
|
+
// sign-in in the browser, receive the OIDC code on a loopback redirect
|
|
6
|
+
// (RFC 8252), exchange the code for tokens at the tenant's /token endpoint
|
|
7
|
+
// (PKCE), then exchange the resulting id_token at `POST /hq/account/exchange`
|
|
8
|
+
// for the hq API key — saved to ~/.myapi/config.json like `account setup`.
|
|
9
|
+
//
|
|
10
|
+
// `--mock` still runs the whole browser UX against an in-process fake of the
|
|
11
|
+
// hosted login page (real loopback server, real PKCE, fake identity, persists
|
|
12
|
+
// nothing) for offline UX previews and demos.
|
|
13
|
+
import * as http from 'http';
|
|
14
|
+
import * as crypto from 'crypto';
|
|
15
|
+
import { spawn } from 'child_process';
|
|
16
|
+
import { info, success, error } from '../output.js';
|
|
17
|
+
import { addAccount } from '../config.js';
|
|
18
|
+
export const EXPOSES = [
|
|
19
|
+
'POST /hq/account/exchange',
|
|
20
|
+
];
|
|
21
|
+
export const SCHEMA = {
|
|
22
|
+
mock: 'boolean',
|
|
23
|
+
'no-browser': 'boolean',
|
|
24
|
+
};
|
|
25
|
+
export const HELP = `Usage: myapi login [--no-browser] [--mock]
|
|
26
|
+
|
|
27
|
+
Signs in to MyAPI in your browser — Google, or an email code — and stores the
|
|
28
|
+
session in ~/.myapi/config.json. The account created/linked here is the same
|
|
29
|
+
account model as \`myapi account setup\`; anonymous setup stays available and
|
|
30
|
+
unchanged.
|
|
31
|
+
|
|
32
|
+
Flags:
|
|
33
|
+
--no-browser Print the sign-in URL instead of opening a browser
|
|
34
|
+
--mock Run the flow against an in-process mock (offline UX preview;
|
|
35
|
+
nothing is saved, no network calls leave your machine)`;
|
|
36
|
+
// Operator tenant + native CLI client on my-auth-api (public, non-secret ids;
|
|
37
|
+
// overridable for staging). Must match the backend's OPERATOR_AUTH_TENANT /
|
|
38
|
+
// OPERATOR_AUTH_CLIENT_ID (auto-provisioned by EnsureOperatorTenant).
|
|
39
|
+
const OPERATOR_TENANT = process.env.MYAPI_OPERATOR_TENANT ?? '75e48426-1e8d-45c7-9ab3-b3b7d6fd216d';
|
|
40
|
+
const OPERATOR_CLIENT_ID = process.env.MYAPI_OPERATOR_CLIENT_ID ?? 'cl_operator_cli';
|
|
41
|
+
function authBase() { return process.env.MYAPI_AUTH_URL ?? 'https://auth.myapihq.com'; }
|
|
42
|
+
function hqBase() { return process.env.MYAPI_HQ_URL ?? process.env.MYAPI_API_BASE ?? 'https://api.myapihq.com'; }
|
|
43
|
+
function authorizeBase() { return `${authBase()}/auth/${OPERATOR_TENANT}/authorize`; }
|
|
44
|
+
function tokenUrl() { return `${authBase()}/auth/${OPERATOR_TENANT}/token`; }
|
|
45
|
+
export function generatePkce() {
|
|
46
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
47
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
48
|
+
const state = crypto.randomBytes(16).toString('base64url');
|
|
49
|
+
return { verifier, challenge, state };
|
|
50
|
+
}
|
|
51
|
+
export function buildAuthorizeUrl(base, redirectUri, pkce) {
|
|
52
|
+
const u = new URL(base);
|
|
53
|
+
u.searchParams.set('client_id', OPERATOR_CLIENT_ID);
|
|
54
|
+
u.searchParams.set('response_type', 'code');
|
|
55
|
+
u.searchParams.set('redirect_uri', redirectUri);
|
|
56
|
+
u.searchParams.set('state', pkce.state);
|
|
57
|
+
u.searchParams.set('code_challenge', pkce.challenge);
|
|
58
|
+
u.searchParams.set('code_challenge_method', 'S256');
|
|
59
|
+
u.searchParams.set('scope', 'openid email profile');
|
|
60
|
+
return u.toString();
|
|
61
|
+
}
|
|
62
|
+
// ── Browser opener (zero-dep, best-effort) ──────────────────────────────────
|
|
63
|
+
function openBrowser(url) {
|
|
64
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
65
|
+
: process.platform === 'win32' ? 'cmd'
|
|
66
|
+
: 'xdg-open';
|
|
67
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
68
|
+
try {
|
|
69
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
70
|
+
child.on('error', () => { });
|
|
71
|
+
child.unref();
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// ── Loopback callback server ────────────────────────────────────────────────
|
|
79
|
+
const CALLBACK_TIMEOUT_MS = 180_000;
|
|
80
|
+
// One page, styled once: used for both the success and error variants of the
|
|
81
|
+
// "return to your terminal" screen the browser lands on after sign-in.
|
|
82
|
+
function landingHtml(title, body, ok) {
|
|
83
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>
|
|
84
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
85
|
+
<style>
|
|
86
|
+
body{font-family:-apple-system,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:90vh;margin:0;background:#fafafa;color:#1a1a1a}
|
|
87
|
+
@media(prefers-color-scheme:dark){body{background:#111;color:#eee}}
|
|
88
|
+
.card{text-align:center;padding:2.5rem 3rem;border-radius:12px;box-shadow:0 1px 8px rgba(0,0,0,.08);background:#fff;max-width:26rem}
|
|
89
|
+
@media(prefers-color-scheme:dark){.card{background:#1c1c1c}}
|
|
90
|
+
.mark{font-size:2.2rem;margin-bottom:.6rem}
|
|
91
|
+
h1{font-size:1.15rem;margin:.2rem 0 .6rem}
|
|
92
|
+
p{color:#666;font-size:.92rem;line-height:1.5;margin:0}
|
|
93
|
+
@media(prefers-color-scheme:dark){p{color:#aaa}}
|
|
94
|
+
</style></head><body><div class="card">
|
|
95
|
+
<div class="mark">${ok ? '✓' : '✕'}</div><h1>${title}</h1><p>${body}</p>
|
|
96
|
+
</div></body></html>`;
|
|
97
|
+
}
|
|
98
|
+
function startCallbackServer(expectedState) {
|
|
99
|
+
return new Promise((resolveStart, rejectStart) => {
|
|
100
|
+
let settle;
|
|
101
|
+
const result = new Promise((resolve, reject) => { settle = { resolve, reject }; });
|
|
102
|
+
const server = http.createServer((req, res) => {
|
|
103
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
104
|
+
if (url.pathname !== '/callback') {
|
|
105
|
+
res.writeHead(404).end();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const err = url.searchParams.get('error');
|
|
109
|
+
const code = url.searchParams.get('code');
|
|
110
|
+
const state = url.searchParams.get('state');
|
|
111
|
+
if (err) {
|
|
112
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
113
|
+
.end(landingHtml('Sign-in cancelled', 'You can close this tab and return to your terminal.', false));
|
|
114
|
+
settle.reject(new Error(err === 'access_denied' ? 'Sign-in was cancelled in the browser.' : `Sign-in failed: ${err}`));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (!code || state !== expectedState) {
|
|
118
|
+
res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
|
|
119
|
+
.end(landingHtml('Sign-in failed', 'State mismatch — please close this tab and run <code>myapi login</code> again.', false));
|
|
120
|
+
settle.reject(new Error('State mismatch on the login callback — possible interception; aborting.'));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
124
|
+
.end(landingHtml("You're signed in", 'You can close this tab and return to your terminal.', true));
|
|
125
|
+
settle.resolve({ code });
|
|
126
|
+
});
|
|
127
|
+
server.on('error', rejectStart);
|
|
128
|
+
// Port 0 → OS-assigned; loopback only. The redirect URI is registered as
|
|
129
|
+
// a loopback template backend-side (RFC 8252 §7.3 — any port allowed).
|
|
130
|
+
server.listen(0, '127.0.0.1', () => {
|
|
131
|
+
const addr = server.address();
|
|
132
|
+
if (!addr || typeof addr === 'string') {
|
|
133
|
+
rejectStart(new Error('Could not bind loopback callback server.'));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
resolveStart({
|
|
137
|
+
redirectUri: `http://127.0.0.1:${addr.port}/callback`,
|
|
138
|
+
result,
|
|
139
|
+
close: () => server.close(),
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
// ── Mock hosted login page (my-auth-api stand-in) ───────────────────────────
|
|
145
|
+
//
|
|
146
|
+
// Serves a believable copy of the operator tenant's hosted login page so the
|
|
147
|
+
// end-to-end UX — terminal → browser → Google/email → redirect → terminal —
|
|
148
|
+
// can be felt before the backend exists. The "auth code" it issues encodes
|
|
149
|
+
// the chosen identity so the CLI can render the final message truthfully.
|
|
150
|
+
function mockLoginPage(query) {
|
|
151
|
+
const redirect = query.get('redirect_uri') ?? '';
|
|
152
|
+
const state = query.get('state') ?? '';
|
|
153
|
+
const qs = (extra) => new URLSearchParams({ redirect_uri: redirect, state, ...extra }).toString();
|
|
154
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>Sign in — MyAPI</title>
|
|
155
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
156
|
+
<style>
|
|
157
|
+
body{font-family:-apple-system,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:95vh;margin:0;background:#fafafa;color:#1a1a1a}
|
|
158
|
+
@media(prefers-color-scheme:dark){body{background:#111;color:#eee}}
|
|
159
|
+
.card{padding:2.5rem;border-radius:12px;box-shadow:0 1px 8px rgba(0,0,0,.08);background:#fff;width:20rem}
|
|
160
|
+
@media(prefers-color-scheme:dark){.card{background:#1c1c1c}}
|
|
161
|
+
h1{font-size:1.1rem;text-align:center;margin:0 0 .4rem}
|
|
162
|
+
.sub{text-align:center;color:#888;font-size:.8rem;margin-bottom:1.4rem}
|
|
163
|
+
a.google{display:flex;align-items:center;justify-content:center;gap:.6rem;border:1px solid #ddd;border-radius:8px;padding:.65rem;text-decoration:none;color:inherit;font-size:.95rem}
|
|
164
|
+
a.google:hover{background:rgba(66,133,244,.06)}
|
|
165
|
+
@media(prefers-color-scheme:dark){a.google{border-color:#333}}
|
|
166
|
+
.g{font-weight:700;background:linear-gradient(90deg,#4285F4,#EA4335,#FBBC05,#34A853);-webkit-background-clip:text;background-clip:text;color:transparent}
|
|
167
|
+
.or{display:flex;align-items:center;gap:.8rem;color:#aaa;font-size:.75rem;margin:1.2rem 0}
|
|
168
|
+
.or::before,.or::after{content:"";flex:1;height:1px;background:#e5e5e5}
|
|
169
|
+
@media(prefers-color-scheme:dark){.or::before,.or::after{background:#333}}
|
|
170
|
+
input{width:100%;box-sizing:border-box;padding:.6rem;border:1px solid #ddd;border-radius:8px;font-size:.9rem;background:transparent;color:inherit}
|
|
171
|
+
@media(prefers-color-scheme:dark){input{border-color:#333}}
|
|
172
|
+
button{width:100%;margin-top:.7rem;padding:.6rem;border:0;border-radius:8px;background:#1a1a1a;color:#fff;font-size:.9rem;cursor:pointer}
|
|
173
|
+
@media(prefers-color-scheme:dark){button{background:#eee;color:#111}}
|
|
174
|
+
.mock{margin-top:1.4rem;text-align:center;font-size:.72rem;color:#c98200}
|
|
175
|
+
</style></head><body><div class="card">
|
|
176
|
+
<h1>Sign in to MyAPI</h1>
|
|
177
|
+
<div class="sub">to continue to the MyAPI CLI</div>
|
|
178
|
+
<a class="google" href="/mock/google?${qs({})}"><span class="g">G</span> Continue with Google</a>
|
|
179
|
+
<div class="or">or</div>
|
|
180
|
+
<form action="/mock/email" method="get">
|
|
181
|
+
<input type="hidden" name="redirect_uri" value="${redirect}">
|
|
182
|
+
<input type="hidden" name="state" value="${state}">
|
|
183
|
+
<input name="email" type="email" placeholder="you@example.com" required>
|
|
184
|
+
<button type="submit">Continue with email</button>
|
|
185
|
+
</form>
|
|
186
|
+
<div class="mock">MOCK — local preview, no real authentication</div>
|
|
187
|
+
</div></body></html>`;
|
|
188
|
+
}
|
|
189
|
+
function mockGoogleChooser(query) {
|
|
190
|
+
const account = (email) => {
|
|
191
|
+
const u = new URLSearchParams({
|
|
192
|
+
redirect_uri: query.get('redirect_uri') ?? '',
|
|
193
|
+
state: query.get('state') ?? '',
|
|
194
|
+
email, method: 'google',
|
|
195
|
+
});
|
|
196
|
+
return `/mock/complete?${u.toString()}`;
|
|
197
|
+
};
|
|
198
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>Choose an account</title>
|
|
199
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
200
|
+
<style>
|
|
201
|
+
body{font-family:Roboto,-apple-system,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:95vh;margin:0;background:#fff;color:#202124}
|
|
202
|
+
@media(prefers-color-scheme:dark){body{background:#1f1f1f;color:#e8eaed}}
|
|
203
|
+
.card{border:1px solid #dadce0;border-radius:12px;padding:2.5rem 2rem;width:21rem}
|
|
204
|
+
@media(prefers-color-scheme:dark){.card{border-color:#5f6368}}
|
|
205
|
+
.logo{text-align:center;font-size:1.4rem;font-weight:500;margin-bottom:.6rem}
|
|
206
|
+
.logo span:nth-child(1){color:#4285F4}.logo span:nth-child(2){color:#EA4335}.logo span:nth-child(3){color:#FBBC05}.logo span:nth-child(4){color:#4285F4}.logo span:nth-child(5){color:#34A853}.logo span:nth-child(6){color:#EA4335}
|
|
207
|
+
h1{font-size:1.15rem;font-weight:400;text-align:center;margin:.4rem 0 1.6rem}
|
|
208
|
+
a{display:flex;align-items:center;gap:.9rem;padding:.8rem .6rem;text-decoration:none;color:inherit;border-top:1px solid #eee;font-size:.9rem}
|
|
209
|
+
@media(prefers-color-scheme:dark){a{border-color:#3c4043}}
|
|
210
|
+
a:hover{background:rgba(66,133,244,.05)}
|
|
211
|
+
.av{width:2rem;height:2rem;border-radius:50%;background:#7b1fa2;color:#fff;display:flex;align-items:center;justify-content:center;font-size:.9rem}
|
|
212
|
+
.av.b{background:#00796b}
|
|
213
|
+
.mock{margin-top:1.4rem;text-align:center;font-size:.72rem;color:#c98200}
|
|
214
|
+
</style></head><body><div class="card">
|
|
215
|
+
<div class="logo"><span>G</span><span>o</span><span>o</span><span>g</span><span>l</span><span>e</span></div>
|
|
216
|
+
<h1>Choose an account<br><small style="font-size:.75rem;color:#888">to continue to <b>MyAPI</b></small></h1>
|
|
217
|
+
<a href="${account('simon@gmail.com')}"><span class="av">S</span> <span>Simon<br><small style="color:#888">simon@gmail.com</small></span></a>
|
|
218
|
+
<a href="${account('founder@mycompany.com')}"><span class="av b">F</span> <span>Founder<br><small style="color:#888">founder@mycompany.com</small></span></a>
|
|
219
|
+
<div class="mock">MOCK — no real Google involved</div>
|
|
220
|
+
</div></body></html>`;
|
|
221
|
+
}
|
|
222
|
+
function mockEmailCodePage(query) {
|
|
223
|
+
const u = new URLSearchParams({
|
|
224
|
+
redirect_uri: query.get('redirect_uri') ?? '',
|
|
225
|
+
state: query.get('state') ?? '',
|
|
226
|
+
email: query.get('email') ?? '',
|
|
227
|
+
method: 'email',
|
|
228
|
+
});
|
|
229
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>Enter code — MyAPI</title>
|
|
230
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
231
|
+
<style>
|
|
232
|
+
body{font-family:-apple-system,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:95vh;margin:0;background:#fafafa;color:#1a1a1a}
|
|
233
|
+
@media(prefers-color-scheme:dark){body{background:#111;color:#eee}}
|
|
234
|
+
.card{padding:2.5rem;border-radius:12px;box-shadow:0 1px 8px rgba(0,0,0,.08);background:#fff;width:20rem;text-align:center}
|
|
235
|
+
@media(prefers-color-scheme:dark){.card{background:#1c1c1c}}
|
|
236
|
+
h1{font-size:1.05rem;margin:0 0 .5rem}
|
|
237
|
+
p{color:#888;font-size:.85rem;margin:0 0 1.2rem}
|
|
238
|
+
input{width:100%;box-sizing:border-box;padding:.6rem;border:1px solid #ddd;border-radius:8px;font-size:1.1rem;text-align:center;letter-spacing:.4em;background:transparent;color:inherit}
|
|
239
|
+
@media(prefers-color-scheme:dark){input{border-color:#333}}
|
|
240
|
+
button{width:100%;margin-top:.8rem;padding:.6rem;border:0;border-radius:8px;background:#1a1a1a;color:#fff;font-size:.9rem;cursor:pointer}
|
|
241
|
+
@media(prefers-color-scheme:dark){button{background:#eee;color:#111}}
|
|
242
|
+
.mock{margin-top:1.4rem;font-size:.72rem;color:#c98200}
|
|
243
|
+
</style></head><body><div class="card">
|
|
244
|
+
<h1>Check your email</h1>
|
|
245
|
+
<p>We sent a code to <b>${query.get('email') ?? ''}</b><br>(mock: any 6 digits work)</p>
|
|
246
|
+
<form action="/mock/complete" method="get">
|
|
247
|
+
${[...u.entries()].map(([k, v]) => `<input type="hidden" name="${k}" value="${v}">`).join('')}
|
|
248
|
+
<input name="code" inputmode="numeric" pattern="[0-9]{6}" placeholder="••••••" required>
|
|
249
|
+
<button type="submit">Verify</button>
|
|
250
|
+
</form>
|
|
251
|
+
<div class="mock">MOCK — no email was sent</div>
|
|
252
|
+
</div></body></html>`;
|
|
253
|
+
}
|
|
254
|
+
function startMockIdp() {
|
|
255
|
+
return new Promise((resolveStart, rejectStart) => {
|
|
256
|
+
const server = http.createServer((req, res) => {
|
|
257
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
258
|
+
const html = (body) => res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(body);
|
|
259
|
+
switch (url.pathname) {
|
|
260
|
+
case '/authorize': return html(mockLoginPage(url.searchParams));
|
|
261
|
+
case '/mock/google': return html(mockGoogleChooser(url.searchParams));
|
|
262
|
+
case '/mock/email': return html(mockEmailCodePage(url.searchParams));
|
|
263
|
+
case '/mock/complete': {
|
|
264
|
+
// Issue the "auth code": identity claims, base64url — mock only.
|
|
265
|
+
const claims = { email: url.searchParams.get('email'), method: url.searchParams.get('method') };
|
|
266
|
+
const code = 'mock_' + Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
267
|
+
const target = new URL(url.searchParams.get('redirect_uri') ?? '');
|
|
268
|
+
target.searchParams.set('code', code);
|
|
269
|
+
target.searchParams.set('state', url.searchParams.get('state') ?? '');
|
|
270
|
+
res.writeHead(302, { location: target.toString() }).end();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
default: res.writeHead(404).end();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
server.on('error', rejectStart);
|
|
277
|
+
server.listen(0, '127.0.0.1', () => {
|
|
278
|
+
const addr = server.address();
|
|
279
|
+
if (!addr || typeof addr === 'string') {
|
|
280
|
+
rejectStart(new Error('Could not bind mock login server.'));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
resolveStart({
|
|
284
|
+
authorizeBase: `http://127.0.0.1:${addr.port}/authorize`,
|
|
285
|
+
close: () => server.close(),
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
// exchangeCodeForTokens redeems the OIDC authorization code at the operator
|
|
291
|
+
// tenant's /token endpoint (PKCE, no client secret — public native client) and
|
|
292
|
+
// returns the id_token. The endpoint is form-encoded (OAuth2 §4.1.3).
|
|
293
|
+
async function exchangeCodeForTokens(code, verifier, redirectUri) {
|
|
294
|
+
const body = new URLSearchParams({
|
|
295
|
+
grant_type: 'authorization_code',
|
|
296
|
+
code,
|
|
297
|
+
redirect_uri: redirectUri,
|
|
298
|
+
client_id: OPERATOR_CLIENT_ID,
|
|
299
|
+
code_verifier: verifier,
|
|
300
|
+
});
|
|
301
|
+
const r = await fetch(tokenUrl(), {
|
|
302
|
+
method: 'POST',
|
|
303
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
304
|
+
body: body.toString(),
|
|
305
|
+
signal: AbortSignal.timeout(30_000),
|
|
306
|
+
});
|
|
307
|
+
const data = await r.json().catch(() => ({}));
|
|
308
|
+
if (!r.ok || !data?.id_token) {
|
|
309
|
+
throw new Error(`Token exchange failed (${r.status}): ${data?.error_description || data?.error || 'no id_token returned'}`);
|
|
310
|
+
}
|
|
311
|
+
return data.id_token;
|
|
312
|
+
}
|
|
313
|
+
// exchangeTokenForKey trades the verified id_token for an hq API key + defaults.
|
|
314
|
+
async function exchangeTokenForKey(idToken) {
|
|
315
|
+
const r = await fetch(`${hqBase()}/hq/account/exchange`, {
|
|
316
|
+
method: 'POST',
|
|
317
|
+
headers: { 'content-type': 'application/json' },
|
|
318
|
+
body: JSON.stringify({ id_token: idToken }),
|
|
319
|
+
signal: AbortSignal.timeout(30_000),
|
|
320
|
+
});
|
|
321
|
+
const env = await r.json().catch(() => ({}));
|
|
322
|
+
if (!r.ok || !env?.data?.api_key) {
|
|
323
|
+
const msg = env?.error?.message || env?.error || `HTTP ${r.status}`;
|
|
324
|
+
throw new Error(`Sign-in exchange failed: ${typeof msg === 'string' ? msg : JSON.stringify(msg)}`);
|
|
325
|
+
}
|
|
326
|
+
return env.data;
|
|
327
|
+
}
|
|
328
|
+
// emailFromIdToken reads the (already backend-verified) email claim for display
|
|
329
|
+
// + the local config entry. Never trusted for auth — that's the backend's job.
|
|
330
|
+
function emailFromIdToken(idToken) {
|
|
331
|
+
try {
|
|
332
|
+
const claims = JSON.parse(Buffer.from(idToken.split('.')[1] ?? '', 'base64url').toString());
|
|
333
|
+
return typeof claims.email === 'string' ? claims.email : '';
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return '';
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// Mock stand-in for `POST /hq/account/exchange`. Used only by `--mock`.
|
|
340
|
+
function mockExchange(code) {
|
|
341
|
+
let claims = {};
|
|
342
|
+
try {
|
|
343
|
+
claims = JSON.parse(Buffer.from(code.replace(/^mock_/, ''), 'base64url').toString());
|
|
344
|
+
}
|
|
345
|
+
catch { /* fall through */ }
|
|
346
|
+
return {
|
|
347
|
+
email: claims.email || 'you@example.com',
|
|
348
|
+
method: claims.method === 'email' ? 'email' : 'google',
|
|
349
|
+
account_id: 'acct_mock_' + crypto.randomBytes(4).toString('hex'),
|
|
350
|
+
default_org: 'mock-org',
|
|
351
|
+
default_funnel: 'mock-funnel',
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
// ── Command ─────────────────────────────────────────────────────────────────
|
|
355
|
+
export async function login(flags = {}) {
|
|
356
|
+
if (flags.help) {
|
|
357
|
+
info(HELP);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (flags.mock) {
|
|
361
|
+
await loginMock(flags);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const pkce = generatePkce();
|
|
365
|
+
const callback = await startCallbackServer(pkce.state);
|
|
366
|
+
const authorizeUrl = buildAuthorizeUrl(authorizeBase(), callback.redirectUri, pkce);
|
|
367
|
+
info('› Opening your browser to sign in…');
|
|
368
|
+
const opened = flags['no-browser'] ? false : openBrowser(authorizeUrl);
|
|
369
|
+
if (!opened) {
|
|
370
|
+
info('› Open this URL to sign in:');
|
|
371
|
+
info(` ${authorizeUrl}`);
|
|
372
|
+
}
|
|
373
|
+
info('› Waiting for sign-in in the browser… (Ctrl-C to cancel)');
|
|
374
|
+
const timeout = setTimeout(() => {
|
|
375
|
+
info('');
|
|
376
|
+
error('Timed out waiting for the browser sign-in (3 minutes). Run: myapi login');
|
|
377
|
+
}, CALLBACK_TIMEOUT_MS);
|
|
378
|
+
let auth;
|
|
379
|
+
let email = '';
|
|
380
|
+
try {
|
|
381
|
+
const { code } = await callback.result;
|
|
382
|
+
const idToken = await exchangeCodeForTokens(code, pkce.verifier, callback.redirectUri);
|
|
383
|
+
email = emailFromIdToken(idToken);
|
|
384
|
+
auth = await exchangeTokenForKey(idToken);
|
|
385
|
+
}
|
|
386
|
+
catch (e) {
|
|
387
|
+
error(e?.message ?? 'Sign-in failed.');
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
clearTimeout(timeout);
|
|
391
|
+
callback.close();
|
|
392
|
+
}
|
|
393
|
+
if (!auth)
|
|
394
|
+
error('Sign-in failed.');
|
|
395
|
+
addAccount({
|
|
396
|
+
api_key: auth.api_key,
|
|
397
|
+
account_id: auth.account_id,
|
|
398
|
+
email: email || undefined,
|
|
399
|
+
default_org: auth.default_org || undefined,
|
|
400
|
+
default_funnel: auth.default_funnel || undefined,
|
|
401
|
+
});
|
|
402
|
+
success(`› ✓ Signed in${email ? ` · ${email}` : ''}`);
|
|
403
|
+
info(` Account: ${auth.account_id}`);
|
|
404
|
+
if (auth.default_org)
|
|
405
|
+
info(` Org: ${auth.default_org}${auth.default_funnel ? ` · Funnel: ${auth.default_funnel}` : ''}`);
|
|
406
|
+
info(' Saved to ~/.myapi/config.json');
|
|
407
|
+
}
|
|
408
|
+
// loginMock runs the full browser UX against the in-process fake IdP. Nothing is
|
|
409
|
+
// persisted and no network calls leave the machine — a UX preview / demo aid.
|
|
410
|
+
async function loginMock(flags = {}) {
|
|
411
|
+
const pkce = generatePkce();
|
|
412
|
+
const callback = await startCallbackServer(pkce.state);
|
|
413
|
+
const idp = await startMockIdp();
|
|
414
|
+
const authorizeUrl = buildAuthorizeUrl(idp.authorizeBase, callback.redirectUri, pkce);
|
|
415
|
+
info('› Opening your browser to sign in… (mock preview — nothing is saved)');
|
|
416
|
+
const opened = flags['no-browser'] ? false : openBrowser(authorizeUrl);
|
|
417
|
+
if (!opened) {
|
|
418
|
+
info('› Open this URL to sign in:');
|
|
419
|
+
info(` ${authorizeUrl}`);
|
|
420
|
+
}
|
|
421
|
+
info('› Waiting for sign-in in the browser… (Ctrl-C to cancel)');
|
|
422
|
+
const timeout = setTimeout(() => {
|
|
423
|
+
info('');
|
|
424
|
+
error('Timed out waiting for the browser sign-in (3 minutes). Run: myapi login --mock');
|
|
425
|
+
}, CALLBACK_TIMEOUT_MS);
|
|
426
|
+
let result;
|
|
427
|
+
try {
|
|
428
|
+
const { code } = await callback.result;
|
|
429
|
+
result = mockExchange(code);
|
|
430
|
+
}
|
|
431
|
+
catch (e) {
|
|
432
|
+
error(e?.message ?? 'Sign-in failed.');
|
|
433
|
+
}
|
|
434
|
+
finally {
|
|
435
|
+
clearTimeout(timeout);
|
|
436
|
+
callback.close();
|
|
437
|
+
idp.close();
|
|
438
|
+
}
|
|
439
|
+
if (!result)
|
|
440
|
+
error('Sign-in failed.');
|
|
441
|
+
const via = result.method === 'google' ? 'Google' : 'email code';
|
|
442
|
+
success(`› Signed in · ${result.email} (via ${via})`);
|
|
443
|
+
info(` Account: ${result.account_id}`);
|
|
444
|
+
info(` Org: ${result.default_org} · Funnel: ${result.default_funnel}`);
|
|
445
|
+
info('');
|
|
446
|
+
info('› (mock) Nothing was saved. Run `myapi login` for the real sign-in.');
|
|
447
|
+
}
|
package/dist/commands/org.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export declare const SCHEMA: FlagSchema;
|
|
|
6
6
|
export declare function create(restArgs: string[], flags: Flags): Promise<void>;
|
|
7
7
|
export declare function list(flags: Flags): Promise<void>;
|
|
8
8
|
export declare function get(id: string, flags: Flags): Promise<void>;
|
|
9
|
-
export declare function del(id: string,
|
|
9
|
+
export declare function del(id: string, flags: Flags): Promise<void>;
|
|
10
10
|
export declare function update(restArgs: string[], flags: Flags): Promise<void>;
|
|
11
11
|
export declare function importOrg(args: string[], flags: Flags): Promise<void>;
|
|
12
12
|
export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
|
package/dist/commands/org.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { hq, funnel as sdkFunnel } from '@myapihq/sdk';
|
|
2
2
|
import { requireConfig, saveConfig } from '../config.js';
|
|
3
3
|
import { success, error, printTable, printJson, info } from '../output.js';
|
|
4
|
-
import { confirm } from '../prompt.js';
|
|
4
|
+
import { confirm, isNonInteractive } from '../prompt.js';
|
|
5
5
|
import { formatDate, pollJob } from '../utils.js';
|
|
6
6
|
import { requireOrg, requireArg } from '../helpers.js';
|
|
7
7
|
export const EXPOSES = [
|
|
@@ -45,7 +45,11 @@ export async function create(restArgs, flags) {
|
|
|
45
45
|
info(`Preview domain: https://${org.preview_subdomain}.makeautonomous.com`);
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
-
|
|
48
|
+
// Never re-point the CLI's defaults on a silent fallback: in non-interactive
|
|
49
|
+
// contexts (CI, agent pipelines) only --yes may switch the default org —
|
|
50
|
+
// an unanswered prompt must not count as consent.
|
|
51
|
+
const setDefault = !!flags.yes ||
|
|
52
|
+
(!isNonInteractive() && await confirm('› Set as default org and funnel? (Y/n) ', true));
|
|
49
53
|
let funnelId;
|
|
50
54
|
if (setDefault) {
|
|
51
55
|
config.default_org = org.id;
|
|
@@ -112,11 +116,23 @@ export async function get(id, flags) {
|
|
|
112
116
|
if (org.preview_subdomain)
|
|
113
117
|
info(`Preview: https://${org.preview_subdomain}.makeautonomous.com`);
|
|
114
118
|
}
|
|
115
|
-
export async function del(id,
|
|
116
|
-
requireArg(id, 'id', 'myapi org delete <id>');
|
|
119
|
+
export async function del(id, flags) {
|
|
120
|
+
requireArg(id, 'id', 'myapi org delete <id> --yes');
|
|
117
121
|
const config = requireConfig();
|
|
122
|
+
// Look before the write: name the org we are about to destroy, and fail
|
|
123
|
+
// (rather than proceed blind) if it can't be resolved.
|
|
124
|
+
const org = await hq.getOrg(config.api_key, id);
|
|
125
|
+
const label = org.name ? `"${org.name}" (${id})` : id;
|
|
126
|
+
if (!flags.yes) {
|
|
127
|
+
if (isNonInteractive()) {
|
|
128
|
+
error(`Refusing to delete org ${label} without --yes.\nThis permanently deletes the org and its funnels. Usage: myapi org delete <id> --yes`);
|
|
129
|
+
}
|
|
130
|
+
const ok = await confirm(`› Permanently delete org ${label} and all its funnels? (y/N) `, false);
|
|
131
|
+
if (!ok)
|
|
132
|
+
error('Aborted.');
|
|
133
|
+
}
|
|
118
134
|
await hq.deleteOrg(config.api_key, id);
|
|
119
|
-
success(`Org ${
|
|
135
|
+
success(`Org ${label} deleted`);
|
|
120
136
|
}
|
|
121
137
|
// Update one or more fields of an org. At least one --flag must be supplied;
|
|
122
138
|
// otherwise we have nothing to send (the SDK accepts a partial payload but
|
|
@@ -195,11 +211,12 @@ Examples:
|
|
|
195
211
|
myapi org create "Acme Inc" --yes
|
|
196
212
|
myapi org create --name "Acme Inc" --yes`,
|
|
197
213
|
'get': 'myapi org get <id> [--json]',
|
|
198
|
-
'delete': `myapi org delete <id>
|
|
214
|
+
'delete': `myapi org delete <id> [--yes]
|
|
199
215
|
|
|
200
216
|
Permanently deletes an organization and its associated funnels.
|
|
201
217
|
Any registered domains assigned to this org must be unassigned first.
|
|
202
|
-
|
|
218
|
+
Asks for confirmation; pass --yes to skip (required in non-interactive runs).
|
|
219
|
+
Verify the id first with: myapi org get <id>`,
|
|
203
220
|
'sync-brand': `myapi org sync-brand <domain> [--org <id>]
|
|
204
221
|
|
|
205
222
|
Fetches an existing website and extracts brand signals (name, logo, description,
|
package/dist/commands/queue.js
CHANGED
|
@@ -2,7 +2,7 @@ import { queue as sdkQueue } from '@myapihq/sdk';
|
|
|
2
2
|
import { requireConfig } from '../config.js';
|
|
3
3
|
import { success, error, printTable, info, printJson } from '../output.js';
|
|
4
4
|
import { formatDate } from '../utils.js';
|
|
5
|
-
import { requireOrg, requireArg } from '../helpers.js';
|
|
5
|
+
import { requireOrg, requireArg, confirmDestructive } from '../helpers.js';
|
|
6
6
|
export const EXPOSES = [
|
|
7
7
|
'POST /queue/orgs/{org_id}/queues',
|
|
8
8
|
'GET /queue/orgs/{org_id}/queues',
|
|
@@ -167,10 +167,11 @@ export async function job(jobId, flags) {
|
|
|
167
167
|
}
|
|
168
168
|
export async function del(name, flags) {
|
|
169
169
|
const config = requireConfig();
|
|
170
|
-
const orgId = requireOrg(flags, config, 'myapi queue delete <name> [--org <id>]');
|
|
170
|
+
const orgId = requireOrg(flags, config, 'myapi queue delete <name> [--yes] [--org <id>]');
|
|
171
171
|
requireArg(name, 'name', 'myapi queue delete <name>');
|
|
172
|
+
await confirmDestructive(flags, `delete queue "${name}" in org ${orgId}`, 'myapi queue delete <name> --yes [--org <id>]');
|
|
172
173
|
await sdkQueue.deleteQueue(config.api_key, orgId, name);
|
|
173
|
-
success(`Deleted queue: ${name}`);
|
|
174
|
+
success(`Deleted queue: ${name} (org ${orgId})`);
|
|
174
175
|
}
|
|
175
176
|
// ── Dispatcher ───────────────────────────────────────────────────────────────
|
|
176
177
|
const SUBCOMMAND_USAGE = {
|
package/dist/commands/setup.js
CHANGED
|
@@ -214,7 +214,10 @@ export async function importKey(apiKey, flags) {
|
|
|
214
214
|
throw new Error(`Could not verify API key: ${e.message}`);
|
|
215
215
|
}
|
|
216
216
|
const { org: defaultOrg, funnel: defaultFunnel } = await resolveDefaults(apiKey);
|
|
217
|
-
|
|
217
|
+
// import-key is the CI/Docker path and its help documents --install-skills
|
|
218
|
+
// as opt-IN: never write skill files/symlinks into the operator's home dirs
|
|
219
|
+
// unless explicitly asked.
|
|
220
|
+
const wantsSkills = !!flags['install-skills'] && !flags['no-skills'];
|
|
218
221
|
addAccount({
|
|
219
222
|
api_key: apiKey, account_id: accountId, email,
|
|
220
223
|
default_org: defaultOrg, default_funnel: defaultFunnel,
|
|
@@ -223,6 +226,8 @@ export async function importKey(apiKey, flags) {
|
|
|
223
226
|
success(`› ✓ Key imported${email ? ` · ${email}` : ''}`);
|
|
224
227
|
if (wantsSkills)
|
|
225
228
|
await installSkills();
|
|
229
|
+
else
|
|
230
|
+
info('Skills pack not installed. Add it any time with: myapi install-skills');
|
|
226
231
|
}
|
|
227
232
|
// Persist a new account + (optionally) install skills.
|
|
228
233
|
async function installAndPersistSkills(account, wantsSkills) {
|
package/dist/commands/storage.js
CHANGED
|
@@ -158,7 +158,7 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
|
|
|
158
158
|
if (usage)
|
|
159
159
|
info(`Usage: ${usage}`);
|
|
160
160
|
else
|
|
161
|
-
|
|
161
|
+
info(`Unknown subcommand: ${subcommand}. Run "myapi storage --help" for the list.`);
|
|
162
162
|
return;
|
|
163
163
|
}
|
|
164
164
|
switch (subcommand) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { workflow as sdkWorkflow } from '@myapihq/sdk';
|
|
2
2
|
import { requireConfig } from '../config.js';
|
|
3
3
|
import { success, error, printTable, info, printJson } from '../output.js';
|
|
4
|
-
import { requireOrg } from '../helpers.js';
|
|
4
|
+
import { requireOrg, confirmDestructive } from '../helpers.js';
|
|
5
5
|
export const EXPOSES = [
|
|
6
6
|
'POST /workflow/orgs/{org_id}/workflows',
|
|
7
7
|
'GET /workflow/orgs/{org_id}/workflows',
|
|
@@ -273,11 +273,12 @@ export async function disable(id, flags) {
|
|
|
273
273
|
}
|
|
274
274
|
export async function del(id, flags) {
|
|
275
275
|
const config = requireConfig();
|
|
276
|
-
const orgId = requireOrg(flags, config, 'myapi workflow delete <id> [--org <id>]');
|
|
276
|
+
const orgId = requireOrg(flags, config, 'myapi workflow delete <id> [--yes] [--org <id>]');
|
|
277
277
|
if (!id)
|
|
278
|
-
error('Missing required arguments.\nUsage: myapi workflow delete <id> [--org <id>]');
|
|
278
|
+
error('Missing required arguments.\nUsage: myapi workflow delete <id> [--yes] [--org <id>]');
|
|
279
|
+
await confirmDestructive(flags, `delete workflow ${id} in org ${orgId}`, 'myapi workflow delete <id> --yes [--org <id>]');
|
|
279
280
|
await sdkWorkflow.deleteWorkflow(config.api_key, orgId, id);
|
|
280
|
-
success(`Deleted workflow ${id}`);
|
|
281
|
+
success(`Deleted workflow ${id} (org ${orgId})`);
|
|
281
282
|
}
|
|
282
283
|
export async function runs(id, flags) {
|
|
283
284
|
const config = requireConfig();
|
package/dist/completion.js
CHANGED
|
@@ -28,14 +28,14 @@ const BLOCK_END = `# end ${PROGRAM} completion`;
|
|
|
28
28
|
export const COMMANDS = [
|
|
29
29
|
'account', 'audience', 'auth', 'billing', 'company', 'completion', 'config', 'container',
|
|
30
30
|
'crm', 'database', 'domain', 'email', 'fn', 'funnel', 'git', 'help', 'image',
|
|
31
|
-
'doctor', 'install-skills', 'keys', 'llm', 'org', 'payments', 'people', 'pixel',
|
|
32
|
-
'setup', 'status', 'storage', 'task', 'update', 'url', 'webhook', 'whoami',
|
|
31
|
+
'doctor', 'install-skills', 'keys', 'llm', 'login', 'org', 'payments', 'people', 'pixel',
|
|
32
|
+
'queue', 'setup', 'status', 'storage', 'task', 'update', 'url', 'webhook', 'whoami',
|
|
33
33
|
'workflow',
|
|
34
34
|
];
|
|
35
35
|
// command → subcommands, for `myapi <command> <TAB>`. Mirrors each
|
|
36
36
|
// command's dispatcher; commands absent here take no subcommand.
|
|
37
37
|
export const SUBCOMMANDS = {
|
|
38
|
-
account: ['setup', 'import-key', 'whoami', 'link', 'switch', 'install-skills', 'config', 'registrant', 'api-keys', 'keys', 'mailing-address'],
|
|
38
|
+
account: ['setup', 'import-key', 'whoami', 'login', 'link', 'switch', 'install-skills', 'config', 'registrant', 'api-keys', 'keys', 'mailing-address'],
|
|
39
39
|
// The end-user auth product. Operator/account commands live under `account`.
|
|
40
40
|
auth: ['tenant', 'client', 'usage', 'domain'],
|
|
41
41
|
org: ['create', 'delete', 'get', 'import', 'list', 'sync-brand', 'update'],
|
package/dist/config.js
CHANGED
|
@@ -54,6 +54,9 @@ export function loadConfig() {
|
|
|
54
54
|
function writeFullConfig(full) {
|
|
55
55
|
ensureDir();
|
|
56
56
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(full, null, 2), { mode: 0o600 });
|
|
57
|
+
// `mode` only applies when the file is created. Repair permissions on
|
|
58
|
+
// configs written by older CLI versions (pre-0o600) that are still 0644.
|
|
59
|
+
fs.chmodSync(CONFIG_FILE, 0o600);
|
|
57
60
|
}
|
|
58
61
|
export function saveConfig(config) {
|
|
59
62
|
const full = loadFullConfig() ?? { active: 0, accounts: [] };
|
package/dist/errors.d.ts
ADDED