codeply-cli 0.2.0 → 0.3.1
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/THIRD_PARTY_NOTICES.md +63 -0
- package/bin/codeply.js +558 -370
- package/lib/agent.mjs +1627 -160
- package/lib/ai.js +1107 -387
- package/lib/apply-limit.js +33 -51
- package/lib/apply-patch.js +217 -0
- package/lib/arity-table.json +138 -0
- package/lib/arity.js +70 -0
- package/lib/auth.js +118 -112
- package/lib/commands.js +103 -0
- package/lib/config.js +392 -136
- package/lib/design-library/artifact-template.html +500 -0
- package/lib/design-library/assemble-artifact.mjs +21 -0
- package/lib/design-library/build.mjs +226 -0
- package/lib/design-library/cleanup.mjs +45 -0
- package/lib/design-library/index.json +22380 -0
- package/lib/design-library/make-table-data.mjs +39 -0
- package/lib/design-library/query.mjs +104 -0
- package/lib/diagnostics.js +192 -0
- package/lib/edit-engine.js +171 -14
- package/lib/github-agent.mjs +307 -0
- package/lib/history.js +75 -0
- package/lib/mcp.js +258 -0
- package/lib/native-tools.mjs +189 -0
- package/lib/oauth-connectors.js +478 -0
- package/lib/permissions.js +109 -0
- package/lib/plugins.js +353 -0
- package/lib/render-md.mjs +617 -617
- package/lib/server.mjs +457 -0
- package/lib/session-db.js +220 -0
- package/lib/share.js +168 -0
- package/lib/skills.js +80 -22
- package/lib/snapshot.js +146 -0
- package/lib/subagents.js +164 -0
- package/lib/terminal-hints.js +154 -0
- package/lib/textarea.mjs +258 -258
- package/lib/tools.mjs +1658 -79
- package/lib/tui.js +7 -7
- package/lib/tui.mjs +1086 -992
- package/lib/typecheck-worker.js +147 -0
- package/lib/web-tools.js +158 -0
- package/package.json +4 -3
- package/skills/premium-web-design/SKILL.md +154 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codeply - Gmail and Slack OAuth + the real API calls the agent's tools use.
|
|
3
|
+
*
|
|
4
|
+
* Desktop-app OAuth (RFC 8252): the consent screen opens in the user's real
|
|
5
|
+
* system browser, not an embedded webview, and the redirect is caught by a
|
|
6
|
+
* short-lived local HTTP server on a fixed loopback port - Real App/main.js
|
|
7
|
+
* owns spinning that server up/down; this file only builds URLs and talks to
|
|
8
|
+
* the providers' token/API endpoints. No secrets are ever logged.
|
|
9
|
+
*/
|
|
10
|
+
const GMAIL_SCOPES = 'https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.send';
|
|
11
|
+
// channels:join is what lets slackPostMessage auto-join a public channel
|
|
12
|
+
// before posting to it - without it, chat:write alone only covers channels
|
|
13
|
+
// the app has already been manually invited into.
|
|
14
|
+
const SLACK_SCOPES = 'chat:write,channels:read,channels:history,channels:join';
|
|
15
|
+
// Separate from the bot scopes above - Slack issues bot and user tokens
|
|
16
|
+
// through entirely different mechanisms in the same OAuth exchange. `scope`
|
|
17
|
+
// requests the bot token (posts as "Codeply Craft APP"); `user_scope`
|
|
18
|
+
// requests a second, independent token that posts as the actual signed-in
|
|
19
|
+
// user. Both come back in one authorize round-trip, but they're genuinely
|
|
20
|
+
// two different tokens with two different scope sets.
|
|
21
|
+
const SLACK_USER_SCOPES = 'chat:write';
|
|
22
|
+
|
|
23
|
+
function buildGmailAuthUrl(clientId, redirectUri) {
|
|
24
|
+
const params = new URLSearchParams({
|
|
25
|
+
client_id: clientId,
|
|
26
|
+
redirect_uri: redirectUri,
|
|
27
|
+
response_type: 'code',
|
|
28
|
+
scope: GMAIL_SCOPES,
|
|
29
|
+
access_type: 'offline', // required to get a refresh_token back
|
|
30
|
+
prompt: 'consent', // forces the refresh_token every time, not just first connect
|
|
31
|
+
});
|
|
32
|
+
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function exchangeGmailCode(clientId, clientSecret, code, redirectUri) {
|
|
36
|
+
const res = await fetch('https://oauth2.googleapis.com/token', {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
39
|
+
body: new URLSearchParams({
|
|
40
|
+
client_id: clientId, client_secret: clientSecret, code,
|
|
41
|
+
redirect_uri: redirectUri, grant_type: 'authorization_code',
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
const body = await res.json();
|
|
45
|
+
if (!res.ok) throw new Error(body.error_description || body.error || `Gmail token exchange failed (HTTP ${res.status})`);
|
|
46
|
+
return body; // { access_token, refresh_token, expires_in, ... }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function refreshGmailToken(clientId, clientSecret, refreshToken) {
|
|
50
|
+
const res = await fetch('https://oauth2.googleapis.com/token', {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
53
|
+
body: new URLSearchParams({
|
|
54
|
+
client_id: clientId, client_secret: clientSecret,
|
|
55
|
+
refresh_token: refreshToken, grant_type: 'refresh_token',
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
const body = await res.json();
|
|
59
|
+
if (!res.ok) throw new Error(body.error_description || body.error || `Gmail token refresh failed (HTTP ${res.status})`);
|
|
60
|
+
return body; // { access_token, expires_in, ... } - no new refresh_token on refresh
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function getGmailProfile(accessToken) {
|
|
64
|
+
const res = await fetch('https://www.googleapis.com/gmail/v1/users/me/profile', {
|
|
65
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
66
|
+
});
|
|
67
|
+
const body = await res.json();
|
|
68
|
+
if (!res.ok) throw new Error(body.error?.message || `Gmail profile lookup failed (HTTP ${res.status})`);
|
|
69
|
+
return body.emailAddress;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function base64UrlEncode(str) {
|
|
73
|
+
return Buffer.from(str, 'utf8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function gmailSend(accessToken, { to, subject, body }) {
|
|
77
|
+
const raw = base64UrlEncode(`To: ${to}\r\nSubject: ${subject}\r\nContent-Type: text/plain; charset="UTF-8"\r\n\r\n${body}`);
|
|
78
|
+
const res = await fetch('https://www.googleapis.com/gmail/v1/users/me/messages/send', {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
|
81
|
+
body: JSON.stringify({ raw }),
|
|
82
|
+
});
|
|
83
|
+
const data = await res.json();
|
|
84
|
+
if (!res.ok) throw new Error(data.error?.message || `Gmail send failed (HTTP ${res.status})`);
|
|
85
|
+
return data; // { id, threadId, ... }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function gmailSearch(accessToken, query, maxResults = 10) {
|
|
89
|
+
const params = new URLSearchParams({ q: query, maxResults: String(maxResults) });
|
|
90
|
+
const res = await fetch(`https://www.googleapis.com/gmail/v1/users/me/messages?${params}`, {
|
|
91
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
92
|
+
});
|
|
93
|
+
const list = await res.json();
|
|
94
|
+
if (!res.ok) throw new Error(list.error?.message || `Gmail search failed (HTTP ${res.status})`);
|
|
95
|
+
if (!list.messages?.length) return [];
|
|
96
|
+
// The list endpoint only returns bare ids - each result needs its own
|
|
97
|
+
// fetch for subject/from/snippet, capped by maxResults so a broad query
|
|
98
|
+
// can't fan out into dozens of requests.
|
|
99
|
+
const details = await Promise.all(list.messages.map(async (m) => {
|
|
100
|
+
const r = await fetch(
|
|
101
|
+
`https://www.googleapis.com/gmail/v1/users/me/messages/${m.id}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date`,
|
|
102
|
+
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
|
103
|
+
);
|
|
104
|
+
const msg = await r.json();
|
|
105
|
+
const header = (name) => msg.payload?.headers?.find((h) => h.name === name)?.value || '';
|
|
106
|
+
return { id: m.id, subject: header('Subject'), from: header('From'), date: header('Date'), snippet: msg.snippet || '' };
|
|
107
|
+
}));
|
|
108
|
+
return details;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildSlackAuthUrl(clientId, redirectUri) {
|
|
112
|
+
const params = new URLSearchParams({
|
|
113
|
+
client_id: clientId, redirect_uri: redirectUri,
|
|
114
|
+
scope: SLACK_SCOPES, user_scope: SLACK_USER_SCOPES,
|
|
115
|
+
});
|
|
116
|
+
return `https://slack.com/oauth/v2/authorize?${params}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function exchangeSlackCode(clientId, clientSecret, code, redirectUri) {
|
|
120
|
+
const res = await fetch('https://slack.com/api/oauth.v2.access', {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
123
|
+
body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code, redirect_uri: redirectUri }),
|
|
124
|
+
});
|
|
125
|
+
const body = await res.json();
|
|
126
|
+
// Slack's OAuth endpoint returns HTTP 200 even on failure - ok:false in the body is the real signal.
|
|
127
|
+
if (!body.ok) throw new Error(body.error || 'Slack token exchange failed');
|
|
128
|
+
return body; // { access_token, team: { id, name }, ... }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Public-channel-only equivalent of clicking "Join channel" in Slack. Idempotent - already_in_channel is fine. */
|
|
132
|
+
async function slackJoinChannel(accessToken, channelId) {
|
|
133
|
+
const res = await fetch('https://slack.com/api/conversations.join', {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
|
136
|
+
body: JSON.stringify({ channel: channelId }),
|
|
137
|
+
});
|
|
138
|
+
const body = await res.json();
|
|
139
|
+
if (!body.ok && body.error !== 'already_in_channel') throw new Error(body.error || 'Could not join the channel');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function slackPostMessage(accessToken, { channel, text }, { isUserToken = false } = {}) {
|
|
143
|
+
// A bot token's chat:write only covers channels the app has actually
|
|
144
|
+
// joined - not "any public channel" the way a human member can post to.
|
|
145
|
+
// Auto-joining first (public channels only; that's all resolveSlackChannel
|
|
146
|
+
// in tools.mjs ever resolves to) means the agent can post to a channel the
|
|
147
|
+
// very first time it's asked to, instead of failing with not_in_channel
|
|
148
|
+
// until someone manually runs /invite in Slack. Joining a channel it's
|
|
149
|
+
// already in is a harmless no-op (already_in_channel), so this doesn't
|
|
150
|
+
// need to track membership itself - just always try.
|
|
151
|
+
//
|
|
152
|
+
// A user token is different: it posts as an actual workspace member, who
|
|
153
|
+
// either already belongs to the channel or doesn't - conversations.join
|
|
154
|
+
// isn't the right operation for a user identity the way it is for a bot,
|
|
155
|
+
// so this is skipped entirely on that path.
|
|
156
|
+
if (!isUserToken) await slackJoinChannel(accessToken, channel);
|
|
157
|
+
|
|
158
|
+
const res = await fetch('https://slack.com/api/chat.postMessage', {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
|
161
|
+
body: JSON.stringify({ channel, text }),
|
|
162
|
+
});
|
|
163
|
+
const body = await res.json();
|
|
164
|
+
if (!body.ok) throw new Error(body.error || 'Slack message failed to send');
|
|
165
|
+
return body;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function slackListChannels(accessToken) {
|
|
169
|
+
// public_channel only - private_channel requires the separate groups:read
|
|
170
|
+
// scope, which isn't part of the bot scope set this app asks for
|
|
171
|
+
// (chat:write, channels:read, channels:history). Slack rejects the WHOLE
|
|
172
|
+
// conversations.list call with missing_scope if any requested type isn't
|
|
173
|
+
// covered, not just the private-channel portion - so asking for a type we
|
|
174
|
+
// don't have scope for breaks lookup even for public channels the bot can
|
|
175
|
+
// see fine, which is exactly what was happening here.
|
|
176
|
+
const res = await fetch('https://slack.com/api/conversations.list?types=public_channel&limit=100', {
|
|
177
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
178
|
+
});
|
|
179
|
+
const body = await res.json();
|
|
180
|
+
if (!body.ok) throw new Error(body.error || 'Slack channel list failed');
|
|
181
|
+
return (body.channels || []).map((c) => ({ id: c.id, name: c.name, isPrivate: c.is_private, isMember: c.is_member }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Vercel Integrations Console apps do NOT use a plain /oauth/authorize?
|
|
185
|
+
// client_id=... URL - that endpoint belongs to a different, older Vercel
|
|
186
|
+
// OAuth app system and rejects Integrations Console client IDs outright
|
|
187
|
+
// ("App configuration error: The app ID is invalid"). An integration created
|
|
188
|
+
// in the Integrations Console starts its install at this slug-based URL
|
|
189
|
+
// instead (found on the integration's own Console settings page, not
|
|
190
|
+
// derived from client_id), and Vercel redirects back to whichever Redirect
|
|
191
|
+
// URL is configured in the Console (already this app's loopback callback)
|
|
192
|
+
// with ?code=... (and &teamId=... when installed to a team). The code
|
|
193
|
+
// exchange step below is unchanged - that part of the flow really is the
|
|
194
|
+
// same as the old system.
|
|
195
|
+
function buildVercelAuthUrl(slug, state) {
|
|
196
|
+
const params = new URLSearchParams();
|
|
197
|
+
if (state) params.set('state', state);
|
|
198
|
+
const qs = params.toString();
|
|
199
|
+
return `https://vercel.com/integrations/${encodeURIComponent(slug)}/new${qs ? `?${qs}` : ''}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function exchangeVercelCode(clientId, clientSecret, code, redirectUri) {
|
|
203
|
+
const res = await fetch('https://api.vercel.com/v2/oauth/access_token', {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
206
|
+
body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code, redirect_uri: redirectUri }),
|
|
207
|
+
});
|
|
208
|
+
const body = await res.json();
|
|
209
|
+
if (!res.ok) throw new Error(body.error?.message || body.error || `Vercel token exchange failed (HTTP ${res.status})`);
|
|
210
|
+
return body; // { access_token, team_id, user_id, ... }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function getVercelProfile(accessToken) {
|
|
214
|
+
const res = await fetch('https://api.vercel.com/v2/user', {
|
|
215
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
216
|
+
});
|
|
217
|
+
const body = await res.json();
|
|
218
|
+
if (!res.ok) throw new Error(body.error?.message || `Vercel profile lookup failed (HTTP ${res.status})`);
|
|
219
|
+
return body.user?.username || body.user?.email || '';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Supabase's Management API OAuth2 flow (supabase.com/docs/guides/platform/oauth-apps):
|
|
223
|
+
// authorize/token both live under api.supabase.com, token exchange uses HTTP
|
|
224
|
+
// Basic auth (client_id:client_secret) rather than form-body credentials.
|
|
225
|
+
const SUPABASE_SCOPES = 'all';
|
|
226
|
+
|
|
227
|
+
function buildSupabaseAuthUrl(clientId, redirectUri) {
|
|
228
|
+
const params = new URLSearchParams({
|
|
229
|
+
client_id: clientId, redirect_uri: redirectUri,
|
|
230
|
+
response_type: 'code', scope: SUPABASE_SCOPES,
|
|
231
|
+
});
|
|
232
|
+
return `https://api.supabase.com/v1/oauth/authorize?${params}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function exchangeSupabaseCode(clientId, clientSecret, code, redirectUri) {
|
|
236
|
+
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
|
237
|
+
const res = await fetch('https://api.supabase.com/v1/oauth/token', {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Basic ${basic}` },
|
|
240
|
+
body: new URLSearchParams({ code, grant_type: 'authorization_code', redirect_uri: redirectUri }),
|
|
241
|
+
});
|
|
242
|
+
const body = await res.json();
|
|
243
|
+
if (!res.ok) throw new Error(body.error_description || body.error || `Supabase token exchange failed (HTTP ${res.status})`);
|
|
244
|
+
return body; // { access_token, refresh_token, expires_in, ... }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Non-git deployment: upload isn't needed for small projects - files are
|
|
248
|
+
// inlined as base64 directly in the create-deployment body (Vercel's REST API
|
|
249
|
+
// supports both an inline `data`+`encoding` file or a reference to one
|
|
250
|
+
// pre-uploaded via /v2/files; inlining avoids a second round-trip per file
|
|
251
|
+
// and this app only ever deploys folders small enough for that to be fine).
|
|
252
|
+
async function vercelDeploy(accessToken, teamId, projectName, files) {
|
|
253
|
+
// A brand-new project (no projectSettings passed - framework is left to
|
|
254
|
+
// Vercel's own auto-detection) otherwise gets rejected with "projectSettings
|
|
255
|
+
// is required... or use skipAutoDetectionConfirmation=1" on its first-ever
|
|
256
|
+
// deploy, since Vercel wants an explicit confirmation step before it'll
|
|
257
|
+
// guess the framework unattended.
|
|
258
|
+
const params = new URLSearchParams({ skipAutoDetectionConfirmation: '1' });
|
|
259
|
+
if (teamId) params.set('teamId', teamId);
|
|
260
|
+
const res = await fetch(`https://api.vercel.com/v13/deployments${params.toString() ? `?${params}` : ''}`, {
|
|
261
|
+
method: 'POST',
|
|
262
|
+
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
|
263
|
+
body: JSON.stringify({ name: projectName, target: 'production', files }),
|
|
264
|
+
});
|
|
265
|
+
const body = await res.json();
|
|
266
|
+
if (!res.ok) throw new Error(body.error?.message || body.error_description || `Vercel deploy failed (HTTP ${res.status})`);
|
|
267
|
+
return body; // { id, url, readyState, ... }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Bulk-create/update project env vars - `vars` is [{ key, value }]. `type: 'encrypted'` hides values in the dashboard by default, same as a normal manual entry. */
|
|
271
|
+
async function vercelSetEnvVars(accessToken, teamId, projectIdOrName, vars) {
|
|
272
|
+
const params = new URLSearchParams({ upsert: 'true' });
|
|
273
|
+
if (teamId) params.set('teamId', teamId);
|
|
274
|
+
const res = await fetch(`https://api.vercel.com/v10/projects/${encodeURIComponent(projectIdOrName)}/env?${params}`, {
|
|
275
|
+
method: 'POST',
|
|
276
|
+
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
|
277
|
+
body: JSON.stringify(vars.map((v) => ({
|
|
278
|
+
key: v.key, value: v.value, type: 'encrypted', target: ['production', 'preview', 'development'],
|
|
279
|
+
}))),
|
|
280
|
+
});
|
|
281
|
+
const body = await res.json();
|
|
282
|
+
if (!res.ok) throw new Error(body.error?.message || `Vercel env var update failed (HTTP ${res.status})`);
|
|
283
|
+
return body; // { created, failed: [...] }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function supabaseListOrganizations(accessToken) {
|
|
287
|
+
const res = await fetch('https://api.supabase.com/v1/organizations', {
|
|
288
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
289
|
+
});
|
|
290
|
+
const body = await res.json();
|
|
291
|
+
if (!res.ok) throw new Error(body.message || `Supabase organization lookup failed (HTTP ${res.status})`);
|
|
292
|
+
return body; // [{ id, slug, name }]
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const SUPABASE_PROVISION_POLL_MS = 5000;
|
|
296
|
+
const SUPABASE_PROVISION_TIMEOUT_MS = 3 * 60 * 1000;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Project creation is asynchronous - the initial response comes back
|
|
300
|
+
* `status: "INACTIVE"` while Supabase provisions the database, so this polls
|
|
301
|
+
* until it flips to `ACTIVE_HEALTHY` (or gives up after the timeout; the
|
|
302
|
+
* project still exists at that point, it's just not confirmed ready yet).
|
|
303
|
+
*/
|
|
304
|
+
async function supabaseCreateProject(accessToken, { name, organizationSlug, dbPass, region = 'us-east-1' }) {
|
|
305
|
+
const res = await fetch('https://api.supabase.com/v1/projects', {
|
|
306
|
+
method: 'POST',
|
|
307
|
+
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
|
308
|
+
// Despite older docs listing it as optional, the API now rejects project
|
|
309
|
+
// creation without exactly one of region/region_selection set.
|
|
310
|
+
body: JSON.stringify({ name, organization_slug: organizationSlug, db_pass: dbPass, region }),
|
|
311
|
+
});
|
|
312
|
+
let project = await res.json();
|
|
313
|
+
if (!res.ok) throw new Error(project.message || `Supabase project creation failed (HTTP ${res.status})`);
|
|
314
|
+
|
|
315
|
+
const ref = project.ref;
|
|
316
|
+
const deadline = Date.now() + SUPABASE_PROVISION_TIMEOUT_MS;
|
|
317
|
+
while (project.status !== 'ACTIVE_HEALTHY' && Date.now() < deadline) {
|
|
318
|
+
await new Promise((r) => setTimeout(r, SUPABASE_PROVISION_POLL_MS));
|
|
319
|
+
const pr = await fetch(`https://api.supabase.com/v1/projects/${ref}`, { headers: { Authorization: `Bearer ${accessToken}` } });
|
|
320
|
+
project = await pr.json();
|
|
321
|
+
if (!pr.ok) throw new Error(project.message || `Supabase project status check failed (HTTP ${pr.status})`);
|
|
322
|
+
}
|
|
323
|
+
if (project.status !== 'ACTIVE_HEALTHY') {
|
|
324
|
+
throw new Error(`Project ${ref} is still provisioning (status: ${project.status}) after ${SUPABASE_PROVISION_TIMEOUT_MS / 1000}s - it will likely finish shortly; check the Supabase dashboard.`);
|
|
325
|
+
}
|
|
326
|
+
return project; // { ref, name, status, ... }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function supabaseListProjects(accessToken) {
|
|
330
|
+
const res = await fetch('https://api.supabase.com/v1/projects', {
|
|
331
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
332
|
+
});
|
|
333
|
+
const body = await res.json();
|
|
334
|
+
if (!res.ok) throw new Error(body.message || `Supabase project list failed (HTTP ${res.status})`);
|
|
335
|
+
return body; // [{ id, organization_id, name, ref, status, region, ... }]
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Permanent, irreversible - Supabase does not soft-delete or restore a removed project. */
|
|
339
|
+
async function supabaseDeleteProject(accessToken, ref) {
|
|
340
|
+
const res = await fetch(`https://api.supabase.com/v1/projects/${ref}`, {
|
|
341
|
+
method: 'DELETE',
|
|
342
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
343
|
+
});
|
|
344
|
+
if (res.status === 204 || res.status === 200) return true;
|
|
345
|
+
const body = await res.json().catch(() => ({}));
|
|
346
|
+
throw new Error(body.message || `Supabase project deletion failed (HTTP ${res.status})`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** anon/public key + project URL + DB connection string. The connection string is built locally (not fetched) since dbPass was chosen by the caller, not returned by the API. */
|
|
350
|
+
async function supabaseGetProjectKeys(accessToken, ref, dbPass) {
|
|
351
|
+
const res = await fetch(`https://api.supabase.com/v1/projects/${ref}/api-keys?reveal=true`, {
|
|
352
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
353
|
+
});
|
|
354
|
+
const keys = await res.json();
|
|
355
|
+
if (!res.ok) throw new Error(keys.message || `Supabase API key lookup failed (HTTP ${res.status})`);
|
|
356
|
+
const anon = keys.find((k) => /^anon$/i.test(k.name || '') || /anon|publishable/i.test(k.name || k.type || ''));
|
|
357
|
+
if (!anon?.api_key) throw new Error(`Project ${ref} has no anon/public key yet - it may still be finishing setup.`);
|
|
358
|
+
return {
|
|
359
|
+
url: `https://${ref}.supabase.co`,
|
|
360
|
+
anonKey: anon.api_key,
|
|
361
|
+
databaseUrl: `postgresql://postgres:${dbPass}@db.${ref}.supabase.co:5432/postgres`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Supabase OAuth access tokens expire; the refresh token doesn't (until revoked). */
|
|
366
|
+
async function refreshSupabaseToken(clientId, clientSecret, refreshToken) {
|
|
367
|
+
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
|
368
|
+
const res = await fetch('https://api.supabase.com/v1/oauth/token', {
|
|
369
|
+
method: 'POST',
|
|
370
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Basic ${basic}` },
|
|
371
|
+
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken }),
|
|
372
|
+
});
|
|
373
|
+
const body = await res.json().catch(() => ({}));
|
|
374
|
+
if (!res.ok) throw new Error(body.error_description || body.error || `Supabase token refresh failed (HTTP ${res.status})`);
|
|
375
|
+
return body; // { access_token, refresh_token, expires_in }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const API_TIMEOUT_MS = 60_000;
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Generic authenticated JSON call used by the agent's full-access tools
|
|
382
|
+
* (supabase_api / vercel_api). Returns { ok, status, body } and never throws
|
|
383
|
+
* for an HTTP error - the status and the provider's own error body are
|
|
384
|
+
* exactly what the agent needs to see to fix its request.
|
|
385
|
+
*/
|
|
386
|
+
async function apiCall(base, accessToken, method, apiPath, body, query) {
|
|
387
|
+
const cleanPath = '/' + String(apiPath || '').replace(/^\/+/, '');
|
|
388
|
+
const url = new URL(base + cleanPath);
|
|
389
|
+
for (const [k, v] of Object.entries(query || {})) {
|
|
390
|
+
if (v !== undefined && v !== null && v !== '' && !url.searchParams.has(k)) url.searchParams.set(k, v);
|
|
391
|
+
}
|
|
392
|
+
const init = {
|
|
393
|
+
method,
|
|
394
|
+
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
|
|
395
|
+
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
|
396
|
+
};
|
|
397
|
+
if (body !== undefined && body !== null && body !== '' && method !== 'GET' && method !== 'HEAD') {
|
|
398
|
+
init.headers['Content-Type'] = 'application/json';
|
|
399
|
+
init.body = typeof body === 'string' ? body : JSON.stringify(body);
|
|
400
|
+
}
|
|
401
|
+
const res = await fetch(url, init);
|
|
402
|
+
const text = await res.text();
|
|
403
|
+
let parsed = text;
|
|
404
|
+
try { parsed = text ? JSON.parse(text) : null; } catch {}
|
|
405
|
+
return { ok: res.ok, status: res.status, body: parsed, url: url.toString() };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function supabaseApi(accessToken, method, apiPath, body) {
|
|
409
|
+
return apiCall('https://api.supabase.com', accessToken, method, apiPath, body);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Runs SQL against a project's Postgres through the Management API. */
|
|
413
|
+
function supabaseQuery(accessToken, ref, query) {
|
|
414
|
+
return apiCall('https://api.supabase.com', accessToken, 'POST', `/v1/projects/${encodeURIComponent(ref)}/database/query`, { query });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** teamId is appended automatically when the connection was installed to a team. */
|
|
418
|
+
function vercelApi(accessToken, teamId, method, apiPath, body) {
|
|
419
|
+
return apiCall('https://api.vercel.com', accessToken, method, apiPath, body, { teamId });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// GitHub's standard OAuth Apps flow (docs.github.com/apps/oauth-apps) - much
|
|
423
|
+
// simpler registration than Vercel/Supabase: no scopes picker or store
|
|
424
|
+
// listing, just a name + callback URL. `repo` scope is what lets
|
|
425
|
+
// githubCreateRepo actually create repositories under the connected account.
|
|
426
|
+
const GITHUB_SCOPES = 'repo gist';
|
|
427
|
+
|
|
428
|
+
function buildGithubAuthUrl(clientId, redirectUri, state) {
|
|
429
|
+
const params = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, scope: GITHUB_SCOPES });
|
|
430
|
+
if (state) params.set('state', state);
|
|
431
|
+
return `https://github.com/login/oauth/authorize?${params}`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function exchangeGithubCode(clientId, clientSecret, code, redirectUri) {
|
|
435
|
+
const res = await fetch('https://github.com/login/oauth/access_token', {
|
|
436
|
+
method: 'POST',
|
|
437
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
438
|
+
body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code, redirect_uri: redirectUri }),
|
|
439
|
+
});
|
|
440
|
+
const body = await res.json();
|
|
441
|
+
if (!res.ok || body.error) throw new Error(body.error_description || body.error || `GitHub token exchange failed (HTTP ${res.status})`);
|
|
442
|
+
return body; // { access_token, scope, token_type }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function getGithubProfile(accessToken) {
|
|
446
|
+
const res = await fetch('https://api.github.com/user', {
|
|
447
|
+
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github+json' },
|
|
448
|
+
});
|
|
449
|
+
const body = await res.json();
|
|
450
|
+
if (!res.ok) throw new Error(body.message || `GitHub profile lookup failed (HTTP ${res.status})`);
|
|
451
|
+
return body.login || '';
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Creates a new repo under the connected user's account. Defaults to private - safer for an arbitrary local folder than defaulting public. */
|
|
455
|
+
async function githubCreateRepo(accessToken, name, { private: isPrivate = true } = {}) {
|
|
456
|
+
const res = await fetch('https://api.github.com/user/repos', {
|
|
457
|
+
method: 'POST',
|
|
458
|
+
headers: {
|
|
459
|
+
Authorization: `Bearer ${accessToken}`,
|
|
460
|
+
Accept: 'application/vnd.github+json',
|
|
461
|
+
'Content-Type': 'application/json',
|
|
462
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
463
|
+
},
|
|
464
|
+
body: JSON.stringify({ name, private: isPrivate }),
|
|
465
|
+
});
|
|
466
|
+
const body = await res.json();
|
|
467
|
+
if (!res.ok) throw new Error(body.message || `GitHub repo creation failed (HTTP ${res.status})`);
|
|
468
|
+
return body; // { name, full_name, html_url, clone_url, default_branch, ... }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
module.exports = {
|
|
472
|
+
buildGmailAuthUrl, exchangeGmailCode, refreshGmailToken, getGmailProfile, gmailSend, gmailSearch,
|
|
473
|
+
buildSlackAuthUrl, exchangeSlackCode, slackPostMessage, slackListChannels, slackJoinChannel,
|
|
474
|
+
buildVercelAuthUrl, exchangeVercelCode, getVercelProfile, vercelDeploy, vercelSetEnvVars,
|
|
475
|
+
buildSupabaseAuthUrl, exchangeSupabaseCode, refreshSupabaseToken, supabaseListOrganizations, supabaseCreateProject, supabaseGetProjectKeys,
|
|
476
|
+
supabaseListProjects, supabaseDeleteProject, supabaseApi, supabaseQuery, vercelApi,
|
|
477
|
+
buildGithubAuthUrl, exchangeGithubCode, getGithubProfile, githubCreateRepo,
|
|
478
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standing permission rules, set once in a file instead of clicked per chat.
|
|
3
|
+
*
|
|
4
|
+
* ~/.codeply/permissions.json (every project)
|
|
5
|
+
* <project>/.codeply/permissions.json (this project, checked first)
|
|
6
|
+
*
|
|
7
|
+
* {
|
|
8
|
+
* "deny": ["run:git push*", "run:rm*", "write_file:*.env", "supabase_delete_project"],
|
|
9
|
+
* "allow": ["run:npm test", "run:npm run *", "run:git status", "edit_file:src/**"]
|
|
10
|
+
* }
|
|
11
|
+
*
|
|
12
|
+
* A rule is "<tool>" or "<tool>:<pattern>". For run the pattern matches the
|
|
13
|
+
* command's name (npm run dev, git checkout) and the full command line; for
|
|
14
|
+
* file tools it matches the path relative to the project. * matches within a
|
|
15
|
+
* path segment, ** across them, and a trailing " *" also matches the bare
|
|
16
|
+
* command. Deny beats allow; project rules beat global ones; anything no rule
|
|
17
|
+
* mentions is asked as usual. Evaluation is the same idea as opencode's
|
|
18
|
+
* permission/ rulesets.
|
|
19
|
+
*/
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
|
|
24
|
+
function readRules(file) {
|
|
25
|
+
try {
|
|
26
|
+
const j = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
27
|
+
const list = (k) => (Array.isArray(j[k]) ? j[k].map(String).filter(Boolean) : []);
|
|
28
|
+
return { allow: list('allow'), deny: list('deny') };
|
|
29
|
+
} catch { return { allow: [], deny: [] }; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function wildcard(pattern) {
|
|
33
|
+
let re = '';
|
|
34
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
35
|
+
const c = pattern[i];
|
|
36
|
+
if (c === '*' && pattern[i + 1] === '*') { re += '.*'; i++; }
|
|
37
|
+
else if (c === '*') re += '[^/]*';
|
|
38
|
+
else if (c === '?') re += '.';
|
|
39
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
40
|
+
}
|
|
41
|
+
// "npm run *" should also cover plain "npm run".
|
|
42
|
+
re = re.replace(/ \[\^\/\]\*$/, '(?: .*)?').replace(/ \.\*$/, '(?: .*)?');
|
|
43
|
+
return new RegExp(`^${re}$`, process.platform === 'win32' ? 'i' : '');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Strings a request can be matched against for its tool. */
|
|
47
|
+
function subjects(req, cwd) {
|
|
48
|
+
if (req.tool === 'run') {
|
|
49
|
+
const cmd = String(req.detail || '').trim();
|
|
50
|
+
// Commands match by name AND as full text, so "run:npm run *" and
|
|
51
|
+
// "run:git push*" both work.
|
|
52
|
+
return { cmd, names: Array.isArray(req.patterns) ? req.patterns : [] };
|
|
53
|
+
}
|
|
54
|
+
if (req.path) {
|
|
55
|
+
const abs = path.isAbsolute(req.path) ? req.path : path.resolve(cwd || '.', req.path);
|
|
56
|
+
const rel = path.relative(cwd || '.', abs).replace(/\\/g, '/');
|
|
57
|
+
return { path: rel.startsWith('..') ? abs.replace(/\\/g, '/') : rel };
|
|
58
|
+
}
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ruleMatches(rule, req, subj) {
|
|
63
|
+
const i = rule.indexOf(':');
|
|
64
|
+
const tool = (i === -1 ? rule : rule.slice(0, i)).trim();
|
|
65
|
+
const pattern = i === -1 ? null : rule.slice(i + 1).trim();
|
|
66
|
+
if (tool !== req.tool && tool !== '*') return false;
|
|
67
|
+
if (!pattern || pattern === '*') return true;
|
|
68
|
+
const re = wildcard(pattern);
|
|
69
|
+
if (req.tool === 'run') {
|
|
70
|
+
if (re.test(subj.cmd)) return true;
|
|
71
|
+
// For allow on a compound command every part must be covered (checked by
|
|
72
|
+
// the caller); here a single name matching is enough to count.
|
|
73
|
+
return subj.names.some((n) => re.test(n));
|
|
74
|
+
}
|
|
75
|
+
return subj.path ? re.test(subj.path) : false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {object} req the approval request ({tool, detail, patterns, path, danger})
|
|
80
|
+
* @returns {{decision: 'allow'|'deny'|null, rule?: string, file?: string}}
|
|
81
|
+
*/
|
|
82
|
+
function decide(req, cwd) {
|
|
83
|
+
const files = [cwd && path.join(cwd, '.codeply', 'permissions.json'), path.join(os.homedir(), '.codeply', 'permissions.json')].filter(Boolean);
|
|
84
|
+
const sets = files.map((f) => ({ file: f, ...readRules(f) }));
|
|
85
|
+
const subj = subjects(req, cwd);
|
|
86
|
+
for (const s of sets) {
|
|
87
|
+
const hit = s.deny.find((r) => ruleMatches(r, req, subj));
|
|
88
|
+
if (hit) return { decision: 'deny', rule: hit, file: s.file };
|
|
89
|
+
}
|
|
90
|
+
// Risky-looking commands (rm -rf, format, curl | sh...) always ask.
|
|
91
|
+
if (req.danger) return { decision: null };
|
|
92
|
+
for (const s of sets) {
|
|
93
|
+
if (req.tool === 'run') {
|
|
94
|
+
// Allow is judged per command, never on the whole line: "npm run *"
|
|
95
|
+
// must not let "npm run build && npm install" through. A line that
|
|
96
|
+
// can't be split into named commands (subshells, redirects) is never
|
|
97
|
+
// auto-allowed.
|
|
98
|
+
if (!subj.names.length) continue;
|
|
99
|
+
const parts = subj.names.length === 1 ? [{ cmd: subj.cmd, names: subj.names }] : subj.names.map((n) => ({ cmd: n, names: [n] }));
|
|
100
|
+
if (parts.every((p) => s.allow.some((r) => ruleMatches(r, req, p)))) return { decision: 'allow', rule: 'allow rules', file: s.file };
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const hit = s.allow.find((r) => ruleMatches(r, req, subj));
|
|
104
|
+
if (hit) return { decision: 'allow', rule: hit, file: s.file };
|
|
105
|
+
}
|
|
106
|
+
return { decision: null };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { decide, wildcard };
|