@bnbagent/deploy-provider-bnb 0.3.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/package.json +27 -0
- package/src/api-types.ts +147 -0
- package/src/artifact.ts +121 -0
- package/src/client.ts +258 -0
- package/src/extras.ts +150 -0
- package/src/image.ts +222 -0
- package/src/index.ts +591 -0
- package/src/init.ts +55 -0
- package/src/session.ts +94 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bnbagent/deploy-provider-bnb — the BNB Chain trial-platform provider.
|
|
3
|
+
*
|
|
4
|
+
* Runtime: **bnb/trial** (the managed free-trial service, bnbagent-api). Unlike
|
|
5
|
+
* aws/azure this provider talks to a PLATFORM over plain HTTPS — you bring no
|
|
6
|
+
* cloud credentials; GitHub device-flow login is the only auth. The platform
|
|
7
|
+
* deploys to its own AWS account (48h testnet trial) and exposes the agent
|
|
8
|
+
* through its gateway.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately mechanics-only: business gates that belong to the caller
|
|
11
|
+
* (trial terms, campaign windows, wallet warnings in bnbagent-studio) stay
|
|
12
|
+
* OUT of this provider — it maps the CloudProvider lifecycle onto the
|
|
13
|
+
* platform's HTTP API (bnbagent-api docs/client-integration.md) and nothing else.
|
|
14
|
+
*
|
|
15
|
+
* ★ All platform knowledge stays inside this package; core is unaware of it.
|
|
16
|
+
*/
|
|
17
|
+
import type {
|
|
18
|
+
AgentListing,
|
|
19
|
+
CloudProvider,
|
|
20
|
+
Context,
|
|
21
|
+
DeploymentSpec,
|
|
22
|
+
DeployResult,
|
|
23
|
+
Status,
|
|
24
|
+
} from "@bnbagent/deploy-core";
|
|
25
|
+
import { artifactReadinessIssues } from "@bnbagent/deploy-core";
|
|
26
|
+
import { apiUrl, authedRequest, publicRequest, sameOrigin, envApiToken, BnbApiError } from "./client.ts";
|
|
27
|
+
import { loadSession, saveSession, clearSession } from "./session.ts";
|
|
28
|
+
import { shipZipArtifact } from "./artifact.ts";
|
|
29
|
+
import { shipImageArtifact } from "./image.ts";
|
|
30
|
+
import { initCmd } from "./init.ts";
|
|
31
|
+
import type {
|
|
32
|
+
AccountInfo,
|
|
33
|
+
AgentInfo,
|
|
34
|
+
CampaignStatus,
|
|
35
|
+
DeploymentAccepted,
|
|
36
|
+
DeploymentStatus,
|
|
37
|
+
DevicePending,
|
|
38
|
+
DeviceStart,
|
|
39
|
+
TokenPair,
|
|
40
|
+
TrialStatus,
|
|
41
|
+
} from "./api-types.ts";
|
|
42
|
+
import { agentIdOf } from "./api-types.ts";
|
|
43
|
+
|
|
44
|
+
const DEFAULT_RUNTIME = "trial";
|
|
45
|
+
const SUPPORTED_RUNTIMES = [DEFAULT_RUNTIME];
|
|
46
|
+
|
|
47
|
+
/** Platform slug rule (per-tenant agent name). */
|
|
48
|
+
const SLUG_RE = /^[a-z][a-z0-9-]{1,40}$/;
|
|
49
|
+
|
|
50
|
+
function resolveProviderRuntime(ctx: Context): string {
|
|
51
|
+
const runtime = ctx.runtime ?? DEFAULT_RUNTIME;
|
|
52
|
+
if (SUPPORTED_RUNTIMES.includes(runtime)) return runtime;
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Unknown runtime 'bnb/${runtime}'. Available: ${SUPPORTED_RUNTIMES.map((r) => `bnb/${r}`).join(", ")}.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Map both agent states (provisioning/updating/…) and deployment states (queued/deploying/…). */
|
|
59
|
+
function mapState(state: string | undefined): Status["state"] {
|
|
60
|
+
switch (state) {
|
|
61
|
+
case "ready":
|
|
62
|
+
return "running";
|
|
63
|
+
case "queued":
|
|
64
|
+
case "deploying":
|
|
65
|
+
case "provisioning":
|
|
66
|
+
case "updating":
|
|
67
|
+
return "deploying";
|
|
68
|
+
case "failed":
|
|
69
|
+
return "failed";
|
|
70
|
+
case "reclaiming":
|
|
71
|
+
case "deleted":
|
|
72
|
+
return "stopped";
|
|
73
|
+
default:
|
|
74
|
+
return "unknown";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The platform stores deploy issues as {level,code,message} objects OR plain strings. */
|
|
79
|
+
function formatIssues(issues: unknown): string {
|
|
80
|
+
if (!Array.isArray(issues) || issues.length === 0) return "";
|
|
81
|
+
const lines = issues.map((i) =>
|
|
82
|
+
typeof i === "string"
|
|
83
|
+
? i
|
|
84
|
+
: i && typeof i === "object"
|
|
85
|
+
? [(i as any).code, (i as any).message].filter(Boolean).join(": ") || JSON.stringify(i)
|
|
86
|
+
: String(i),
|
|
87
|
+
);
|
|
88
|
+
return `\n ${lines.join("\n ")}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Slug is validated only in validate(); encode it defensively on every path build. */
|
|
92
|
+
function slugPath(id: string): string {
|
|
93
|
+
return encodeURIComponent(id);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The HARD spec contract for bnb/trial. validate() reports these early;
|
|
98
|
+
* deploy() re-asserts UNCONDITIONALLY — --force / skipValidation only skip the
|
|
99
|
+
* operational gates (sign-in/campaign), never the platform contract. All
|
|
100
|
+
* checks run BEFORE the artifact upload, so a doomed deploy costs nothing.
|
|
101
|
+
*/
|
|
102
|
+
function specContractIssues(spec: DeploymentSpec, ctx: Context): string[] {
|
|
103
|
+
const issues: string[] = [];
|
|
104
|
+
try {
|
|
105
|
+
resolveProviderRuntime(ctx);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
issues.push((e as Error).message);
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
apiUrl(ctx); // the platform endpoint is a hard prerequisite (no localhost default)
|
|
111
|
+
} catch (e) {
|
|
112
|
+
issues.push((e as Error).message);
|
|
113
|
+
}
|
|
114
|
+
if (!SLUG_RE.test(spec.name)) {
|
|
115
|
+
issues.push(
|
|
116
|
+
`The trial platform requires a slug-style name (^[a-z][a-z0-9-]{1,40}$) — got "${spec.name}".`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (spec.type === "zip") {
|
|
120
|
+
if (!spec.dist) issues.push('deploy.dist is required for bnb/trial (type = "zip").');
|
|
121
|
+
const entryProblem = entrypointIssue(spec);
|
|
122
|
+
if (entryProblem) issues.push(entryProblem);
|
|
123
|
+
} else if (spec.type === "image") {
|
|
124
|
+
if (!spec.image) issues.push('deploy.image is required for type = "image".');
|
|
125
|
+
}
|
|
126
|
+
// container: the Dockerfile is resolved at deploy time (same rules as aws —
|
|
127
|
+
// this tool never generates one). NOTE: the platform pins artifact type per
|
|
128
|
+
// slug — zip↔image on an existing slug is a 409 (deploy.artifact_type_immutable).
|
|
129
|
+
if (spec.secrets?.refs && Object.keys(spec.secrets.refs).length > 0) {
|
|
130
|
+
issues.push(
|
|
131
|
+
"[secrets] refs (secret://…) reference YOUR cloud's secret store — the trial platform " +
|
|
132
|
+
"can't read it. Use secrets.envFile instead (values travel TLS-encrypted to the platform).",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return issues;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function assertSpecContract(spec: DeploymentSpec, ctx: Context): void {
|
|
139
|
+
const issues = specContractIssues(spec, ctx);
|
|
140
|
+
if (issues.length > 0) throw new Error(issues.join("\n"));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A deleted slug is permanently retired by the platform — a deploy against it
|
|
145
|
+
* is doomed AFTER the artifact upload. Cheap GET first so validate reports it
|
|
146
|
+
* and deploy fails before shipping bytes. Returns undefined when the slug is
|
|
147
|
+
* fresh/live or when the check itself can't run (those errors surface elsewhere).
|
|
148
|
+
*/
|
|
149
|
+
async function slugRetiredIssue(name: string, ctx: Context): Promise<string | undefined> {
|
|
150
|
+
const retired = `The slug '${name}' was deleted and is permanently retired by the platform — deploys to it will always fail.`;
|
|
151
|
+
try {
|
|
152
|
+
const agent: AgentInfo = await authedRequest(ctx, "GET", `/v1/agents/${slugPath(name)}`);
|
|
153
|
+
if (agent.state === "deleted" || agent.state === "reclaiming") return retired;
|
|
154
|
+
} catch (e) {
|
|
155
|
+
if (e instanceof BnbApiError && (e.status === 410 || e.code === "agent.deleted")) return retired;
|
|
156
|
+
// 404 = fresh slug; anything else (auth/network) is another gate's job.
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function entrypointIssue(spec: DeploymentSpec): string | undefined {
|
|
162
|
+
const e = spec.entrypoint;
|
|
163
|
+
if (!e) return undefined; // config.ts enforces presence for zip
|
|
164
|
+
if (e.includes("/") || e.includes("\\")) {
|
|
165
|
+
return (
|
|
166
|
+
`deploy.entrypoint "${e}" must be a ROOT-LEVEL file in the bundle — the trial ` +
|
|
167
|
+
`platform resolves it at the zip root (move the file up, or adjust your packaging).`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
// The PLATFORM's exact rule (api ENTRYPOINT_RE): [A-Za-z0-9._-] name + a runtime
|
|
171
|
+
// extension. A looser local check would let e.g. "my agent.py" fail only AFTER
|
|
172
|
+
// the artifact upload — a doomed deploy must cost nothing.
|
|
173
|
+
if (!/^[A-Za-z0-9._-]+\.(js|mjs|cjs|py)$/.test(e)) {
|
|
174
|
+
return (
|
|
175
|
+
`deploy.entrypoint "${e}" is not a valid trial-platform entrypoint — letters/digits/._- only, ` +
|
|
176
|
+
`ending in .py (Python) or .js/.mjs/.cjs (Node).`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Structured trial status (GET /v1/account/trial) — the gate orchestrators read before offering a deploy. */
|
|
183
|
+
export async function getTrialStatus(ctx: Context): Promise<TrialStatus> {
|
|
184
|
+
return authedRequest(ctx, "GET", "/v1/account/trial");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const bnbProvider: CloudProvider = {
|
|
188
|
+
name: "bnb",
|
|
189
|
+
|
|
190
|
+
capabilities() {
|
|
191
|
+
return {
|
|
192
|
+
artifactModes: ["zip", "container", "image"],
|
|
193
|
+
supportsSecrets: true, // values ride the deploy request (TLS); platform injects
|
|
194
|
+
supportsSecretRefs: false, // refs point at YOUR cloud's store — rejected by contract
|
|
195
|
+
supportsLogs: true,
|
|
196
|
+
supportsOAuth: true, // buyer invoke-client plane for A2A/MCP (`clients`)
|
|
197
|
+
supportsDestroy: true,
|
|
198
|
+
supportsToken: true, // bnbk_ platform API tokens (`token` + BNBAGENT_API_TOKEN)
|
|
199
|
+
supportsTrial: true,
|
|
200
|
+
supportsList: true,
|
|
201
|
+
supportsInit: true,
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
/** Account-level inventory (GET /v1/agents). */
|
|
206
|
+
async list(ctx: Context): Promise<AgentListing[]> {
|
|
207
|
+
const res = (await authedRequest(ctx, "GET", "/v1/agents")) as { agents?: AgentInfo[] } | AgentInfo[];
|
|
208
|
+
const agents = Array.isArray(res) ? res : (res.agents ?? []);
|
|
209
|
+
return agents.map((a) => ({
|
|
210
|
+
id: a.slug,
|
|
211
|
+
state: mapState(a.state),
|
|
212
|
+
nativeState: a.state,
|
|
213
|
+
...(a.invoke_url ? { endpoint: { kind: "url" as const, value: a.invoke_url } } : {}),
|
|
214
|
+
...(a.protocol ? { protocol: a.protocol } : {}),
|
|
215
|
+
managed: true, // everything under this account went through the platform API
|
|
216
|
+
}));
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
/** Environment diagnosis: is there a working platform auth path? */
|
|
220
|
+
async doctor(ctx: Context): Promise<{ level: "info" | "warn" | "error"; code: string; message: string }[]> {
|
|
221
|
+
if (envApiToken()) return [{ level: "info", code: "auth.ok", message: "BNBAGENT_API_TOKEN is set (CI bearer)." }];
|
|
222
|
+
const session = await loadSession();
|
|
223
|
+
if (!session) {
|
|
224
|
+
return [{ level: "error", code: "auth.signed_out", message: "No session and no BNBAGENT_API_TOKEN — run `bnbagent-deploy login`." }];
|
|
225
|
+
}
|
|
226
|
+
return [{ level: "info", code: "auth.ok", message: `Session present (minted against ${session.apiUrl}).` }];
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
/** Scaffold an agent-deploy.toml pointed at the trial platform (no agent code). */
|
|
230
|
+
init(ctx: Context): Promise<void> {
|
|
231
|
+
return initCmd(ctx);
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
/** GitHub device-flow sign-in (no cloud credentials, no client secret). */
|
|
235
|
+
async login(ctx: Context): Promise<void> {
|
|
236
|
+
const base = apiUrl(ctx);
|
|
237
|
+
ctx.logger.step(`Signing in to the trial platform (${base})…`);
|
|
238
|
+
const start: DeviceStart = await publicRequest(base, "POST", "/v1/auth/device/start");
|
|
239
|
+
ctx.logger.info(
|
|
240
|
+
`Open ${start.verification_uri} in a browser and enter the code: ${start.user_code}`,
|
|
241
|
+
);
|
|
242
|
+
let interval = Math.max(Number(start.interval) || 5, 1);
|
|
243
|
+
const deadline = Date.now() + (Number(start.expires_in) || 900) * 1000;
|
|
244
|
+
while (Date.now() < deadline) {
|
|
245
|
+
await Bun.sleep(interval * 1000);
|
|
246
|
+
const res: TokenPair | DevicePending = await publicRequest(base, "POST", "/v1/auth/device/poll", {
|
|
247
|
+
json: { device_code: start.device_code },
|
|
248
|
+
});
|
|
249
|
+
if ("access_token" in res && res.access_token) {
|
|
250
|
+
await saveSession({ apiUrl: base, access_token: res.access_token, refresh_token: res.refresh_token });
|
|
251
|
+
ctx.logger.success("Signed in — session saved to ~/.bnbagent-deploy/bnb/ (mode 0600).");
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if ("status" in res && res.status === "slow_down") interval += 5;
|
|
255
|
+
// authorization_pending → keep polling
|
|
256
|
+
}
|
|
257
|
+
throw new Error("Device-flow sign-in timed out — run `bnbagent-deploy login` again.");
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
/** Show the signed-in identity (GitHub login) + trial clock. */
|
|
261
|
+
async whoami(ctx: Context): Promise<void> {
|
|
262
|
+
const session = await loadSession();
|
|
263
|
+
const viaToken = envApiToken();
|
|
264
|
+
if (!session && !viaToken) {
|
|
265
|
+
ctx.logger.info("Not signed in to the trial platform (run `bnbagent-deploy login`).");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const account: AccountInfo = await authedRequest(ctx, "GET", "/v1/account");
|
|
270
|
+
const trial: TrialStatus = await authedRequest(ctx, "GET", "/v1/account/trial");
|
|
271
|
+
const target = viaToken ? `${apiUrl(ctx)} (via BNBAGENT_API_TOKEN)` : session!.apiUrl;
|
|
272
|
+
const who = account.login ? ` as ${account.login} (GitHub)` : "";
|
|
273
|
+
const extra =
|
|
274
|
+
trial.case === "active"
|
|
275
|
+
? ` — trial active, ${Math.max(0, Math.round((trial.remaining_seconds ?? 0) / 3600))}h left`
|
|
276
|
+
: ` — trial ${trial.case}`;
|
|
277
|
+
ctx.logger.info(`Signed in to ${target}${who}${extra}.`);
|
|
278
|
+
if (typeof account.agents === "number") {
|
|
279
|
+
ctx.logger.info(`Agents on this account: ${account.agents}.`);
|
|
280
|
+
}
|
|
281
|
+
} catch (e) {
|
|
282
|
+
if (e instanceof BnbApiError && e.status === 401) {
|
|
283
|
+
ctx.logger.info("Session expired — run `bnbagent-deploy login` again.");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
throw e;
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
/** Revoke the platform session (best-effort), then forget the local one. */
|
|
291
|
+
async logout(ctx: Context): Promise<void> {
|
|
292
|
+
const session = await loadSession();
|
|
293
|
+
if (session) {
|
|
294
|
+
// Best-effort server-side revocation — the local delete is the part that
|
|
295
|
+
// must succeed; an unreachable platform must not block signing out.
|
|
296
|
+
try {
|
|
297
|
+
await authedRequest(ctx, "DELETE", "/v1/auth/session");
|
|
298
|
+
} catch {
|
|
299
|
+
ctx.logger.debug("[bnb] server-side session revoke failed (continuing with local sign-out)");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const removed = await clearSession();
|
|
303
|
+
ctx.logger.success(removed ? "Signed out (local session removed)." : "Already signed out.");
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
/** Spec + sign-in + campaign gates (business gates like trial terms stay with the caller). */
|
|
307
|
+
async validate(spec, ctx) {
|
|
308
|
+
const issues: { level: "info" | "warn" | "error"; code: string; message: string; hint?: string }[] = [];
|
|
309
|
+
// apiUrl is a hard part of the contract (nothing works without it), so it
|
|
310
|
+
// rides in specContractIssues — same as aws/azure report ALL contract issues
|
|
311
|
+
// at once, and deploy's assertSpecContract surfaces the SAME code (not a
|
|
312
|
+
// generic provider.error). Resolve it once here for the campaign check.
|
|
313
|
+
for (const message of specContractIssues(spec, ctx)) {
|
|
314
|
+
issues.push({ level: "error", code: "spec.invalid", message });
|
|
315
|
+
}
|
|
316
|
+
let base: string | undefined;
|
|
317
|
+
try {
|
|
318
|
+
base = apiUrl(ctx);
|
|
319
|
+
} catch {
|
|
320
|
+
/* already reported via specContractIssues */
|
|
321
|
+
}
|
|
322
|
+
const session = await loadSession();
|
|
323
|
+
const apiToken = envApiToken(); // CI bearer replaces the session entirely
|
|
324
|
+
if (!session && !apiToken) {
|
|
325
|
+
issues.push({
|
|
326
|
+
level: "error",
|
|
327
|
+
code: "auth.signed_out",
|
|
328
|
+
message: "Not signed in to the trial platform.",
|
|
329
|
+
hint: "Run: bnbagent-deploy login (CI: set BNBAGENT_API_TOKEN)",
|
|
330
|
+
});
|
|
331
|
+
} else if (!apiToken && session && base && session.apiUrl && !sameOrigin(session.apiUrl, base)) {
|
|
332
|
+
// Parity with authedRequest's host binding: a token minted against
|
|
333
|
+
// platform A is never sent to platform B — surface that here instead of
|
|
334
|
+
// letting a green validate die on deploy's first authenticated call.
|
|
335
|
+
issues.push({
|
|
336
|
+
level: "error",
|
|
337
|
+
code: "auth.host_mismatch",
|
|
338
|
+
message: `Signed in against ${session.apiUrl}, but this spec targets ${base} — authenticated calls would be refused.`,
|
|
339
|
+
hint: "Run: bnbagent-deploy login (against the current apiUrl), or fix [bnb] apiUrl / BNBAGENT_API_URL.",
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
if (!base) throw new Error("no endpoint");
|
|
344
|
+
const campaign: CampaignStatus = await publicRequest(base, "GET", "/v1/campaign");
|
|
345
|
+
if (campaign.campaign_active === false) {
|
|
346
|
+
issues.push({
|
|
347
|
+
level: "error",
|
|
348
|
+
code: "campaign.ended",
|
|
349
|
+
message: "The free-trial campaign is closed to NEW deploys (existing agents keep running).",
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
} catch {
|
|
353
|
+
issues.push({
|
|
354
|
+
level: "info",
|
|
355
|
+
code: "campaign.unknown",
|
|
356
|
+
message: `Could not check the campaign status at ${base} (continuing).`,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
// Doomed-deploy precheck (needs working auth): a deleted slug is
|
|
360
|
+
// retired forever — deploy would fail AFTER uploading the artifact.
|
|
361
|
+
if ((apiToken || session) && base && !issues.some((i) => i.code === "auth.host_mismatch")) {
|
|
362
|
+
const retired = await slugRetiredIssue(spec.name, ctx);
|
|
363
|
+
if (retired) {
|
|
364
|
+
issues.push({
|
|
365
|
+
level: "error",
|
|
366
|
+
code: "agent.retired",
|
|
367
|
+
message: retired,
|
|
368
|
+
hint: "Pick a new agent name (slug) in agent-deploy.toml.",
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// Parity with deploy's pre-cloud gates: dist/entrypoint existence (warn —
|
|
373
|
+
// `validate → pack → deploy` is a legitimate order) + escaping symlinks (error).
|
|
374
|
+
issues.push(...(await artifactReadinessIssues(spec, ctx.baseDir)));
|
|
375
|
+
return { ok: !issues.some((i) => i.level === "error"), issues };
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
/** zip/image → upload/push → create deployment → poll to ready. Secrets ride the deploy request (TLS). */
|
|
379
|
+
async deploy(spec: DeploymentSpec, ctx: Context): Promise<DeployResult> {
|
|
380
|
+
assertSpecContract(spec, ctx); // the platform contract holds even under --force
|
|
381
|
+
const base = apiUrl(ctx);
|
|
382
|
+
if (ctx.dryRun) {
|
|
383
|
+
const what =
|
|
384
|
+
spec.type === "zip"
|
|
385
|
+
? `zip ${spec.dist} and upload it to ${base}`
|
|
386
|
+
: spec.type === "container"
|
|
387
|
+
? `docker-build ${spec.path ?? "."} and push it to the platform's brokered registry`
|
|
388
|
+
: `push image ${spec.image} to the platform's brokered registry`;
|
|
389
|
+
ctx.logger.info(
|
|
390
|
+
`[dry-run] would ${what}, then deploy trial agent '${spec.name}'` +
|
|
391
|
+
`${spec.protocol && spec.protocol !== "HTTP" ? ` (protocol ${spec.protocol})` : ""}. No changes made.`,
|
|
392
|
+
);
|
|
393
|
+
return { id: spec.name, outputs: {} };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// One pre-ship GET answers two things: a retired slug is doomed (fail BEFORE
|
|
397
|
+
// shipping bytes), and a live agent means this deploy is an update.
|
|
398
|
+
let preexisting = false;
|
|
399
|
+
try {
|
|
400
|
+
const agent: AgentInfo = await authedRequest(ctx, "GET", `/v1/agents/${slugPath(spec.name)}`);
|
|
401
|
+
if (agent.state === "deleted" || agent.state === "reclaiming") {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`The slug '${spec.name}' was deleted and is permanently retired by the platform — deploys to it will always fail.\n` +
|
|
404
|
+
` Pick a new agent name (slug) in agent-deploy.toml.`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
preexisting = true;
|
|
408
|
+
} catch (e) {
|
|
409
|
+
if (e instanceof BnbApiError && (e.status === 410 || e.code === "agent.deleted")) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`The slug '${spec.name}' was deleted and is permanently retired by the platform.\n Pick a new agent name (slug).`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
if (!(e instanceof BnbApiError)) throw e; // the retired-slug Error above
|
|
415
|
+
// 404 = fresh slug; other API/network errors are the next call's problem.
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const shipped = spec.type === "zip" ? await shipZipArtifact(spec, ctx) : await shipImageArtifact(spec, ctx);
|
|
419
|
+
// Deterministic idempotency key (slug + content hash): re-running a deploy
|
|
420
|
+
// of IDENTICAL content dedupes to the same deployment platform-side instead
|
|
421
|
+
// of minting a new one (matches the studio semantic).
|
|
422
|
+
const contentKey = "checksum" in shipped ? shipped.checksum : shipped.digest;
|
|
423
|
+
|
|
424
|
+
ctx.logger.step(`Deploying trial agent '${spec.name}'…`);
|
|
425
|
+
const body: Record<string, unknown> = { artifact_id: shipped.artifactId };
|
|
426
|
+
// entrypoint is a ZIP concept (picks the managed runtime); ignored for images.
|
|
427
|
+
if (spec.type === "zip" && spec.entrypoint) body.entrypoint = spec.entrypoint;
|
|
428
|
+
if (spec.protocol && spec.protocol !== "HTTP") body.protocol = spec.protocol;
|
|
429
|
+
if (ctx.secrets && Object.keys(ctx.secrets).length > 0) {
|
|
430
|
+
body.secrets = ctx.secrets; // values TLS-only; never logged
|
|
431
|
+
ctx.logger.info(`Sending ${Object.keys(ctx.secrets).length} secret(s) with the deploy (values hidden).`);
|
|
432
|
+
}
|
|
433
|
+
const accepted: DeploymentAccepted = await authedRequest(
|
|
434
|
+
ctx,
|
|
435
|
+
"POST",
|
|
436
|
+
`/v1/agents/${slugPath(spec.name)}/deployments`,
|
|
437
|
+
{ json: body, headers: { "idempotency-key": `${spec.name}:${contentKey}` } },
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
// Poll to a terminal state (the platform's worker deploys asynchronously).
|
|
441
|
+
// Tolerate a few transient GET failures so one network blip doesn't abort a
|
|
442
|
+
// deploy that is actually progressing on the platform.
|
|
443
|
+
const statusPath = accepted.status_url ?? `/v1/deployments/${accepted.deployment_id}`;
|
|
444
|
+
const deadline = Date.now() + 10 * 60_000;
|
|
445
|
+
let last: DeploymentStatus | undefined;
|
|
446
|
+
let transientErrors = 0;
|
|
447
|
+
while (Date.now() < deadline) {
|
|
448
|
+
await Bun.sleep(3000);
|
|
449
|
+
try {
|
|
450
|
+
last = await authedRequest(ctx, "GET", statusPath);
|
|
451
|
+
} catch (e) {
|
|
452
|
+
if (e instanceof BnbApiError && e.status === 0 && ++transientErrors <= 5) {
|
|
453
|
+
ctx.logger.debug(`[bnb] status poll transient error (${transientErrors}/5): ${e.message}`);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
throw e;
|
|
457
|
+
}
|
|
458
|
+
transientErrors = 0;
|
|
459
|
+
const polled = last!; // just assigned in the try above
|
|
460
|
+
ctx.logger.debug(`[bnb] deployment state: ${polled.state}`);
|
|
461
|
+
if (polled.state === "ready") break;
|
|
462
|
+
if (polled.state === "failed") {
|
|
463
|
+
throw new Error(`Trial deployment failed.${formatIssues(polled.issues)}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (last?.state !== "ready") {
|
|
467
|
+
throw new Error("Timed out waiting for the trial deployment (check `bnbagent-deploy status`).");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const invokeUrl = last.invoke_url ?? accepted.invoke_url;
|
|
471
|
+
const endpoint = invokeUrl ? { kind: "url" as const, value: invokeUrl } : undefined;
|
|
472
|
+
const protocol = last.protocol ?? spec.protocol ?? "HTTP";
|
|
473
|
+
const agentId = last.agent_id ?? accepted.agent_id;
|
|
474
|
+
return {
|
|
475
|
+
id: spec.name,
|
|
476
|
+
// The platform pins slugs: a pre-existing live agent means this was an update.
|
|
477
|
+
status: preexisting ? "updated" : "created",
|
|
478
|
+
...(endpoint ? { endpoint } : {}),
|
|
479
|
+
record: {
|
|
480
|
+
provider: "bnb",
|
|
481
|
+
runtime: "trial",
|
|
482
|
+
protocol,
|
|
483
|
+
packaging: spec.type,
|
|
484
|
+
...(endpoint ? { endpoint } : {}),
|
|
485
|
+
deploymentId: accepted.deployment_id,
|
|
486
|
+
...(agentId ? { agentId } : {}),
|
|
487
|
+
...("imageUri" in shipped && shipped.imageUri ? { imageUri: shipped.imageUri } : {}),
|
|
488
|
+
logsUrl: `${base}/v1/agents/${slugPath(spec.name)}/logs`,
|
|
489
|
+
// A2A/MCP run behind the platform's buyer token plane — the non-secret
|
|
490
|
+
// auth surface is derivable (client-integration.md §5b), so hand it to
|
|
491
|
+
// callers instead of making each one re-derive from base + agentId.
|
|
492
|
+
...(agentId && (protocol === "A2A" || protocol === "MCP")
|
|
493
|
+
? { auth: { tokenUrl: `${base}/v1/oauth/token`, scope: `invoke:${agentId}` } }
|
|
494
|
+
: {}),
|
|
495
|
+
lastDeployAt: new Date().toISOString(),
|
|
496
|
+
},
|
|
497
|
+
outputs: {
|
|
498
|
+
Platform: base,
|
|
499
|
+
...(last.agent_id ? { AgentId: last.agent_id } : {}),
|
|
500
|
+
...(last.protocol ? { Protocol: last.protocol } : {}),
|
|
501
|
+
},
|
|
502
|
+
hints: [
|
|
503
|
+
"The runtime boots on the FIRST invoke; logs stay empty until then.",
|
|
504
|
+
spec.protocol === "A2A" || spec.protocol === "MCP"
|
|
505
|
+
? "Buyers need an invoke client — mint one: bnbagent-deploy clients --new"
|
|
506
|
+
: 'Test it: bnbagent-deploy invoke "hello"',
|
|
507
|
+
"Trial note: the 48h clock starts at the first successful deploy.",
|
|
508
|
+
],
|
|
509
|
+
};
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
/** The platform's recorded state for this slug. */
|
|
513
|
+
async status(id: string, ctx: Context): Promise<Status> {
|
|
514
|
+
try {
|
|
515
|
+
const agent: AgentInfo = await authedRequest(ctx, "GET", `/v1/agents/${slugPath(id)}`);
|
|
516
|
+
const state = agent.state ?? "unknown";
|
|
517
|
+
return {
|
|
518
|
+
id,
|
|
519
|
+
state: mapState(state),
|
|
520
|
+
nativeState: state,
|
|
521
|
+
...(agent.invoke_url ? { endpoint: { kind: "url", value: agent.invoke_url } } : {}),
|
|
522
|
+
...(agentIdOf(agent) ? { agentId: agentIdOf(agent) } : {}),
|
|
523
|
+
};
|
|
524
|
+
} catch (e) {
|
|
525
|
+
if (e instanceof BnbApiError && (e.status === 404 || e.status === 410)) {
|
|
526
|
+
return { id, state: "unknown", detail: "no trial agent found under this slug (not deployed?)" };
|
|
527
|
+
}
|
|
528
|
+
throw e;
|
|
529
|
+
}
|
|
530
|
+
},
|
|
531
|
+
|
|
532
|
+
/** HTTP-protocol agents only (A2A/MCP go through the buyer token plane). */
|
|
533
|
+
async invoke(id: string, payload: string, ctx: Context): Promise<string> {
|
|
534
|
+
ctx.logger.step(`Invoking ${id} (via the platform gateway)…`);
|
|
535
|
+
// Forward the payload VERBATIM (like the AWS provider + the platform contract)
|
|
536
|
+
// — don't re-parse: a raw --payload string must not crash with a SyntaxError.
|
|
537
|
+
return authedRequest(ctx, "POST", `/v1/agents/${slugPath(id)}/invoke`, {
|
|
538
|
+
bytes: new TextEncoder().encode(payload),
|
|
539
|
+
headers: { "content-type": "application/json" },
|
|
540
|
+
text: true,
|
|
541
|
+
});
|
|
542
|
+
},
|
|
543
|
+
|
|
544
|
+
/** Recent runtime logs via the platform (empty until the first invoke). */
|
|
545
|
+
async *logs(id: string, ctx: Context): AsyncIterable<string> {
|
|
546
|
+
const raw: string = await authedRequest(ctx, "GET", `/v1/agents/${slugPath(id)}/logs`, { text: true });
|
|
547
|
+
// Tolerate a JSON envelope ({logs|text|output}: "…") — the platform serves
|
|
548
|
+
// plain text today, but an envelope must not be dumped as one "log line".
|
|
549
|
+
let text = raw;
|
|
550
|
+
const trimmed = raw.trim();
|
|
551
|
+
if (trimmed.startsWith("{")) {
|
|
552
|
+
try {
|
|
553
|
+
const env = JSON.parse(trimmed) as Record<string, unknown>;
|
|
554
|
+
const inner = env.logs ?? env.text ?? env.output;
|
|
555
|
+
if (typeof inner === "string") text = inner;
|
|
556
|
+
} catch {
|
|
557
|
+
/* not JSON after all — keep the raw text */
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (!text.trim()) {
|
|
561
|
+
ctx.logger.info("No logs yet — the runtime boots on the FIRST invoke after a deploy.");
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
for (const line of text.split("\n")) if (line) yield line;
|
|
565
|
+
},
|
|
566
|
+
|
|
567
|
+
/** Delete the trial agent. ⚠ The slug is permanently retired by the platform. */
|
|
568
|
+
async destroy(id: string, ctx: Context): Promise<void> {
|
|
569
|
+
ctx.logger.warn(
|
|
570
|
+
`Trial slugs are retired forever: after this delete, '${id}' cannot be redeployed — pick a new name.`,
|
|
571
|
+
);
|
|
572
|
+
try {
|
|
573
|
+
await authedRequest(ctx, "DELETE", `/v1/agents/${slugPath(id)}`);
|
|
574
|
+
} catch (e) {
|
|
575
|
+
// Idempotent destroy: an already-deleted/never-deployed slug is success,
|
|
576
|
+
// not an error (re-running destroy must not fail the second time).
|
|
577
|
+
if (e instanceof BnbApiError && (e.status === 404 || e.status === 410)) {
|
|
578
|
+
ctx.logger.info(`No trial agent under '${id}' — nothing to delete.`);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
throw e;
|
|
582
|
+
}
|
|
583
|
+
ctx.logger.success(`Delete accepted — the platform reclaims '${id}' asynchronously.`);
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
export default bnbProvider;
|
|
588
|
+
export { bnbProvider };
|
|
589
|
+
// bnb-specific CLI extras (deliberately NOT on the CloudProvider contract —
|
|
590
|
+
// they would leak platform concepts into core), mirroring aws's cognitoCmd.
|
|
591
|
+
export { clientsCmd, tokenCmd, trialCmd } from "./extras.ts";
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bnbagent-deploy init` for bnb/trial — write an agent-deploy.toml pointed at
|
|
3
|
+
* the trial platform. Pure local file generation; scaffolds NO agent code
|
|
4
|
+
* (same rule as every provider's init).
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { writeFile } from "node:fs/promises";
|
|
8
|
+
import { basename, join } from "node:path";
|
|
9
|
+
import type { Context } from "@bnbagent/deploy-core";
|
|
10
|
+
|
|
11
|
+
const ask = (q: string): string => (prompt(q) ?? "").trim();
|
|
12
|
+
|
|
13
|
+
/** Fit a directory name into the platform slug rule (^[a-z][a-z0-9-]{1,40}$). */
|
|
14
|
+
function slugify(raw: string): string {
|
|
15
|
+
let s = raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
16
|
+
if (!/^[a-z]/.test(s)) s = `a-${s}`;
|
|
17
|
+
return s.slice(0, 41) || "my-agent";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function initCmd(ctx: Context): Promise<void> {
|
|
21
|
+
const file = join(ctx.baseDir, "agent-deploy.toml");
|
|
22
|
+
if (existsSync(file)) {
|
|
23
|
+
throw new Error(`agent-deploy.toml already exists in ${ctx.baseDir} — edit it, or remove it to re-init.`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const defaultName = slugify(basename(ctx.baseDir));
|
|
27
|
+
const name = process.stdin.isTTY ? slugify(ask(`Agent name (slug) [${defaultName}]: `) || defaultName) : defaultName;
|
|
28
|
+
const apiUrl = process.stdin.isTTY ? ask("Trial platform URL (https://…): ") : "";
|
|
29
|
+
|
|
30
|
+
const toml = `name = "${name}"
|
|
31
|
+
|
|
32
|
+
[deploy]
|
|
33
|
+
target = "bnb/trial"
|
|
34
|
+
type = "zip" # zip (managed runtime) · container/image (BYO Docker)
|
|
35
|
+
dist = "deployment_package"
|
|
36
|
+
entrypoint = "main.py" # .py → Python · .js/.mjs/.cjs → Node (zip root)
|
|
37
|
+
# protocol = "A2A" # or "MCP" — buyers then authenticate via the platform token plane
|
|
38
|
+
|
|
39
|
+
[bnb]
|
|
40
|
+
apiUrl = "${apiUrl || "https://<the-trial-platform-host>"}"
|
|
41
|
+
|
|
42
|
+
# [env] # non-sensitive runtime config (plaintext)
|
|
43
|
+
# MY_FLAG = "1"
|
|
44
|
+
|
|
45
|
+
# [secrets] # values travel TLS-encrypted with the deploy; never bundled
|
|
46
|
+
# envFile = ".env"
|
|
47
|
+
`;
|
|
48
|
+
await writeFile(file, toml, "utf8");
|
|
49
|
+
ctx.logger.success(`Wrote ${file}`);
|
|
50
|
+
if (!apiUrl) ctx.logger.warn("Set [bnb] apiUrl (or BNBAGENT_API_URL) before deploying — there is no default.");
|
|
51
|
+
ctx.logger.info("Next steps:");
|
|
52
|
+
ctx.logger.info(" 1. bnbagent-deploy login (GitHub device flow)");
|
|
53
|
+
ctx.logger.info(" 2. bnbagent-deploy pack (Python zip projects — vendors arm64 deps)");
|
|
54
|
+
ctx.logger.info(" 3. bnbagent-deploy deploy");
|
|
55
|
+
}
|