@inneranimalmedia/agentsam-sdk 1.2.0 → 1.5.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/README.md +23 -5
- package/package.json +5 -1
- package/src/cli.js +96 -177
- package/src/commands/deploy.js +146 -0
- package/src/commands/start-local.js +61 -0
- package/src/lib/auth.js +76 -0
- package/src/lib/core-client.js +88 -0
- package/src/lib/detect-context.js +345 -0
- package/src/lib/gcp-setup.js +54 -0
- package/src/lib/local-scaffold.js +373 -0
- package/src/lib/prompt-byok.js +57 -0
- package/src/lib/save-sdk-token.js +19 -0
- package/src/lib/scaffold.js +7 -259
- package/src/lib/slash-commands.js +1 -1
- package/src/lib/write-files.js +21 -0
- package/src/local-pty/server.js +133 -0
- package/test/smoke.mjs +24 -5
package/src/lib/auth.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser OAuth for SDK init — one click IAM login + Cloudflare connect.
|
|
3
|
+
*/
|
|
4
|
+
import http from 'http';
|
|
5
|
+
import { randomBytes } from 'crypto';
|
|
6
|
+
import { postJson } from './core-client.js';
|
|
7
|
+
|
|
8
|
+
function randomState() {
|
|
9
|
+
return randomBytes(16).toString('hex');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function openBrowser(url) {
|
|
13
|
+
const start =
|
|
14
|
+
process.platform === 'darwin'
|
|
15
|
+
? ['open', url]
|
|
16
|
+
: process.platform === 'win32'
|
|
17
|
+
? ['cmd', '/c', 'start', '', url]
|
|
18
|
+
: ['xdg-open', url];
|
|
19
|
+
import('child_process').then(({ spawn }) => {
|
|
20
|
+
spawn(start[0], start.slice(1), { stdio: 'ignore', detached: true }).unref();
|
|
21
|
+
}).catch(() => {
|
|
22
|
+
console.log(`\n Open in browser:\n ${url}\n`);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @returns {Promise<{ access_token: string, user_id: string, workspace_id: string, tenant_id: string }>}
|
|
28
|
+
*/
|
|
29
|
+
export async function authenticateViaBrowser() {
|
|
30
|
+
const state = randomState();
|
|
31
|
+
const port = 8791 + (randomBytes(1)[0] % 20);
|
|
32
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
33
|
+
|
|
34
|
+
const { auth_url: authUrl } = await postJson('/api/sdk/auth/start', {
|
|
35
|
+
redirect_uri: redirectUri,
|
|
36
|
+
state,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const codePromise = new Promise((resolve, reject) => {
|
|
40
|
+
const server = http.createServer((req, res) => {
|
|
41
|
+
try {
|
|
42
|
+
const u = new URL(req.url || '/', `http://127.0.0.1:${port}`);
|
|
43
|
+
if (u.pathname !== '/callback') {
|
|
44
|
+
res.writeHead(404);
|
|
45
|
+
res.end('Not found');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const code = u.searchParams.get('code');
|
|
49
|
+
const gotState = u.searchParams.get('state');
|
|
50
|
+
if (!code || gotState !== state) {
|
|
51
|
+
res.writeHead(400);
|
|
52
|
+
res.end('Invalid callback');
|
|
53
|
+
reject(new Error('auth callback invalid'));
|
|
54
|
+
server.close();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
58
|
+
res.end('<html><body style="font-family:system-ui"><h1>Agent Sam</h1><p>You can close this tab.</p></body></html>');
|
|
59
|
+
resolve(code);
|
|
60
|
+
server.close();
|
|
61
|
+
} catch (e) {
|
|
62
|
+
reject(e);
|
|
63
|
+
server.close();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
server.on('error', reject);
|
|
67
|
+
server.listen(port, '127.0.0.1');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
console.log('\n Opening browser for IAM sign-in + Cloudflare connect…\n');
|
|
71
|
+
openBrowser(authUrl);
|
|
72
|
+
|
|
73
|
+
const code = await codePromise;
|
|
74
|
+
const session = await postJson('/api/sdk/auth/exchange', { code, state });
|
|
75
|
+
return session;
|
|
76
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IAM CORE client — SDK is a delivery mechanism; intelligence lives server-side.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CORE = 'https://inneranimalmedia.com';
|
|
6
|
+
|
|
7
|
+
export function coreBaseUrl() {
|
|
8
|
+
return (process.env.IAM_CORE_URL || process.env.AGENTSAM_CORE_URL || DEFAULT_CORE).replace(/\/$/, '');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function postJson(path, body, token) {
|
|
12
|
+
const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
|
13
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
14
|
+
const res = await fetch(`${coreBaseUrl()}${path}`, {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers,
|
|
17
|
+
body: JSON.stringify(body ?? {}),
|
|
18
|
+
});
|
|
19
|
+
const data = await res.json().catch(() => ({}));
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
const msg = data?.error || data?.message || `HTTP ${res.status}`;
|
|
22
|
+
throw new Error(String(msg));
|
|
23
|
+
}
|
|
24
|
+
return data;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function getJson(path, token) {
|
|
28
|
+
const headers = { Accept: 'application/json' };
|
|
29
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
30
|
+
const res = await fetch(`${coreBaseUrl()}${path}`, { headers });
|
|
31
|
+
const data = await res.json().catch(() => ({}));
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const msg = data?.error || data?.message || `HTTP ${res.status}`;
|
|
34
|
+
throw new Error(String(msg));
|
|
35
|
+
}
|
|
36
|
+
return data;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stream NDJSON from POST /api/sdk/scaffold — calls onEvent for each line.
|
|
41
|
+
*/
|
|
42
|
+
export async function streamScaffold(body, token, onEvent) {
|
|
43
|
+
const res = await fetch(`${coreBaseUrl()}/api/sdk/scaffold`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: {
|
|
46
|
+
'Content-Type': 'application/json',
|
|
47
|
+
Accept: 'application/x-ndjson',
|
|
48
|
+
Authorization: `Bearer ${token}`,
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify(body),
|
|
51
|
+
});
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
const data = await res.json().catch(() => ({}));
|
|
54
|
+
throw new Error(data?.error || `scaffold HTTP ${res.status}`);
|
|
55
|
+
}
|
|
56
|
+
if (!res.body) throw new Error('scaffold stream missing');
|
|
57
|
+
|
|
58
|
+
const reader = res.body.getReader();
|
|
59
|
+
const dec = new TextDecoder();
|
|
60
|
+
let buf = '';
|
|
61
|
+
|
|
62
|
+
while (true) {
|
|
63
|
+
const { done, value } = await reader.read();
|
|
64
|
+
if (done) break;
|
|
65
|
+
buf += dec.decode(value, { stream: true });
|
|
66
|
+
const lines = buf.split('\n');
|
|
67
|
+
buf = lines.pop() || '';
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
const t = line.trim();
|
|
70
|
+
if (!t) continue;
|
|
71
|
+
let evt;
|
|
72
|
+
try {
|
|
73
|
+
evt = JSON.parse(t);
|
|
74
|
+
} catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
await onEvent(evt);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const tail = buf.trim();
|
|
81
|
+
if (tail) {
|
|
82
|
+
try {
|
|
83
|
+
await onEvent(JSON.parse(tail));
|
|
84
|
+
} catch {
|
|
85
|
+
/* ignore */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect runtime credentials before init — VM metadata, gcloud, gh, wrangler, IAM env.
|
|
3
|
+
* Print what's available; only prompt for what's missing.
|
|
4
|
+
*/
|
|
5
|
+
import { execFile } from 'child_process';
|
|
6
|
+
import { promisify } from 'util';
|
|
7
|
+
import { coreBaseUrl } from './core-client.js';
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
const METADATA_BASE = 'http://metadata.google.internal/computeMetadata/v1';
|
|
12
|
+
const METADATA_HEADERS = { 'Metadata-Flavor': 'Google' };
|
|
13
|
+
|
|
14
|
+
async function tryExec(cmd, args, timeoutMs = 4000) {
|
|
15
|
+
try {
|
|
16
|
+
const { stdout } = await execFileAsync(cmd, args, {
|
|
17
|
+
timeout: timeoutMs,
|
|
18
|
+
env: process.env,
|
|
19
|
+
maxBuffer: 512 * 1024,
|
|
20
|
+
});
|
|
21
|
+
return stdout.trim() || null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function fetchMetadata(path, timeoutMs = 500) {
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch(`${METADATA_BASE}${path}`, {
|
|
30
|
+
headers: METADATA_HEADERS,
|
|
31
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok) return null;
|
|
34
|
+
return (await res.text()).trim() || null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function detectGcpVm() {
|
|
41
|
+
const tokenPath =
|
|
42
|
+
'/instance/service-accounts/default/token?scopes=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform';
|
|
43
|
+
const tokenRes = await fetchMetadata(tokenPath);
|
|
44
|
+
if (!tokenRes) return null;
|
|
45
|
+
|
|
46
|
+
let email = await fetchMetadata('/instance/service-accounts/default/email');
|
|
47
|
+
if (!email) email = await fetchMetadata('/instance/service-accounts/default/');
|
|
48
|
+
|
|
49
|
+
const projectId = await fetchMetadata('/project/project-id');
|
|
50
|
+
const userClaim = (process.env.USER_GCP_PROJECT || process.env.GOOGLE_CLOUD_PROJECT || '').trim();
|
|
51
|
+
const claimedMatch = userClaim && projectId && userClaim === projectId;
|
|
52
|
+
|
|
53
|
+
let parsed = null;
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(tokenRes);
|
|
56
|
+
} catch {
|
|
57
|
+
/* ignore */
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
source: 'vm-metadata',
|
|
62
|
+
email: email || null,
|
|
63
|
+
project_id: projectId || null,
|
|
64
|
+
scope: claimedMatch ? 'user-claimed' : 'unverified',
|
|
65
|
+
note: claimedMatch
|
|
66
|
+
? 'USER_GCP_PROJECT matches VM metadata'
|
|
67
|
+
: 'VM metadata alone does not prove this is YOUR project — confirm or run gcloud auth login --no-browser',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function detectGcloud() {
|
|
72
|
+
const configuredProject = await tryExec('gcloud', ['config', 'get-value', 'project']);
|
|
73
|
+
const userClaim = (process.env.USER_GCP_PROJECT || '').trim();
|
|
74
|
+
const account = await tryExec('gcloud', ['config', 'get-value', 'account']);
|
|
75
|
+
|
|
76
|
+
const adc = await tryExec('gcloud', ['auth', 'application-default', 'print-access-token']);
|
|
77
|
+
if (adc) {
|
|
78
|
+
return {
|
|
79
|
+
source: 'application-default',
|
|
80
|
+
account: account || null,
|
|
81
|
+
project_id: configuredProject || null,
|
|
82
|
+
scope: userClaim && configuredProject === userClaim ? 'user-claimed' : 'local',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const token = await tryExec('gcloud', ['auth', 'print-access-token']);
|
|
87
|
+
if (!token) return null;
|
|
88
|
+
return {
|
|
89
|
+
source: 'gcloud',
|
|
90
|
+
account: account || null,
|
|
91
|
+
project_id: configuredProject || null,
|
|
92
|
+
scope: 'local',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function detectGithub() {
|
|
97
|
+
const envTok = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_PAT;
|
|
98
|
+
if (envTok && String(envTok).trim()) {
|
|
99
|
+
return { source: 'env', account: 'GITHUB_TOKEN/GH_TOKEN set' };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const status = await tryExec('gh', ['auth', 'status']);
|
|
103
|
+
if (!status) return null;
|
|
104
|
+
|
|
105
|
+
const loggedIn =
|
|
106
|
+
/logged in/i.test(status) ||
|
|
107
|
+
/Logged in to/i.test(status) ||
|
|
108
|
+
status.includes('✓');
|
|
109
|
+
if (!loggedIn) return null;
|
|
110
|
+
|
|
111
|
+
const accountMatch =
|
|
112
|
+
status.match(/account\s+(\S+)/i) ||
|
|
113
|
+
status.match(/Logged in to github\.com as (\S+)/i);
|
|
114
|
+
return {
|
|
115
|
+
source: 'gh-cli',
|
|
116
|
+
account: accountMatch?.[1] || null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function detectCloudflare() {
|
|
121
|
+
const envTok = process.env.CLOUDFLARE_API_TOKEN;
|
|
122
|
+
if (envTok && String(envTok).trim()) {
|
|
123
|
+
const acct = process.env.CLOUDFLARE_ACCOUNT_ID || null;
|
|
124
|
+
return {
|
|
125
|
+
source: 'env',
|
|
126
|
+
account: acct ? `account ${acct}` : 'CLOUDFLARE_API_TOKEN set',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const jsonOut = await tryExec('wrangler', ['whoami', '--json']);
|
|
131
|
+
if (jsonOut) {
|
|
132
|
+
try {
|
|
133
|
+
const data = JSON.parse(jsonOut);
|
|
134
|
+
const email = data?.email || data?.user?.email || null;
|
|
135
|
+
const accounts = Array.isArray(data?.accounts) ? data.accounts : [];
|
|
136
|
+
const acctLabel =
|
|
137
|
+
accounts.length === 1
|
|
138
|
+
? accounts[0]?.name || accounts[0]?.id
|
|
139
|
+
: accounts.length > 1
|
|
140
|
+
? `${accounts.length} accounts`
|
|
141
|
+
: null;
|
|
142
|
+
return {
|
|
143
|
+
source: 'wrangler',
|
|
144
|
+
account: email || acctLabel || 'logged in',
|
|
145
|
+
};
|
|
146
|
+
} catch {
|
|
147
|
+
/* fall through */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const textOut = await tryExec('wrangler', ['whoami']);
|
|
152
|
+
if (!textOut) return null;
|
|
153
|
+
if (
|
|
154
|
+
!/logged in/i.test(textOut) &&
|
|
155
|
+
!/You are logged in/i.test(textOut) &&
|
|
156
|
+
!/Account ID/i.test(textOut)
|
|
157
|
+
) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const emailMatch = textOut.match(/[\w.+-]+@[\w.-]+\.\w+/);
|
|
162
|
+
const acctMatch = textOut.match(/Account ID[:\s]+([a-f0-9]{32})/i);
|
|
163
|
+
return {
|
|
164
|
+
source: 'wrangler',
|
|
165
|
+
account: emailMatch?.[0] || (acctMatch ? `account ${acctMatch[1]}` : 'logged in'),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function probeSdkBearer(token) {
|
|
170
|
+
const t = String(token || '').trim();
|
|
171
|
+
if (!t || !t.startsWith('sdk_')) {
|
|
172
|
+
return { valid: false, error: 'not_sdk_bearer' };
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const res = await fetch(`${coreBaseUrl()}/api/sdk/context`, {
|
|
176
|
+
headers: { Accept: 'application/json', Authorization: `Bearer ${t}` },
|
|
177
|
+
signal: AbortSignal.timeout(8000),
|
|
178
|
+
});
|
|
179
|
+
const data = await res.json().catch(() => ({}));
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
return { valid: false, error: data?.error || `HTTP ${res.status}` };
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
valid: true,
|
|
185
|
+
user_id: data.user_id || null,
|
|
186
|
+
workspace_id: data.workspace_id || null,
|
|
187
|
+
cloudflare_connected: data?.cloudflare?.ok === true,
|
|
188
|
+
};
|
|
189
|
+
} catch (e) {
|
|
190
|
+
return { valid: false, error: e?.message || String(e) };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function envPresent(name) {
|
|
195
|
+
const v = process.env[name];
|
|
196
|
+
return v != null && String(v).trim() !== '';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** IAM auth for SDK init — only verified sdk_* bearer counts as ready. */
|
|
200
|
+
async function detectIam(explicitToken = '') {
|
|
201
|
+
const aux = [];
|
|
202
|
+
|
|
203
|
+
if (envPresent('AGENTSAM_BRIDGE_KEY')) {
|
|
204
|
+
aux.push({
|
|
205
|
+
var: 'AGENTSAM_BRIDGE_KEY',
|
|
206
|
+
role: 'platform bridge (X-Bridge-Key)',
|
|
207
|
+
sdk_auth: false,
|
|
208
|
+
note: 'ExecOS/MCP worker trust — not CLI IAM auth',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const workerKey = envPresent('IAM_API_KEY') || envPresent('AGENTSAM_API_KEY');
|
|
213
|
+
if (workerKey) {
|
|
214
|
+
const name = envPresent('IAM_API_KEY') ? 'IAM_API_KEY' : 'AGENTSAM_API_KEY';
|
|
215
|
+
aux.push({
|
|
216
|
+
var: name,
|
|
217
|
+
role: 'Worker runtime secret',
|
|
218
|
+
sdk_auth: false,
|
|
219
|
+
note: 'wrangler secret on YOUR Worker — not CORE SDK bearer',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const sdkToken = explicitToken || process.env.AGENTSAM_SDK_TOKEN || '';
|
|
224
|
+
if (sdkToken.trim()) {
|
|
225
|
+
const probe = await probeSdkBearer(sdkToken);
|
|
226
|
+
if (probe.valid) {
|
|
227
|
+
return {
|
|
228
|
+
source: 'sdk-token',
|
|
229
|
+
ready: true,
|
|
230
|
+
detail: `AGENTSAM_SDK_TOKEN · user ${probe.user_id || '?'}`,
|
|
231
|
+
probe,
|
|
232
|
+
aux,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
source: 'sdk-token',
|
|
237
|
+
ready: false,
|
|
238
|
+
detail: `AGENTSAM_SDK_TOKEN invalid (${probe.error}) → will open browser`,
|
|
239
|
+
probe,
|
|
240
|
+
aux,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const ptyUser = process.env.IAM_PTY_USER_ID || '';
|
|
245
|
+
if (ptyUser.trim()) {
|
|
246
|
+
return {
|
|
247
|
+
source: 'execos-env',
|
|
248
|
+
ready: false,
|
|
249
|
+
detail: `IAM_PTY_USER_ID set (ExecOS identity — SDK bearer still needed)`,
|
|
250
|
+
aux,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (aux.length) {
|
|
255
|
+
return {
|
|
256
|
+
source: null,
|
|
257
|
+
ready: false,
|
|
258
|
+
detail: 'not found → will open browser',
|
|
259
|
+
aux,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return { source: null, ready: false, detail: 'not found → will open browser', aux: [] };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @returns {Promise<{
|
|
268
|
+
* iam: ReturnType<typeof detectIam>,
|
|
269
|
+
* gcp: Awaited<ReturnType<typeof detectGcpVm>> | Awaited<ReturnType<typeof detectGcloud>> | null,
|
|
270
|
+
* gcp_vm: boolean,
|
|
271
|
+
* github: Awaited<ReturnType<typeof detectGithub>>,
|
|
272
|
+
* cloudflare: Awaited<ReturnType<typeof detectCloudflare>>,
|
|
273
|
+
* }>}
|
|
274
|
+
*/
|
|
275
|
+
export async function detectContext(opts = {}) {
|
|
276
|
+
const [gcpVm, gcloud, github, cloudflare] = await Promise.all([
|
|
277
|
+
detectGcpVm(),
|
|
278
|
+
detectGcloud(),
|
|
279
|
+
detectGithub(),
|
|
280
|
+
detectCloudflare(),
|
|
281
|
+
]);
|
|
282
|
+
|
|
283
|
+
const iam = await detectIam(opts.token || '');
|
|
284
|
+
const gcp = gcpVm || gcloud;
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
iam,
|
|
288
|
+
gcp,
|
|
289
|
+
gcp_vm: Boolean(gcpVm),
|
|
290
|
+
github,
|
|
291
|
+
cloudflare,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function formatCell(label, slot, width = 10) {
|
|
296
|
+
const status = slot ? slot.source || 'yes' : 'not found';
|
|
297
|
+
const detail = slot?.account || slot?.email || slot?.project_id || slot?.detail || '';
|
|
298
|
+
const line = `${label.padEnd(8)} ${String(status).padEnd(width)}`;
|
|
299
|
+
return detail ? `${line} (${detail})` : line;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** @param {Awaited<ReturnType<typeof detectContext>>} ctx */
|
|
303
|
+
export function printContextSummary(ctx) {
|
|
304
|
+
console.log('\n Detected credentials (informational — local init needs none):\n');
|
|
305
|
+
if (ctx.iam.ready) {
|
|
306
|
+
console.log(` ${formatCell('IAM', { source: ctx.iam.source, account: ctx.iam.detail })}`);
|
|
307
|
+
} else {
|
|
308
|
+
console.log(` ${'IAM'.padEnd(8)} not found (${ctx.iam.detail || 'will open browser'})`);
|
|
309
|
+
}
|
|
310
|
+
console.log(` ${formatCell('GCP', ctx.gcp)}`);
|
|
311
|
+
if (ctx.gcp?.scope === 'unverified') {
|
|
312
|
+
console.log(
|
|
313
|
+
` ${''.padEnd(8)} ⚠ VM project ${ctx.gcp.project_id || '?'} — not assumed yours (set USER_GCP_PROJECT to confirm)`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
console.log(` ${formatCell('GitHub', ctx.github)}`);
|
|
317
|
+
console.log(` ${formatCell('CF', ctx.cloudflare)}`);
|
|
318
|
+
if (ctx.cloudflare && !ctx.iam.ready) {
|
|
319
|
+
console.log('\n Note: local wrangler/CF token helps on this machine; scaffold still uses IAM Cloudflare OAuth.');
|
|
320
|
+
}
|
|
321
|
+
if (ctx.gcp_vm && ctx.gcp?.scope === 'unverified') {
|
|
322
|
+
console.log(
|
|
323
|
+
'\n GCP VM metadata detected — this may be a shared platform VM, not YOUR Google Cloud project.',
|
|
324
|
+
);
|
|
325
|
+
console.log(' Connect yours: gcloud auth login --no-browser && gcloud config set project YOUR_PROJECT_ID');
|
|
326
|
+
} else if (ctx.gcp_vm && ctx.gcp?.scope === 'user-claimed') {
|
|
327
|
+
console.log('\n GCP VM verified as your project via USER_GCP_PROJECT.');
|
|
328
|
+
}
|
|
329
|
+
if (Array.isArray(ctx.iam.aux) && ctx.iam.aux.length) {
|
|
330
|
+
console.log('\n Other env (not SDK IAM auth):');
|
|
331
|
+
for (const row of ctx.iam.aux) {
|
|
332
|
+
console.log(` ${row.var.padEnd(22)} ${row.note}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
console.log('');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** @param {Awaited<ReturnType<typeof detectContext>>} ctx @param {string} [presetToken] @param {{ runTarget?: string }} [opts] */
|
|
339
|
+
export function missingForInit(ctx, presetToken = '', opts = {}) {
|
|
340
|
+
const runTarget = opts.runTarget || 'local';
|
|
341
|
+
if (runTarget === 'local') return [];
|
|
342
|
+
const missing = [];
|
|
343
|
+
if (!ctx.iam.ready) missing.push('iam');
|
|
344
|
+
return missing;
|
|
345
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GCP setup guidance — never assume platform VM is the user's project.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function printGcpConnectGuide() {
|
|
6
|
+
console.log(`
|
|
7
|
+
Connect YOUR Google Cloud project:
|
|
8
|
+
|
|
9
|
+
# SSH / headless (open URL on any device, paste code back):
|
|
10
|
+
gcloud auth login --no-browser
|
|
11
|
+
gcloud config set project YOUR_PROJECT_ID
|
|
12
|
+
export USER_GCP_PROJECT=YOUR_PROJECT_ID
|
|
13
|
+
|
|
14
|
+
# On YOUR GCP VM (your service account on your project):
|
|
15
|
+
gcloud auth application-default print-access-token
|
|
16
|
+
|
|
17
|
+
Agent Sam does not use Inner Animal Media's platform VM as yours unless
|
|
18
|
+
USER_GCP_PROJECT matches the VM metadata project-id.
|
|
19
|
+
`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {import('./detect-context.js').detectContext extends (...args: any) => Promise<infer R> ? R : never} ctx
|
|
24
|
+
* @param {{ ask: (q: string) => Promise<string> } | null} prompt
|
|
25
|
+
*/
|
|
26
|
+
export async function confirmGcpOwnership(ctx, prompt) {
|
|
27
|
+
if (!ctx?.gcp || ctx.gcp.scope !== 'unverified' || !ctx.gcp_vm) {
|
|
28
|
+
return ctx;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!prompt) {
|
|
32
|
+
printGcpConnectGuide();
|
|
33
|
+
return ctx;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const pid = ctx.gcp.project_id || 'unknown';
|
|
37
|
+
const ans = await prompt.ask(
|
|
38
|
+
` GCP VM metadata shows project "${pid}". Is this YOUR Google Cloud project? (y/n): `,
|
|
39
|
+
);
|
|
40
|
+
if (ans.toLowerCase() === 'y') {
|
|
41
|
+
console.log(`\n Tip: export USER_GCP_PROJECT=${pid} to skip this prompt next time.\n`);
|
|
42
|
+
return {
|
|
43
|
+
...ctx,
|
|
44
|
+
gcp: { ...ctx.gcp, scope: 'user-confirmed' },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
printGcpConnectGuide();
|
|
49
|
+
return {
|
|
50
|
+
...ctx,
|
|
51
|
+
gcp: null,
|
|
52
|
+
gcp_vm: false,
|
|
53
|
+
};
|
|
54
|
+
}
|