@tonbo/cli 0.0.7 → 0.1.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.
Files changed (3) hide show
  1. package/README.md +81 -29
  2. package/dist/bin/tonbo.js +937 -289
  3. package/package.json +7 -7
package/dist/bin/tonbo.js CHANGED
@@ -5,7 +5,7 @@ import { Command } from "commander";
5
5
  import { readFileSync } from "node:fs";
6
6
 
7
7
  // src/api.ts
8
- import { createHash, randomUUID } from "node:crypto";
8
+ import { randomUUID } from "node:crypto";
9
9
 
10
10
  // src/http.ts
11
11
  var HttpError = class extends Error {
@@ -33,21 +33,22 @@ async function requestJson(fetcher, url, init = {}) {
33
33
  }
34
34
 
35
35
  // src/api.ts
36
- function stableJson(value) {
37
- if (value === null || typeof value !== "object") return JSON.stringify(value);
38
- if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
39
- const object = value;
40
- return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableJson(object[key])}`).join(",")}}`;
41
- }
42
- function digest(value) {
43
- return createHash("sha256").update(stableJson(value)).digest("hex");
44
- }
45
36
  var TonboApi = class {
46
37
  constructor(fetcher, accountOrigin = "https://tonbo.dev", managementOrigin = "https://api.tonbo.dev") {
47
38
  this.fetcher = fetcher;
48
39
  this.accountOrigin = accountOrigin;
49
40
  this.managementOrigin = managementOrigin;
50
41
  }
42
+ machineRequest(oauthToken, path6 = "", method = "GET", body) {
43
+ return requestJson(this.fetcher, `${this.accountOrigin}/api/cli/machines${path6}`, {
44
+ method,
45
+ headers: {
46
+ authorization: `Bearer ${oauthToken}`,
47
+ ...body === void 0 ? {} : { "content-type": "application/json" }
48
+ },
49
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
50
+ });
51
+ }
51
52
  listProjects(oauthToken) {
52
53
  return requestJson(
53
54
  this.fetcher,
@@ -57,7 +58,16 @@ var TonboApi = class {
57
58
  }
58
59
  ).then((body) => body.projects);
59
60
  }
60
- createProject(oauthToken, slug, name) {
61
+ listOrganizations(oauthToken) {
62
+ return requestJson(
63
+ this.fetcher,
64
+ `${this.accountOrigin}/api/cli/accounts`,
65
+ {
66
+ headers: { authorization: `Bearer ${oauthToken}` }
67
+ }
68
+ ).then((body) => body.organizations);
69
+ }
70
+ createProject(oauthToken, name, organizationId) {
61
71
  return requestJson(
62
72
  this.fetcher,
63
73
  `${this.accountOrigin}/api/cli/projects`,
@@ -67,7 +77,7 @@ var TonboApi = class {
67
77
  authorization: `Bearer ${oauthToken}`,
68
78
  "content-type": "application/json"
69
79
  },
70
- body: JSON.stringify({ slug, ...name ? { name } : {} })
80
+ body: JSON.stringify(organizationId ? { name, orgId: organizationId } : { name })
71
81
  }
72
82
  ).then((body) => body.project);
73
83
  }
@@ -115,7 +125,9 @@ var TonboApi = class {
115
125
  }
116
126
  async deploy({
117
127
  bundle,
128
+ origin,
118
129
  projectId,
130
+ promote,
119
131
  spec,
120
132
  token
121
133
  }) {
@@ -124,8 +136,7 @@ var TonboApi = class {
124
136
  sha256: bundle.sha256,
125
137
  size_bytes: bundle.size_bytes
126
138
  };
127
- const projectPath = `/v1/projects/${projectId}`;
128
- const bundlesPath = `${projectPath}/source-bundles`;
139
+ const bundlesPath = `/v1/projects/${projectId}/source-bundles`;
129
140
  const prepared = await this.management("PUT", `${bundlesPath}/${bundle.sha256}`, token, {
130
141
  format: descriptor.format,
131
142
  size_bytes: descriptor.size_bytes
@@ -155,28 +166,69 @@ var TonboApi = class {
155
166
  throw error;
156
167
  }
157
168
  }
158
- const revisionsPath = `${projectPath}/revisions`;
159
- const revisionDigest = digest(spec);
160
- const revisions = await this.managementList(revisionsPath, token);
161
- let revision = revisions.find((candidate) => candidate.spec_sha256 === revisionDigest);
162
- if (!revision) {
163
- revision = (await this.management("POST", revisionsPath, token, { spec })).data;
169
+ let deployment = await this.createDeployment(projectId, { spec, origin }, token);
170
+ const previous = await this.getProduction(projectId, token);
171
+ const previousDeployment = previous ? await this.getDeployment(projectId, previous.deployment_id, token) : null;
172
+ const unchanged = previousDeployment?.spec_sha256 === deployment.spec_sha256;
173
+ let production = previous;
174
+ if (promote) {
175
+ production = await this.putProduction(
176
+ projectId,
177
+ {
178
+ deployment_id: deployment.id,
179
+ desired_state: "running",
180
+ expected_generation: previous ? previous.generation : null
181
+ },
182
+ token
183
+ );
184
+ deployment = await this.getDeployment(projectId, deployment.id, token);
164
185
  }
165
- const deploymentPath = `${projectPath}/deployment`;
166
- const current = await this.management(
186
+ return { deployment, production, unchanged };
187
+ }
188
+ createDeployment(projectId, body, token) {
189
+ return this.management(
190
+ "POST",
191
+ `/v1/projects/${projectId}/deployments`,
192
+ token,
193
+ body
194
+ ).then((response) => response.data);
195
+ }
196
+ listDeployments(projectId, token) {
197
+ return this.managementList(`/v1/projects/${projectId}/deployments`, token);
198
+ }
199
+ getDeployment(projectId, deploymentId, token) {
200
+ return this.management(
167
201
  "GET",
168
- deploymentPath,
202
+ `/v1/projects/${projectId}/deployments/${deploymentId}`,
169
203
  token
170
- ).catch((error) => {
171
- if (error.status === 404) return null;
172
- throw error;
173
- });
174
- const deployment = (await this.management("PUT", deploymentPath, token, {
175
- desired_revision_id: revision.id,
176
- desired_state: "running",
177
- expected_generation: current ? Number(current.data.generation) : null
178
- })).data;
179
- return { deployment, revision };
204
+ ).then((response) => response.data);
205
+ }
206
+ getProduction(projectId, token) {
207
+ return this.management(
208
+ "GET",
209
+ `/v1/projects/${projectId}/production`,
210
+ token
211
+ ).then(
212
+ (response) => response.data,
213
+ (error) => {
214
+ if (error.status === 404) return null;
215
+ throw error;
216
+ }
217
+ );
218
+ }
219
+ putProduction(projectId, body, token) {
220
+ return this.management(
221
+ "PUT",
222
+ `/v1/projects/${projectId}/production`,
223
+ token,
224
+ body
225
+ ).then((response) => response.data);
226
+ }
227
+ listRollouts(projectId, token) {
228
+ return this.managementList(
229
+ `/v1/projects/${projectId}/production/rollouts`,
230
+ token
231
+ );
180
232
  }
181
233
  async run({
182
234
  projectId,
@@ -186,14 +238,18 @@ var TonboApi = class {
186
238
  turnId = randomUUID()
187
239
  }) {
188
240
  const projectPath = `/v1/projects/${projectId}`;
189
- const deployment = (await this.management("GET", `${projectPath}/deployment`, token)).data;
190
- if (deployment.observed_state !== "running")
191
- throw new Error(`Project Agent is ${deployment.observed_state}; wait for it to be running.`);
241
+ const production = await this.getProduction(projectId, token);
242
+ if (!production)
243
+ throw new Error("Project has no Production Deployment; run `tonbo deploy` first.");
244
+ if (production.observed_state !== "running")
245
+ throw new Error(
246
+ `Production is ${production.observed_state}${production.last_error ? ` (${production.last_error})` : ""}; wait for it to be running.`
247
+ );
192
248
  const session = sessionId ? { id: sessionId } : (await this.management(
193
249
  "POST",
194
250
  `${projectPath}/sessions`,
195
251
  token,
196
- { revision_id: deployment.desired_revision_id }
252
+ { deployment_id: production.deployment_id }
197
253
  )).data;
198
254
  const turn = (await this.management(
199
255
  "POST",
@@ -202,7 +258,7 @@ var TonboApi = class {
202
258
  { prompt },
203
259
  turnId
204
260
  )).data;
205
- return { deployment, session, turn };
261
+ return { production, session, turn };
206
262
  }
207
263
  turnEvents({
208
264
  after = 0,
@@ -239,8 +295,8 @@ var TonboApi = class {
239
295
  token
240
296
  );
241
297
  }
242
- management(method, path7, token, body, idempotencyKey) {
243
- return requestJson(this.fetcher, `${this.managementOrigin}${path7}`, {
298
+ management(method, path6, token, body, idempotencyKey) {
299
+ return requestJson(this.fetcher, `${this.managementOrigin}${path6}`, {
244
300
  method,
245
301
  headers: {
246
302
  authorization: `Bearer ${token}`,
@@ -252,14 +308,14 @@ var TonboApi = class {
252
308
  ...body === void 0 ? {} : { body: JSON.stringify(body) }
253
309
  });
254
310
  }
255
- async managementList(path7, token) {
311
+ async managementList(path6, token) {
256
312
  const values = [];
257
313
  let cursor = null;
258
314
  do {
259
- const separator = path7.includes("?") ? "&" : "?";
315
+ const separator = path6.includes("?") ? "&" : "?";
260
316
  const page = await this.management(
261
317
  "GET",
262
- `${path7}${separator}limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`,
318
+ `${path6}${separator}limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`,
263
319
  token
264
320
  );
265
321
  values.push(...page.data);
@@ -289,10 +345,10 @@ var supportedExecutionTargets = [
289
345
  { driver: "command", harness: "pi" }
290
346
  ];
291
347
  var AgentSourceInspectionError = class extends Error {
292
- constructor(code, path7, message, options) {
348
+ constructor(code, path6, message, options) {
293
349
  super(message, options);
294
350
  this.code = code;
295
- this.path = path7;
351
+ this.path = path6;
296
352
  this.name = "AgentSourceInspectionError";
297
353
  }
298
354
  };
@@ -395,7 +451,7 @@ function inspectLocalAgentSource(root) {
395
451
  }
396
452
 
397
453
  // src/auth.ts
398
- import { createHash as createHash2, randomBytes } from "node:crypto";
454
+ import { createHash, randomBytes } from "node:crypto";
399
455
  import { createServer } from "node:http";
400
456
  import { execFile } from "node:child_process";
401
457
  import { promisify } from "node:util";
@@ -585,7 +641,7 @@ var AuthClient = class {
585
641
  if (config.redirect_uri !== LOOPBACK_REDIRECT)
586
642
  throw new Error(`Unsupported OAuth redirect URI: ${config.redirect_uri}`);
587
643
  const verifier = base64url(randomBytes(48));
588
- const challenge = createHash2("sha256").update(verifier).digest("base64url");
644
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
589
645
  const state = base64url(randomBytes(24));
590
646
  const authorization = new URL(config.authorization_endpoint);
591
647
  authorization.search = new URLSearchParams({
@@ -751,12 +807,6 @@ async function runBuildCommand(root, command) {
751
807
  });
752
808
  }
753
809
 
754
- // src/declaration.ts
755
- import { randomUUID as randomUUID2 } from "node:crypto";
756
- import { lstat as lstat2, open, readFile as readFile2, rename, rm } from "node:fs/promises";
757
- import path2 from "node:path";
758
- import { parse, stringify } from "smol-toml";
759
-
760
810
  // src/contracts.ts
761
811
  import { Ajv2020 } from "ajv/dist/2020.js";
762
812
 
@@ -883,20 +933,17 @@ var kubernetesProfilesSchema = {
883
933
  };
884
934
  var declarationSchema = {
885
935
  "$schema": "https://json-schema.org/draft/2020-12/schema",
886
- "$id": "https://contracts.tonbo.dev/agents/tonbo-declaration-v1.schema.json",
887
- "title": "Tonbo Project Agent declaration v1",
936
+ "$id": "https://contracts.tonbo.dev/agents/tonbo-declaration-v2.schema.json",
937
+ "title": "Tonbo Agent declaration v2",
888
938
  "type": "object",
889
939
  "additionalProperties": false,
890
940
  "required": [
891
941
  "version",
892
- "agent"
942
+ "harness"
893
943
  ],
894
944
  "properties": {
895
945
  "version": {
896
- "const": 1
897
- },
898
- "agent": {
899
- "$ref": "https://contracts.tonbo.dev/agents/pi-agent-v1.schema.json"
946
+ "const": 2
900
947
  },
901
948
  "inference": {
902
949
  "type": "object",
@@ -936,13 +983,19 @@ var declarationSchema = {
936
983
  },
937
984
  "service": {
938
985
  "$ref": "https://contracts.tonbo.dev/agents/project-service-v1.schema.json"
986
+ },
987
+ "agent": {
988
+ "$ref": "https://contracts.tonbo.dev/agents/project-name-v1.json#/$defs/publicHostname"
989
+ },
990
+ "harness": {
991
+ "$ref": "https://contracts.tonbo.dev/agents/pi-agent-v1.schema.json"
939
992
  }
940
993
  }
941
994
  };
942
- var revisionSchema = {
995
+ var deploymentSchema = {
943
996
  "$schema": "https://json-schema.org/draft/2020-12/schema",
944
- "$id": "https://contracts.tonbo.dev/agents/managed-revision-v1.schema.json",
945
- "title": "Managed Project revision v1",
997
+ "$id": "https://contracts.tonbo.dev/agents/managed-deployment-v1.schema.json",
998
+ "title": "Managed Project Deployment v1",
946
999
  "type": "object",
947
1000
  "additionalProperties": false,
948
1001
  "required": [
@@ -1020,15 +1073,66 @@ var piSessionContract = {
1020
1073
  "session-preflight"
1021
1074
  ]
1022
1075
  };
1076
+ var projectNameContract = {
1077
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
1078
+ "$id": "https://contracts.tonbo.dev/agents/project-name-v1.json",
1079
+ "x-tonbo-version": 1,
1080
+ "x-tonbo-public-hostname-apex": "tonbo.sh",
1081
+ "x-tonbo-public-hostname-template": "<project>-<organization>.tonbo.sh",
1082
+ "$defs": {
1083
+ "name": {
1084
+ "type": "string",
1085
+ "minLength": 3,
1086
+ "maxLength": 40,
1087
+ "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$",
1088
+ "not": {
1089
+ "enum": [
1090
+ "api",
1091
+ "artifacts",
1092
+ "auth",
1093
+ "inference",
1094
+ "network-health",
1095
+ "sandbox-control",
1096
+ "status",
1097
+ "streams",
1098
+ "www"
1099
+ ]
1100
+ }
1101
+ },
1102
+ "publicHostname": {
1103
+ "type": "string",
1104
+ "minLength": 16,
1105
+ "maxLength": 72,
1106
+ "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)+\\.tonbo\\.sh$",
1107
+ "not": {
1108
+ "enum": [
1109
+ "api.tonbo.sh",
1110
+ "artifacts.tonbo.sh",
1111
+ "auth.tonbo.sh",
1112
+ "inference.tonbo.sh",
1113
+ "network-health.tonbo.sh",
1114
+ "sandbox-control.tonbo.sh",
1115
+ "status.tonbo.sh",
1116
+ "streams.tonbo.sh",
1117
+ "www.tonbo.sh"
1118
+ ]
1119
+ }
1120
+ }
1121
+ }
1122
+ };
1023
1123
 
1024
1124
  // src/contracts.ts
1025
1125
  var ajv = new Ajv2020({ allErrors: true, useDefaults: true });
1026
1126
  ajv.addKeyword({ keyword: "x-tonbo-profiles" });
1127
+ ajv.addKeyword({ keyword: "x-tonbo-version" });
1128
+ ajv.addKeyword({ keyword: "x-tonbo-public-hostname-apex" });
1129
+ ajv.addKeyword({ keyword: "x-tonbo-public-hostname-template" });
1027
1130
  ajv.addSchema(kubernetesProfilesSchema);
1028
1131
  ajv.addSchema(piAgentSchema);
1132
+ ajv.addSchema(projectNameContract);
1029
1133
  ajv.addSchema(projectServiceSchema);
1030
1134
  var validateDeclaration = ajv.compile(declarationSchema);
1031
- var validateRevision = ajv.compile(revisionSchema);
1135
+ var validateDeploymentSpec = ajv.compile(deploymentSchema);
1032
1136
  function validationMessage(label, errors) {
1033
1137
  const detail = errors?.map((error) => `${error.instancePath || "/"} ${error.message}`).join("; ");
1034
1138
  return `${label} is invalid${detail ? `: ${detail}` : "."}`;
@@ -1040,27 +1144,39 @@ function parseDeclaration(value) {
1040
1144
  }
1041
1145
  return candidate;
1042
1146
  }
1043
- function assertManagedRevision(value) {
1044
- if (!validateRevision(value)) {
1045
- throw new Error(validationMessage("Managed revision", validateRevision.errors));
1147
+ function assertManagedDeploymentSpec(value) {
1148
+ if (!validateDeploymentSpec(value)) {
1149
+ throw new Error(validationMessage("Managed Deployment spec", validateDeploymentSpec.errors));
1046
1150
  }
1047
1151
  }
1048
1152
 
1049
1153
  // src/declaration.ts
1154
+ import { randomUUID as randomUUID2 } from "node:crypto";
1155
+ import { lstat as lstat2, open, readFile as readFile2, rename, rm } from "node:fs/promises";
1156
+ import path2 from "node:path";
1157
+ import { parse, stringify } from "smol-toml";
1050
1158
  var DECLARATION_FILENAME = ".tonbo";
1051
1159
  var DEFAULT_INFERENCE_MODEL = "claude-sonnet-4-5";
1052
- function createDeclaration(model = DEFAULT_INFERENCE_MODEL, driver = { kind: "native" }, buildCommand) {
1160
+ function createDeclaration(model = DEFAULT_INFERENCE_MODEL, driver = { kind: "native" }, buildCommand, projectHostname) {
1053
1161
  return parseDeclaration({
1054
- version: 1,
1055
- agent: { runtime: "pi", driver },
1162
+ version: 2,
1163
+ ...projectHostname ? { agent: projectHostname } : {},
1164
+ harness: { runtime: "pi", driver },
1056
1165
  inference: { model: model.trim() },
1057
1166
  ...buildCommand ? { build: { command: buildCommand } } : {}
1058
1167
  });
1059
1168
  }
1060
1169
  function renderDeclaration(declaration) {
1061
- return `# Tonbo Project Agent configuration.
1170
+ return `# Tonbo Agent configuration.
1171
+ # Edit this file directly or run \`tonbo init\` to reconfigure.
1062
1172
  ${stringify(declaration)}`;
1063
1173
  }
1174
+ async function bindDeclarationProject(root, projectHostname) {
1175
+ const declaration = await loadDeclaration(root);
1176
+ const bound = parseDeclaration({ ...declaration, agent: projectHostname });
1177
+ await saveDeclaration(root, bound, true);
1178
+ return bound;
1179
+ }
1064
1180
  async function declarationExists(root) {
1065
1181
  const filename = path2.join(root, DECLARATION_FILENAME);
1066
1182
  try {
@@ -1120,10 +1236,10 @@ async function loadDeclaration(declarationRoot) {
1120
1236
  }
1121
1237
  return parseDeclaration(parsed);
1122
1238
  }
1123
- function buildRevision(declaration, source) {
1239
+ function buildDeploymentSpec(declaration, source) {
1124
1240
  const spec = {
1125
1241
  version: 1,
1126
- agent: declaration.agent,
1242
+ agent: declaration.harness,
1127
1243
  source: {
1128
1244
  format: source.format,
1129
1245
  sha256: source.sha256,
@@ -1132,12 +1248,12 @@ function buildRevision(declaration, source) {
1132
1248
  inference: declaration.inference,
1133
1249
  ...declaration.service ? { service: declaration.service } : {}
1134
1250
  };
1135
- assertManagedRevision(spec);
1251
+ assertManagedDeploymentSpec(spec);
1136
1252
  return spec;
1137
1253
  }
1138
1254
 
1139
1255
  // src/source.ts
1140
- import { createHash as createHash3 } from "node:crypto";
1256
+ import { createHash as createHash2 } from "node:crypto";
1141
1257
  import { lstat as lstat3, readFile as readFile3, readdir } from "node:fs/promises";
1142
1258
  import path3 from "node:path";
1143
1259
  import ignore from "ignore";
@@ -1339,13 +1455,13 @@ async function buildSourceBundle(root) {
1339
1455
  bytes,
1340
1456
  format: sourceBundleContract.format,
1341
1457
  root: resolvedRoot,
1342
- sha256: createHash3("sha256").update(bytes).digest("hex"),
1458
+ sha256: createHash2("sha256").update(bytes).digest("hex"),
1343
1459
  size_bytes: bytes.length
1344
1460
  };
1345
1461
  }
1346
1462
 
1347
1463
  // src/ssh-key.ts
1348
- import { createHash as createHash4 } from "node:crypto";
1464
+ import { createHash as createHash3 } from "node:crypto";
1349
1465
  import { readFile as readFile4 } from "node:fs/promises";
1350
1466
  import { homedir } from "node:os";
1351
1467
  import { basename, join } from "node:path";
@@ -1360,13 +1476,13 @@ function parseOpenSshPublicKey(value, label) {
1360
1476
  throw new Error("SSH public key is not canonical base64.");
1361
1477
  return {
1362
1478
  algorithm: fields[0],
1363
- fingerprint: `SHA256:${createHash4("sha256").update(blob).digest("base64").replace(/=$/, "")}`,
1479
+ fingerprint: `SHA256:${createHash3("sha256").update(blob).digest("base64").replace(/=$/, "")}`,
1364
1480
  keyBase64: fields[1],
1365
1481
  label
1366
1482
  };
1367
1483
  }
1368
- async function readSshPublicKey(path7) {
1369
- return parseOpenSshPublicKey(await readFile4(path7, "utf8"), basename(path7, ".pub"));
1484
+ async function readSshPublicKey(path6) {
1485
+ return parseOpenSshPublicKey(await readFile4(path6, "utf8"), basename(path6, ".pub"));
1370
1486
  }
1371
1487
  async function readDefaultSshPublicKeys(sshDirectory = join(homedir(), ".ssh")) {
1372
1488
  const keys = [];
@@ -1382,140 +1498,362 @@ async function readDefaultSshPublicKeys(sshDirectory = join(homedir(), ".ssh"))
1382
1498
  return keys;
1383
1499
  }
1384
1500
 
1385
- // src/ssh.ts
1386
- import { spawn as spawn2 } from "node:child_process";
1387
- var PROJECT_SSH_HOST = "tonbo.sh";
1388
- function projectSshDestination(projectSlug) {
1389
- return `${projectSlug}@${PROJECT_SSH_HOST}`;
1501
+ // src/project-name.ts
1502
+ var PROJECT_NAME_RULE = projectNameContract.$defs.name;
1503
+ var PROJECT_NAME_PATTERN = new RegExp(PROJECT_NAME_RULE.pattern);
1504
+ var RESERVED_PROJECT_NAMES = new Set(PROJECT_NAME_RULE.not.enum);
1505
+ function validateProjectName(name) {
1506
+ const valid = name.length >= PROJECT_NAME_RULE.minLength && name.length <= PROJECT_NAME_RULE.maxLength && PROJECT_NAME_PATTERN.test(name) && !RESERVED_PROJECT_NAMES.has(name);
1507
+ if (!valid) {
1508
+ throw new Error(
1509
+ `Project name must be ${PROJECT_NAME_RULE.minLength}-${PROJECT_NAME_RULE.maxLength} lowercase letters, digits, or single hyphens, start with a letter, and not be reserved.`
1510
+ );
1511
+ }
1390
1512
  }
1391
- async function launchProjectSsh(projectSlug) {
1392
- return new Promise((resolve, reject) => {
1393
- const child = spawn2("ssh", [projectSshDestination(projectSlug)], {
1394
- stdio: "inherit"
1395
- });
1396
- child.once("error", reject);
1397
- child.once("exit", (code, signal) => {
1398
- if (signal) reject(new Error(`ssh was terminated by ${signal}`));
1399
- else resolve(code ?? 1);
1400
- });
1401
- });
1513
+ function projectNameSuggestion(value) {
1514
+ let name = value.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
1515
+ if (!/^[a-z]/.test(name)) name = `agent-${name}`;
1516
+ name = name.slice(0, PROJECT_NAME_RULE.maxLength).replace(/-+$/g, "");
1517
+ if (name.length < PROJECT_NAME_RULE.minLength) {
1518
+ name = `${name || "agent"}-agent`.slice(0, PROJECT_NAME_RULE.maxLength);
1519
+ }
1520
+ if (RESERVED_PROJECT_NAMES.has(name)) name = `${name}-agent`;
1521
+ return name;
1402
1522
  }
1403
1523
 
1404
1524
  // src/commands.ts
1405
- async function resolveProject(deps, selector) {
1406
- const oauthToken = await deps.auth.accessToken();
1407
- if (selector) {
1408
- const normalized = selector.endsWith(".tonbo.sh") ? selector.slice(0, -".tonbo.sh".length) : selector;
1409
- return {
1410
- oauthToken,
1411
- project: selectProject(await deps.api.listProjects(oauthToken), normalized)
1412
- };
1413
- }
1525
+ async function resolveProject(deps) {
1414
1526
  const root = await findDeclarationRoot(deps.cwd());
1415
- const binding = await deps.config.getBinding(root);
1416
- if (!binding) throw new Error("No Project selected. Run `tonbo project use <project>` first.");
1527
+ const declaration = await loadDeclaration(root);
1528
+ if (!declaration.agent) {
1529
+ throw new Error("No Project is configured in .tonbo. Run `tonbo project use <project>` first.");
1530
+ }
1531
+ const oauthToken = await deps.auth.accessToken();
1417
1532
  return {
1533
+ declaration,
1418
1534
  oauthToken,
1419
- project: {
1420
- id: binding.projectId,
1421
- slug: binding.projectSlug,
1422
- status: "active"
1423
- }
1535
+ project: selectProject(await deps.api.listProjects(oauthToken), declaration.agent),
1536
+ root
1424
1537
  };
1425
1538
  }
1426
1539
  function selectProject(projects, selector) {
1540
+ const normalized = selector.toLowerCase();
1427
1541
  const matches = projects.filter(
1428
- (project) => project.id === selector || project.slug === selector
1542
+ (project) => project.id === selector || project.name === normalized || project.publicHostname === normalized
1429
1543
  );
1430
1544
  if (matches.length === 0) throw new Error(`Project ${selector} was not found in your account.`);
1431
- if (matches.length > 1) throw new Error(`Project slug ${selector} is ambiguous; use its ID.`);
1545
+ if (matches.length > 1)
1546
+ throw new Error(`Project name ${selector} is ambiguous; use its full hostname or ID.`);
1432
1547
  if (matches[0].status !== "active") throw new Error(`Project ${selector} is not active.`);
1433
1548
  return matches[0];
1434
1549
  }
1435
1550
  async function initCommand(deps, options) {
1436
1551
  const root = deps.cwd();
1437
1552
  const exists = await declarationExists(root);
1438
- let overwrite = options.force === true;
1439
- if (exists && !overwrite) {
1440
- if (!deps.interactive())
1441
- throw new Error(`${DECLARATION_FILENAME} already exists. Pass --force to replace it.`);
1442
- const answer = (await deps.prompt(`Replace existing ${DECLARATION_FILENAME}? [y/N] `)).trim().toLowerCase();
1443
- if (answer !== "y" && answer !== "yes") {
1444
- deps.output({ message: `Kept existing ${DECLARATION_FILENAME}.` });
1445
- return;
1446
- }
1447
- overwrite = true;
1553
+ let existingDeclaration;
1554
+ if (exists) {
1555
+ existingDeclaration = await loadDeclaration(root).catch(() => void 0);
1448
1556
  }
1557
+ const interactive = deps.interactive();
1558
+ const overwrite = options.force === true || exists && interactive;
1559
+ if (exists && !overwrite)
1560
+ throw new Error(`${DECLARATION_FILENAME} already exists. Pass --force to replace it.`);
1449
1561
  const inspection = await deps.inspectSource(root);
1450
- let harness = options.harness;
1562
+ let harness = options.harness ?? (existingDeclaration ? "pi" : void 0);
1451
1563
  if (harness !== void 0 && harness !== "pi") {
1452
1564
  throw new Error("--harness must name a supported Harness: pi.");
1453
1565
  }
1454
- let driver = options.driver;
1566
+ let driver = options.driver ?? existingDeclaration?.harness.driver.kind;
1455
1567
  if (driver !== void 0 && driver !== "native" && driver !== "command") {
1456
1568
  throw new Error("--driver must be native or command.");
1457
1569
  }
1458
- if (!harness && !driver && deps.interactive() && inspection.harness.state === "selection_required") {
1459
- const target = await deps.select("How should Tonbo run this project?", piTargetChoices(false));
1460
- harness = "pi";
1461
- driver = target;
1462
- }
1463
1570
  if (!harness) {
1464
1571
  if (inspection.harness.state === "identified") harness = inspection.harness.id;
1465
- else if (!deps.interactive()) {
1572
+ else if (!interactive) {
1466
1573
  throw new Error("No Harness-specific configuration found. Pass --harness pi.");
1467
1574
  } else {
1468
- harness = await deps.select("Which supported Harness should Tonbo use?", [
1469
- {
1470
- description: "Run this project with the pi command.",
1471
- name: "PI (`pi`)",
1472
- value: "pi"
1473
- }
1474
- ]);
1575
+ deps.output({
1576
+ message: "No Harness-specific configuration was found in this project."
1577
+ });
1578
+ harness = await selectHarness(deps);
1475
1579
  }
1476
1580
  }
1477
- if (harness !== "pi") throw new Error(`Harness ${harness} is not supported.`);
1478
1581
  if (!driver) {
1479
- if (!deps.interactive()) {
1582
+ if (!interactive) {
1480
1583
  throw new Error("PI execution mode is required. Pass --driver native or --driver command.");
1481
1584
  }
1482
1585
  if (inspection.pi.settingsFound) {
1483
1586
  const packageSummary = inspection.pi.packageCount === 0 ? "Found PI configuration at .pi/settings.json." : `Found PI configuration at .pi/settings.json with ${inspection.pi.packageCount} package${inspection.pi.packageCount === 1 ? "" : "s"}.`;
1484
1587
  deps.output({ message: packageSummary });
1485
1588
  }
1486
- driver = await deps.select(
1487
- "How should Tonbo start PI?",
1488
- piTargetChoices(inspection.pi.settingsFound)
1489
- );
1589
+ driver = "native";
1490
1590
  }
1491
1591
  if (driver === "native" && (options.agentEntry || options.buildCommand)) {
1492
1592
  throw new Error("--agent-entry and --build-command require --driver command.");
1493
1593
  }
1494
- let entry = options.agentEntry?.trim();
1495
- if (driver === "command" && !entry && deps.interactive()) {
1594
+ const existingEntry = existingDeclaration?.harness.driver.kind === "command" && existingDeclaration.harness.driver.command[0] === "node" ? existingDeclaration.harness.driver.command[1] : void 0;
1595
+ let entry = options.agentEntry?.trim() || existingEntry;
1596
+ if (driver === "command" && !entry && interactive) {
1496
1597
  entry = (await deps.prompt("PI SDK entry file [dist/agent.mjs]: ")).trim();
1497
1598
  }
1498
1599
  entry ||= "dist/agent.mjs";
1499
- const model = options.model?.trim() || DEFAULT_INFERENCE_MODEL;
1500
- const buildCommand = driver === "command" ? options.buildCommand ?? ["npm", "run", "build"] : void 0;
1501
- const declaration = createDeclaration(
1600
+ let model = options.model?.trim() || existingDeclaration?.inference.model || DEFAULT_INFERENCE_MODEL;
1601
+ let buildCommand = driver === "command" ? options.buildCommand ?? existingDeclaration?.build?.command ?? ["npm", "run", "build"] : void 0;
1602
+ let projectPlan = existingDeclaration?.agent ? {
1603
+ kind: "existing",
1604
+ publicHostname: existingDeclaration.agent
1605
+ } : { kind: "later" };
1606
+ if (interactive && !existingDeclaration?.agent) {
1607
+ projectPlan = await selectInitProject(deps, root);
1608
+ }
1609
+ if (interactive) {
1610
+ while (true) {
1611
+ deps.output({
1612
+ message: renderInitSummary(projectPlan, harness, driver, model, entry, buildCommand)
1613
+ });
1614
+ const saveLabel = initSaveLabel(exists, projectPlan);
1615
+ const action = await deps.select(`${saveLabel} with this configuration?`, [
1616
+ {
1617
+ name: saveLabel,
1618
+ value: "save"
1619
+ },
1620
+ {
1621
+ description: "Change the project, Harness, execution mode, model, or SDK entrypoint.",
1622
+ name: "Edit configuration",
1623
+ value: "edit"
1624
+ },
1625
+ { name: "Cancel", value: "cancel" }
1626
+ ]);
1627
+ if (action === "cancel") {
1628
+ deps.output({
1629
+ message: exists ? `Cancelled. Kept existing ${DECLARATION_FILENAME}.` : `Cancelled. Did not create ${DECLARATION_FILENAME}.`
1630
+ });
1631
+ return;
1632
+ }
1633
+ if (action === "save") break;
1634
+ const setting = await deps.select("What would you like to change?", [
1635
+ {
1636
+ description: initProjectSummary(projectPlan),
1637
+ name: "Project",
1638
+ value: "project"
1639
+ },
1640
+ { description: harnessName(harness), name: "Harness", value: "harness" },
1641
+ {
1642
+ description: driver === "native" ? "PI CLI (`pi`)" : "PI SDK app",
1643
+ name: "Execution mode",
1644
+ value: "driver"
1645
+ },
1646
+ { description: model, name: "Inference model", value: "model" },
1647
+ ...driver === "command" ? [{ description: entry, name: "PI SDK entry file", value: "entry" }] : [],
1648
+ { name: "Back to review", value: "back" }
1649
+ ]);
1650
+ if (setting === "project") projectPlan = await selectInitProject(deps, root);
1651
+ if (setting === "harness") harness = await selectHarness(deps);
1652
+ if (setting === "driver") {
1653
+ driver = await deps.select(
1654
+ "How should Tonbo start PI?",
1655
+ piTargetChoices(inspection.pi.settingsFound)
1656
+ );
1657
+ if (driver === "command") {
1658
+ const answer = (await deps.prompt(`PI SDK entry file [${entry}]: `)).trim();
1659
+ if (answer) entry = answer;
1660
+ buildCommand = options.buildCommand ?? ["npm", "run", "build"];
1661
+ } else {
1662
+ buildCommand = void 0;
1663
+ }
1664
+ }
1665
+ if (setting === "model") {
1666
+ const answer = (await deps.prompt(`Inference model [${model}]: `)).trim();
1667
+ if (answer) model = answer;
1668
+ }
1669
+ if (setting === "entry") {
1670
+ const answer = (await deps.prompt(`PI SDK entry file [${entry}]: `)).trim();
1671
+ if (answer) entry = answer;
1672
+ }
1673
+ }
1674
+ }
1675
+ let projectHostname = projectPlan.kind === "existing" ? projectPlan.publicHostname : void 0;
1676
+ let createdProject;
1677
+ if (projectPlan.kind === "create") {
1678
+ const oauthToken = await deps.auth.accessToken();
1679
+ createdProject = await deps.api.createProject(
1680
+ oauthToken,
1681
+ projectPlan.name,
1682
+ projectPlan.organizationId
1683
+ );
1684
+ projectHostname = createdProject.publicHostname;
1685
+ }
1686
+ const configuredDeclaration = createDeclaration(
1502
1687
  model,
1503
1688
  driver === "native" ? { kind: "native" } : { kind: "command", protocol: "pi-rpc-v1", command: ["node", entry] },
1504
- buildCommand
1689
+ buildCommand,
1690
+ projectHostname
1505
1691
  );
1506
- await saveDeclaration(root, declaration, overwrite);
1692
+ const declaration = parseDeclaration({
1693
+ ...configuredDeclaration,
1694
+ harness: {
1695
+ ...configuredDeclaration.harness,
1696
+ ...existingDeclaration?.harness.secrets ? { secrets: existingDeclaration.harness.secrets } : {}
1697
+ },
1698
+ ...existingDeclaration?.service ? { service: existingDeclaration.service } : {}
1699
+ });
1700
+ try {
1701
+ await saveDeclaration(root, declaration, overwrite);
1702
+ } catch (error) {
1703
+ if (createdProject) {
1704
+ throw new Error(
1705
+ `Created project ${createdProject.name}, but could not write ${DECLARATION_FILENAME}. Fix the file and run \`tonbo project use ${createdProject.publicHostname}\`.`,
1706
+ { cause: error }
1707
+ );
1708
+ }
1709
+ throw error;
1710
+ }
1507
1711
  const driverSummary = driver === "command" ? `PI SDK app
1508
1712
  Build: ${buildCommand?.join(" ")}
1509
1713
  Entrypoint: node ${entry}` : inspection.pi.packageCount > 0 ? `PI CLI with ${inspection.pi.packageCount} package${inspection.pi.packageCount === 1 ? "" : "s"}` : "PI CLI";
1714
+ const projectSummary = createdProject ? `Project: ${createdProject.name} (${createdProject.publicHostname})
1715
+ Organization: ${createdProject.organizationName}
1716
+ Application: https://${createdProject.publicHostname}
1717
+ SSH after deploy: ssh ${createdProject.sshDestination}` : projectPlan.kind === "existing" ? `Project: ${projectPlan.name ? `${projectPlan.name} (${projectPlan.publicHostname})` : projectPlan.publicHostname}${projectPlan.organizationName ? `
1718
+ Organization: ${projectPlan.organizationName}` : ""}${projectPlan.sshDestination ? `
1719
+ SSH after deploy: ssh ${projectPlan.sshDestination}` : ""}` : "Next: tonbo project create <name>";
1510
1720
  deps.output({
1511
- message: `${exists ? "Updated" : "Created"} ${DECLARATION_FILENAME}.
1721
+ message: `${createdProject ? `Created project ${createdProject.name} and` : exists ? "Updated" : "Created"} ${DECLARATION_FILENAME}.
1722
+ ${projectSummary}
1512
1723
  Harness: PI
1513
- Mode: ${driverSummary}
1514
- Next: tonbo project create <slug>`,
1724
+ Mode: ${driverSummary}${projectPlan.kind === "later" ? "" : "\nNext: tonbo deploy"}`,
1515
1725
  declaration,
1516
1726
  path: path4.join(root, DECLARATION_FILENAME)
1517
1727
  });
1518
1728
  }
1729
+ function initSaveLabel(exists, project) {
1730
+ if (project.kind === "create") {
1731
+ return exists ? `Create project and replace ${DECLARATION_FILENAME}` : `Create project and ${DECLARATION_FILENAME}`;
1732
+ }
1733
+ return exists ? `Replace ${DECLARATION_FILENAME}` : `Create ${DECLARATION_FILENAME}`;
1734
+ }
1735
+ async function selectInitProject(deps, root) {
1736
+ for (; ; ) {
1737
+ const action = await deps.select("How should this Agent connect to Tonbo?", [
1738
+ {
1739
+ description: "Create it only after you confirm the complete configuration.",
1740
+ name: "Create a new project",
1741
+ value: "create"
1742
+ },
1743
+ {
1744
+ description: "Bind this source tree to one project you can access.",
1745
+ name: "Use an existing project",
1746
+ value: "existing"
1747
+ },
1748
+ {
1749
+ description: "Write an unbound declaration and configure its project later.",
1750
+ name: "Set up later",
1751
+ value: "later"
1752
+ }
1753
+ ]);
1754
+ if (action === "later") return { kind: "later" };
1755
+ if (action === "create") {
1756
+ const organization = await selectInitOrganization(deps);
1757
+ const defaultName = projectNameSuggestion(path4.basename(root)) || "tonbo-agent";
1758
+ for (; ; ) {
1759
+ const answer = (await deps.prompt(`Project name [${defaultName}]: `)).trim().toLowerCase();
1760
+ const name = answer || defaultName;
1761
+ try {
1762
+ validateProjectName(name);
1763
+ return {
1764
+ kind: "create",
1765
+ name,
1766
+ organizationId: organization?.id,
1767
+ organizationName: organization?.name
1768
+ };
1769
+ } catch (error) {
1770
+ deps.output({
1771
+ message: error instanceof Error ? error.message : "Project name is invalid."
1772
+ });
1773
+ }
1774
+ }
1775
+ }
1776
+ const oauthToken = await deps.auth.accessToken();
1777
+ const projects = (await deps.api.listProjects(oauthToken)).filter(
1778
+ (project2) => project2.status === "active"
1779
+ );
1780
+ if (projects.length === 0) {
1781
+ deps.output({ message: "No active projects are available. Create a new project instead." });
1782
+ continue;
1783
+ }
1784
+ const id = await deps.select(
1785
+ "Which project should this Agent use?",
1786
+ projects.map((project2) => ({
1787
+ name: project2.name,
1788
+ description: `Organization: ${project2.organizationName} \xB7 ${project2.publicHostname} \xB7 ${project2.id}`,
1789
+ value: project2.id
1790
+ }))
1791
+ );
1792
+ const project = selectProject(projects, id);
1793
+ return {
1794
+ kind: "existing",
1795
+ name: project.name,
1796
+ organizationName: project.organizationName,
1797
+ publicHostname: project.publicHostname,
1798
+ sshDestination: project.sshDestination
1799
+ };
1800
+ }
1801
+ }
1802
+ function initProjectSummary(project) {
1803
+ if (project.kind === "create")
1804
+ return `Create ${project.name}${project.organizationName ? ` in ${project.organizationName}` : ""} (permanent address assigned after creation)`;
1805
+ if (project.kind === "existing") {
1806
+ return project.name ? `${project.name}${project.organizationName ? ` in ${project.organizationName}` : ""} (${project.publicHostname})` : `Keep ${project.publicHostname}`;
1807
+ }
1808
+ return "Set up later";
1809
+ }
1810
+ async function selectInitOrganization(deps) {
1811
+ const oauthToken = await deps.auth.accessToken();
1812
+ const organizations = (await deps.api.listOrganizations(oauthToken)).filter(
1813
+ (organization) => organization.role !== "member"
1814
+ );
1815
+ if (organizations.length === 0)
1816
+ throw new Error("No organization lets you create a project. Ask an admin or owner.");
1817
+ if (organizations.length === 1) return organizations[0];
1818
+ const id = await deps.select(
1819
+ "Which organization should own the new project?",
1820
+ organizations.map((organization) => ({
1821
+ description: organization.role,
1822
+ name: organization.name,
1823
+ value: organization.id
1824
+ }))
1825
+ );
1826
+ return organizations.find((organization) => organization.id === id) ?? organizations[0];
1827
+ }
1828
+ async function selectHarness(deps) {
1829
+ return await deps.select(
1830
+ "Which supported Harness should Tonbo use?",
1831
+ supportedHarnesses.map((supported) => ({
1832
+ description: `Run this project with the ${supported.command} command.`,
1833
+ name: `${supported.name} (\`${supported.command}\`)`,
1834
+ value: supported.id
1835
+ }))
1836
+ );
1837
+ }
1838
+ function harnessName(harness) {
1839
+ const supported = supportedHarnesses.find((candidate) => candidate.id === harness);
1840
+ return supported?.name ?? harness;
1841
+ }
1842
+ function renderInitSummary(project, harness, driver, model, entry, buildCommand) {
1843
+ const lines = [
1844
+ "",
1845
+ "Tonbo Agent configuration:",
1846
+ ` Project: ${initProjectSummary(project)}`,
1847
+ ` Harness: ${harnessName(harness)}`,
1848
+ ` Execution: ${driver === "native" ? "PI CLI (`pi`)" : "PI SDK app"}`,
1849
+ ` Model: ${model}`
1850
+ ];
1851
+ if (driver === "command") {
1852
+ lines.push(` Build: ${buildCommand?.join(" ") ?? "none"}`);
1853
+ lines.push(` Entrypoint: node ${entry}`);
1854
+ }
1855
+ return lines.join("\n");
1856
+ }
1519
1857
  function piTargetChoices(settingsFound) {
1520
1858
  const choices = {
1521
1859
  command: {
@@ -1579,8 +1917,8 @@ function reportLoginProgress(progress, event) {
1579
1917
  if (event.status === "started") progress.start(messages.started);
1580
1918
  else progress.succeed(messages.completed);
1581
1919
  }
1582
- async function sshKeyAddCommand(deps, path7) {
1583
- const key = await readSshPublicKey(path7);
1920
+ async function sshKeyAddCommand(deps, path6) {
1921
+ const key = await readSshPublicKey(path6);
1584
1922
  const oauthToken = await deps.auth.accessToken();
1585
1923
  await deps.api.registerSshKey(oauthToken, key);
1586
1924
  deps.output({ message: `Registered SSH key ${key.fingerprint}.`, key });
@@ -1590,166 +1928,361 @@ async function sshKeyRemoveCommand(deps, fingerprint) {
1590
1928
  const key = await deps.api.revokeSshKey(oauthToken, fingerprint);
1591
1929
  deps.output({ message: `Revoked SSH key ${key.fingerprint}.`, key });
1592
1930
  }
1593
- async function projectUseCommand(deps, selector) {
1931
+ async function projectUseCommand(deps, selector, force = false) {
1594
1932
  const root = await findDeclarationRoot(deps.cwd());
1933
+ const declaration = await loadDeclaration(root);
1595
1934
  const oauthToken = await deps.auth.accessToken();
1596
- const project = selectProject(await deps.api.listProjects(oauthToken), selector);
1597
- await deps.config.setBinding(root, {
1598
- projectId: project.id,
1599
- projectSlug: project.slug
1600
- });
1601
- deps.output({ message: `Using Project ${project.slug}.`, project });
1935
+ const projects = await deps.api.listProjects(oauthToken);
1936
+ const project = selectProject(projects, selector);
1937
+ if (declaration.agent === project.publicHostname) {
1938
+ deps.output({ message: `.tonbo already uses project ${project.publicHostname}.`, project });
1939
+ return;
1940
+ }
1941
+ if (declaration.agent && !force) {
1942
+ const current = projects.find((candidate) => candidate.publicHostname === declaration.agent);
1943
+ const currentLabel = current?.publicHostname ?? declaration.agent;
1944
+ if (!deps.interactive()) {
1945
+ throw new Error(
1946
+ `.tonbo already uses project ${currentLabel}. Pass --force to rebind it to ${project.publicHostname}.`
1947
+ );
1948
+ }
1949
+ const action = await deps.select(
1950
+ `Rebind .tonbo from ${currentLabel} to ${project.publicHostname}?`,
1951
+ [
1952
+ { name: `Rebind to ${project.publicHostname}`, value: "rebind" },
1953
+ { name: "Cancel", value: "cancel" }
1954
+ ]
1955
+ );
1956
+ if (action !== "rebind") {
1957
+ deps.output({ message: `Cancelled. .tonbo still uses project ${currentLabel}.` });
1958
+ return;
1959
+ }
1960
+ }
1961
+ await bindDeclarationProject(root, project.publicHostname);
1962
+ deps.output({ message: `Bound .tonbo to project ${project.publicHostname}.`, project });
1602
1963
  }
1603
- async function projectCreateCommand(deps, slug, name) {
1964
+ async function projectCreateCommand(deps, name) {
1604
1965
  const root = await findDeclarationRoot(deps.cwd());
1966
+ const declaration = await loadDeclaration(root);
1967
+ if (declaration.agent) {
1968
+ throw new Error(
1969
+ `.tonbo is already bound to project ${declaration.agent}. Run \`tonbo project use <project>\` to review a rebind.`
1970
+ );
1971
+ }
1605
1972
  const oauthToken = await deps.auth.accessToken();
1606
- const project = await deps.api.createProject(oauthToken, slug, name);
1607
- await deps.config.setBinding(root, {
1608
- projectId: project.id,
1609
- projectSlug: project.slug
1610
- });
1611
- deps.output({ message: `Created and selected Project ${project.slug}.`, project });
1973
+ validateProjectName(name);
1974
+ const project = await deps.api.createProject(oauthToken, name);
1975
+ try {
1976
+ await bindDeclarationProject(root, project.publicHostname);
1977
+ } catch (error) {
1978
+ throw new Error(
1979
+ `Created project ${project.name}, but could not bind ${DECLARATION_FILENAME}. Fix the file and run \`tonbo project use ${project.publicHostname}\`.`,
1980
+ { cause: error }
1981
+ );
1982
+ }
1983
+ deps.output({ message: `Created project ${project.name} and bound it in .tonbo.`, project });
1612
1984
  }
1613
- async function deployCommand(deps, selector) {
1614
- const root = await findDeclarationRoot(deps.cwd());
1615
- const declaration = await loadDeclaration(root);
1985
+ async function deployCommand(deps, options = { promote: true }) {
1986
+ const { declaration, oauthToken, project, root } = await resolveProject(deps);
1616
1987
  if (declaration.build) await runBuildCommand(root, declaration.build.command);
1617
1988
  const source = await buildSourceBundle(root);
1618
- const oauthToken = await deps.auth.accessToken();
1619
- let binding = await deps.config.getBinding(root);
1620
- if (selector) {
1621
- const project = selectProject(await deps.api.listProjects(oauthToken), selector);
1622
- binding = { projectId: project.id, projectSlug: project.slug };
1623
- }
1624
- if (!binding) throw new Error("No Project selected. Run `tonbo project use <project>` first.");
1625
- const managementToken = await deps.api.exchangeManagementToken(oauthToken, binding.projectId);
1989
+ const origin = { actor: "cli", git: await deps.gitProvenance(root) };
1990
+ const managementToken = await deps.api.exchangeManagementToken(oauthToken, project.id);
1626
1991
  const result = await deps.api.deploy({
1627
1992
  bundle: source,
1628
- projectId: binding.projectId,
1629
- spec: buildRevision(declaration, source),
1993
+ origin,
1994
+ projectId: project.id,
1995
+ promote: options.promote,
1996
+ spec: buildDeploymentSpec(declaration, source),
1630
1997
  token: managementToken
1631
1998
  });
1632
- deps.output({
1633
- message: `Deployed Project ${binding.projectSlug}.`,
1634
- project: binding,
1635
- ...result
1636
- });
1999
+ const { deployment } = result;
2000
+ const shortId = shortDeploymentId(deployment.id);
2001
+ const lines = [
2002
+ options.promote ? `Deployed project ${project.name}.` : `Created Deployment ${deploymentName(deployment)} (${shortId}) for project ${project.name} without promoting it.`,
2003
+ `Organization: ${project.organizationName}`,
2004
+ `Deployment: ${deploymentName(deployment)} (${shortId})`,
2005
+ `Source: ${deploymentSourceLabel(deployment)}`,
2006
+ `Production: ${options.promote ? productionStateLabel(deployment) : `not promoted; run \`tonbo deployments promote ${shortId}\` to move Production here`}`,
2007
+ `Application: https://${project.publicHostname}`
2008
+ ];
2009
+ if (result.unchanged) {
2010
+ lines.push(
2011
+ `Contents are identical to the ${options.promote ? "previous" : "current"} Production Deployment; the running Agent does not change.`
2012
+ );
2013
+ }
2014
+ if (options.promote) {
2015
+ lines.push(
2016
+ "",
2017
+ "Agent process: starts with the first turn and stays warm while the runtime is active.",
2018
+ "",
2019
+ "Start the Agent:",
2020
+ ' tonbo run "<prompt>"',
2021
+ "",
2022
+ "Connect with SSH:",
2023
+ ` ssh ${project.sshDestination}`
2024
+ );
2025
+ }
2026
+ deps.output({ message: lines.join("\n"), project, ...result });
1637
2027
  }
1638
2028
  async function runCommand(deps, prompt, options) {
1639
- const root = await findDeclarationRoot(deps.cwd());
1640
- const oauthToken = await deps.auth.accessToken();
1641
- let binding = await deps.config.getBinding(root);
1642
- if (options.project) {
1643
- const project = selectProject(await deps.api.listProjects(oauthToken), options.project);
1644
- binding = { projectId: project.id, projectSlug: project.slug };
1645
- }
1646
- if (!binding) throw new Error("No Project selected. Run `tonbo project use <project>` first.");
1647
- const managementToken = await deps.api.exchangeManagementToken(oauthToken, binding.projectId);
2029
+ const { oauthToken, project } = await resolveProject(deps);
2030
+ const managementToken = await deps.api.exchangeManagementToken(oauthToken, project.id);
1648
2031
  const result = await deps.api.run({
1649
- projectId: binding.projectId,
2032
+ projectId: project.id,
1650
2033
  prompt,
1651
2034
  sessionId: options.session,
1652
2035
  token: managementToken
1653
2036
  });
1654
2037
  deps.output({
1655
2038
  message: result.turn.assistant_text,
1656
- project: binding,
2039
+ project,
2040
+ ...result
2041
+ });
2042
+ }
2043
+ async function projectShowCommand(deps) {
2044
+ const { project, token } = await projectManagement(deps);
2045
+ const production = await deps.api.getProduction(project.id, token);
2046
+ const deployment = production ? await deps.api.getDeployment(project.id, production.deployment_id, token) : null;
2047
+ const lines = [
2048
+ `project: ${project.name}`,
2049
+ `Organization: ${project.organizationName}`,
2050
+ `application: https://${project.publicHostname}`,
2051
+ `ssh: ssh ${project.sshDestination}`
2052
+ ];
2053
+ if (deployment) {
2054
+ lines.push(
2055
+ `production: ${deploymentName(deployment)} (${shortDeploymentId(deployment.id)}) ${productionStateLabel(deployment)}`
2056
+ );
2057
+ }
2058
+ deps.output({ message: lines.join("\n"), project, production, deployment });
2059
+ }
2060
+ function shortDeploymentId(id) {
2061
+ return id.replace(/-/g, "").slice(0, 8);
2062
+ }
2063
+ function deploymentName(deployment) {
2064
+ const subject = deployment.origin.git?.subject?.trim();
2065
+ return subject ? subject : shortDeploymentId(deployment.id);
2066
+ }
2067
+ function deploymentSourceLabel(deployment) {
2068
+ const git = deployment.origin.git;
2069
+ if (!git) return "tonbo deploy";
2070
+ const sha = git.commit_sha.slice(0, 7);
2071
+ return git.ref ? `${sha} \xB7 ${git.ref}` : sha;
2072
+ }
2073
+ function productionStateLabel(deployment) {
2074
+ const { generation, state } = deployment.production;
2075
+ return generation === null ? state : `${state} (generation ${generation})`;
2076
+ }
2077
+ function selectDeployment(deployments, prefix) {
2078
+ const normalized = prefix.trim().toLowerCase().replace(/-/g, "");
2079
+ if (!/^[0-9a-f]+$/.test(normalized)) {
2080
+ throw new Error(`Deployment id ${prefix} must be a hexadecimal id prefix.`);
2081
+ }
2082
+ const matches = deployments.filter(
2083
+ (deployment) => deployment.id.replace(/-/g, "").startsWith(normalized)
2084
+ );
2085
+ if (matches.length === 0) throw new Error(`Deployment ${prefix} was not found in this project.`);
2086
+ if (matches.length > 1) {
2087
+ throw new Error(
2088
+ `Deployment id ${prefix} is ambiguous; it matches ${matches.map((deployment) => shortDeploymentId(deployment.id)).join(", ")}. Use more characters.`
2089
+ );
2090
+ }
2091
+ return matches[0];
2092
+ }
2093
+ function renderTable(headers, rows) {
2094
+ const widths = headers.map(
2095
+ (header, column) => Math.max(header.length, ...rows.map((row) => row[column].length))
2096
+ );
2097
+ const render = (row) => row.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widths[column])).join(" ").trimEnd();
2098
+ return [render(headers), ...rows.map(render)].join("\n");
2099
+ }
2100
+ function formatTimestamp(value) {
2101
+ const date = new Date(value);
2102
+ if (Number.isNaN(date.getTime())) return value;
2103
+ return `${date.toISOString().slice(0, 16)}Z`;
2104
+ }
2105
+ function productionMarker(state) {
2106
+ if (state === "current") return "\u25CF";
2107
+ if (state === "previous") return "\u25CB";
2108
+ return "";
2109
+ }
2110
+ async function deploymentsListCommand(deps) {
2111
+ const { project, token } = await projectManagement(deps);
2112
+ const [deployments, production] = await Promise.all([
2113
+ deps.api.listDeployments(project.id, token),
2114
+ deps.api.getProduction(project.id, token)
2115
+ ]);
2116
+ const message = deployments.length ? renderTable(
2117
+ ["ID", "NAME", "STATUS", "PRODUCTION", "SOURCE", "CREATED", "BY"],
2118
+ deployments.map((deployment) => [
2119
+ shortDeploymentId(deployment.id),
2120
+ deploymentName(deployment),
2121
+ deployment.production.state,
2122
+ productionMarker(deployment.production.state),
2123
+ deploymentSourceLabel(deployment),
2124
+ formatTimestamp(deployment.created_at),
2125
+ deployment.created_by_user_id ? shortDeploymentId(deployment.created_by_user_id) : ""
2126
+ ])
2127
+ ) : `No Deployments exist for project ${project.name}. Run \`tonbo deploy\` to create one.`;
2128
+ deps.output({ message, project, production, deployments });
2129
+ }
2130
+ async function deploymentsShowCommand(deps, prefix) {
2131
+ const { project, token } = await projectManagement(deps);
2132
+ const deployment = selectDeployment(await deps.api.listDeployments(project.id, token), prefix);
2133
+ const rollouts = (await deps.api.listRollouts(project.id, token)).filter(
2134
+ (rollout) => rollout.deployment_id === deployment.id || rollout.previous_deployment_id === deployment.id
2135
+ );
2136
+ deps.output({
2137
+ message: renderDeploymentDetail(deployment, rollouts),
2138
+ project,
2139
+ deployment,
2140
+ rollouts
2141
+ });
2142
+ }
2143
+ function renderDeploymentDetail(deployment, rollouts) {
2144
+ const git = deployment.origin.git;
2145
+ const source = deployment.spec.source;
2146
+ const bundleLabel = typeof source?.sha256 === "string" ? [
2147
+ typeof source.format === "string" ? source.format : "",
2148
+ source.sha256,
2149
+ typeof source.size_bytes === "number" ? `(${source.size_bytes} bytes)` : ""
2150
+ ].filter(Boolean).join(" ") : null;
2151
+ const inference = deployment.spec.inference;
2152
+ const rows = [
2153
+ ["Deployment", `${deploymentName(deployment)} (${shortDeploymentId(deployment.id)})`],
2154
+ ["ID", deployment.id],
2155
+ ["Status", productionStateLabel(deployment)],
2156
+ ["Source", `${deploymentSourceLabel(deployment)}${git?.dirty ? " (dirty)" : ""}`]
2157
+ ];
2158
+ if (git) rows.push(["Commit", git.commit_sha], ["Author", git.author ?? ""]);
2159
+ rows.push(
2160
+ ["Created", formatTimestamp(deployment.created_at)],
2161
+ ["Created by", deployment.created_by_user_id ?? ""],
2162
+ ["Spec sha256", deployment.spec_sha256]
2163
+ );
2164
+ if (typeof inference?.model === "string") rows.push(["Model", inference.model]);
2165
+ if (bundleLabel) rows.push(["Bundle", bundleLabel]);
2166
+ const label = Math.max(...rows.map(([name]) => name.length)) + 1;
2167
+ const lines = rows.map(([name, value]) => `${`${name}:`.padEnd(label)} ${value}`.trimEnd());
2168
+ lines.push("", "Rollouts:");
2169
+ if (rollouts.length === 0) lines.push(" none");
2170
+ for (const rollout of rollouts) {
2171
+ const direction = rollout.deployment_id === deployment.id ? `${rollout.kind} to this Deployment${rollout.previous_deployment_id ? ` from ${shortDeploymentId(rollout.previous_deployment_id)}` : ""}` : `${rollout.kind} away to ${shortDeploymentId(rollout.deployment_id)}`;
2172
+ lines.push(
2173
+ ` generation ${rollout.generation} ${formatTimestamp(rollout.created_at)} ${direction}${rollout.created_by_user_id ? ` by ${shortDeploymentId(rollout.created_by_user_id)}` : ""}`
2174
+ );
2175
+ }
2176
+ return lines.join("\n");
2177
+ }
2178
+ async function deploymentsPromoteCommand(deps, prefix) {
2179
+ const { project, token } = await projectManagement(deps);
2180
+ const target = selectDeployment(await deps.api.listDeployments(project.id, token), prefix);
2181
+ const result = await moveProduction(deps, project.id, target, token);
2182
+ deps.output({
2183
+ message: describeProductionMove(project, result, "Promoted"),
2184
+ project,
2185
+ ...result
2186
+ });
2187
+ }
2188
+ async function deploymentsRollbackCommand(deps, prefix) {
2189
+ const { project, token } = await projectManagement(deps);
2190
+ let target;
2191
+ if (prefix !== void 0) {
2192
+ target = selectDeployment(await deps.api.listDeployments(project.id, token), prefix);
2193
+ } else {
2194
+ const rollouts = await deps.api.listRollouts(project.id, token);
2195
+ const previousId = rollouts.find(
2196
+ (rollout) => rollout.previous_deployment_id !== null
2197
+ )?.previous_deployment_id;
2198
+ if (!previousId) {
2199
+ throw new Error(
2200
+ `Project ${project.name} has no previous Production Deployment to roll back to. Name one with \`tonbo deployments rollback <id>\`.`
2201
+ );
2202
+ }
2203
+ target = await deps.api.getDeployment(project.id, previousId, token);
2204
+ }
2205
+ const result = await moveProduction(deps, project.id, target, token);
2206
+ deps.output({
2207
+ message: describeProductionMove(project, result, "Rolled back"),
2208
+ project,
1657
2209
  ...result
1658
2210
  });
1659
2211
  }
1660
- async function sshCommand(deps, selector) {
1661
- const { project } = await resolveProject(deps, selector);
1662
- const code = await launchProjectSsh(project.slug);
1663
- if (code !== 0) throw new Error(`ssh exited with status ${code}`);
2212
+ async function moveProduction(deps, projectId, target, token) {
2213
+ const previous = await deps.api.getProduction(projectId, token);
2214
+ const production = await deps.api.putProduction(
2215
+ projectId,
2216
+ {
2217
+ deployment_id: target.id,
2218
+ desired_state: "running",
2219
+ expected_generation: previous ? previous.generation : null
2220
+ },
2221
+ token
2222
+ );
2223
+ return {
2224
+ deployment: await deps.api.getDeployment(projectId, target.id, token),
2225
+ previous,
2226
+ production
2227
+ };
1664
2228
  }
1665
- async function projectManagement(deps, selector) {
1666
- const { oauthToken, project } = await resolveProject(deps, selector);
2229
+ function describeProductionMove(project, move, verb) {
2230
+ const { deployment, previous } = move;
2231
+ const name = `${deploymentName(deployment)} (${shortDeploymentId(deployment.id)})`;
2232
+ const headline = previous?.deployment_id === deployment.id ? `Deployment ${name} is already Production for project ${project.name}; requested it to be running.` : `${verb} ${name} to Production for project ${project.name}${previous ? ` (from ${shortDeploymentId(previous.deployment_id)})` : ""}.`;
2233
+ return `${headline}
2234
+ Production: ${productionStateLabel(deployment)}
2235
+ Application: https://${project.publicHostname}`;
2236
+ }
2237
+ async function projectManagement(deps) {
2238
+ const { oauthToken, project } = await resolveProject(deps);
1667
2239
  return {
1668
2240
  project,
1669
2241
  token: await deps.api.exchangeManagementToken(oauthToken, project.id)
1670
2242
  };
1671
2243
  }
1672
- async function secretListCommand(deps, selector) {
1673
- const { project, token } = await projectManagement(deps, selector);
2244
+ async function secretListCommand(deps) {
2245
+ const { project, token } = await projectManagement(deps);
1674
2246
  const secrets = await deps.api.listProjectSecrets(project.id, token);
1675
2247
  deps.output({
1676
- message: secrets.length ? secrets.map((secret) => secret.name).join("\n") : "No Project secrets are configured.",
2248
+ message: secrets.length ? secrets.map((secret) => secret.name).join("\n") : "No project secrets are configured.",
1677
2249
  project,
1678
2250
  secrets
1679
2251
  });
1680
2252
  }
1681
2253
  async function secretSetCommand(deps, name, options) {
1682
2254
  const value = await deps.secretValue(name, options.fromEnv);
1683
- const { project, token } = await projectManagement(deps, options.project);
2255
+ const { project, token } = await projectManagement(deps);
1684
2256
  const secret = await deps.api.setProjectSecret(project.id, name, value, token);
1685
2257
  deps.output({
1686
- message: `Set Project secret ${secret.name}. Redeploy to replace the active runtime with this value.`,
2258
+ message: `Set project secret ${secret.name}. Redeploy to replace the active runtime with this value.`,
1687
2259
  project,
1688
2260
  secret
1689
2261
  });
1690
2262
  }
1691
- async function secretRemoveCommand(deps, name, selector) {
1692
- const { project, token } = await projectManagement(deps, selector);
2263
+ async function secretRemoveCommand(deps, name) {
2264
+ const { project, token } = await projectManagement(deps);
1693
2265
  await deps.api.deleteProjectSecret(project.id, name, token);
1694
- deps.output({ message: `Removed Project secret ${name}.`, project, name });
2266
+ deps.output({ message: `Removed project secret ${name}.`, project, name });
1695
2267
  }
1696
2268
 
1697
- // src/config.ts
1698
- import { mkdir, readFile as readFile5, writeFile } from "node:fs/promises";
2269
+ // src/credentials.ts
2270
+ import { randomUUID as randomUUID3 } from "node:crypto";
2271
+ import { chmod, lstat as lstat4, mkdir, open as open2, readFile as readFile5, rename as rename2, rm as rm2 } from "node:fs/promises";
1699
2272
  import os from "node:os";
1700
2273
  import path5 from "node:path";
1701
- var FileConfigStore = class {
1702
- constructor(filename = defaultConfigPath()) {
1703
- this.filename = filename;
1704
- }
1705
- async getBinding(declarationRoot) {
1706
- const config = await this.read();
1707
- return config.bindings[path5.resolve(declarationRoot)] ?? null;
1708
- }
1709
- async setBinding(declarationRoot, binding) {
1710
- const config = await this.read();
1711
- config.bindings[path5.resolve(declarationRoot)] = binding;
1712
- await mkdir(path5.dirname(this.filename), { recursive: true, mode: 448 });
1713
- await writeFile(this.filename, `${JSON.stringify(config, null, 2)}
1714
- `, {
1715
- mode: 384
1716
- });
1717
- }
1718
- async read() {
1719
- try {
1720
- const value = JSON.parse(await readFile5(this.filename, "utf8"));
1721
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
1722
- const bindings = value.bindings;
1723
- if (!bindings || typeof bindings !== "object" || Array.isArray(bindings)) throw new Error();
1724
- return { bindings };
1725
- } catch (error) {
1726
- if (error.code === "ENOENT") return { bindings: {} };
1727
- throw new Error(`Could not read Tonbo config at ${this.filename}.`, {
1728
- cause: error
1729
- });
1730
- }
1731
- }
1732
- };
1733
- function defaultConfigPath() {
1734
- return path5.join(defaultConfigDirectory(), "config.json");
1735
- }
1736
2274
  function defaultConfigDirectory() {
1737
2275
  const base = process.env.XDG_CONFIG_HOME || path5.join(os.homedir(), ".config");
1738
2276
  return path5.join(base, "tonbo");
1739
2277
  }
1740
-
1741
- // src/credentials.ts
1742
- import { randomUUID as randomUUID3 } from "node:crypto";
1743
- import { chmod, lstat as lstat4, mkdir as mkdir2, open as open2, readFile as readFile6, rename as rename2, rm as rm2 } from "node:fs/promises";
1744
- import path6 from "node:path";
1745
2278
  var FileCredentialStore = class {
1746
- constructor(filename = path6.join(defaultConfigDirectory(), "credentials.json")) {
2279
+ constructor(filename = path5.join(defaultConfigDirectory(), "credentials.json")) {
1747
2280
  this.filename = filename;
1748
2281
  }
1749
2282
  async load() {
1750
2283
  try {
1751
2284
  await assertPrivateRegularFile(this.filename);
1752
- const parsed = JSON.parse(await readFile6(this.filename, "utf8"));
2285
+ const parsed = JSON.parse(await readFile5(this.filename, "utf8"));
1753
2286
  if (!isOAuthTokenSet(parsed)) throw new Error("invalid token set");
1754
2287
  return parsed;
1755
2288
  } catch (error) {
@@ -1759,13 +2292,13 @@ var FileCredentialStore = class {
1759
2292
  }
1760
2293
  async save(tokens) {
1761
2294
  if (!isOAuthTokenSet(tokens)) throw new Error("Refusing to store a malformed Tonbo token set.");
1762
- const directory = path6.dirname(this.filename);
1763
- await mkdir2(directory, { mode: 448, recursive: true });
2295
+ const directory = path5.dirname(this.filename);
2296
+ await mkdir(directory, { mode: 448, recursive: true });
1764
2297
  await preparePrivateDirectory(directory);
1765
2298
  await assertExistingDestinationIsSafe(this.filename);
1766
- const temporary = path6.join(
2299
+ const temporary = path5.join(
1767
2300
  directory,
1768
- `.${path6.basename(this.filename)}.${process.pid}.${randomUUID3()}.tmp`
2301
+ `.${path5.basename(this.filename)}.${process.pid}.${randomUUID3()}.tmp`
1769
2302
  );
1770
2303
  let handle = null;
1771
2304
  try {
@@ -1812,6 +2345,60 @@ async function assertPrivateRegularFile(filename) {
1812
2345
  throw new Error("Tonbo credential file must have mode 0600.");
1813
2346
  }
1814
2347
 
2348
+ // src/git.ts
2349
+ import { execFile as execFile2 } from "node:child_process";
2350
+ var defaultGitRunner = (args, cwd) => new Promise((resolve, reject) => {
2351
+ execFile2(
2352
+ "git",
2353
+ args,
2354
+ {
2355
+ cwd,
2356
+ encoding: "utf8",
2357
+ // Never take the index lock for a read: a concurrent editor or IDE
2358
+ // must not turn provenance collection into a failure.
2359
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
2360
+ windowsHide: true
2361
+ },
2362
+ (error, stdout) => error ? reject(new Error(`git ${args.join(" ")} failed.`, { cause: error })) : resolve(stdout)
2363
+ );
2364
+ });
2365
+ var COMMIT_SHA = /^[0-9a-f]{40}$/;
2366
+ var REF_MAX_LENGTH = 255;
2367
+ var SUBJECT_MAX_LENGTH = 512;
2368
+ var AUTHOR_MAX_LENGTH = 255;
2369
+ async function collectGitProvenance(root, run = defaultGitRunner) {
2370
+ const commitSha = await read(run, ["rev-parse", "HEAD"], root);
2371
+ if (!commitSha || !COMMIT_SHA.test(commitSha)) return null;
2372
+ const [ref, subject, author, status] = await Promise.all([
2373
+ read(run, ["rev-parse", "--abbrev-ref", "HEAD"], root),
2374
+ read(run, ["log", "-1", "--format=%s"], root),
2375
+ read(run, ["log", "-1", "--format=%an"], root),
2376
+ // Only the deployed tree matters: changes elsewhere in the repository do
2377
+ // not alter the uploaded bundle.
2378
+ read(run, ["status", "--porcelain", "--", "."], root)
2379
+ ]);
2380
+ return {
2381
+ commit_sha: commitSha,
2382
+ ref: ref === "HEAD" ? null : clamp(ref, REF_MAX_LENGTH),
2383
+ subject: clamp(subject, SUBJECT_MAX_LENGTH),
2384
+ author: clamp(author, AUTHOR_MAX_LENGTH),
2385
+ // When the status cannot be read the tree cannot be shown to match the
2386
+ // commit, so the Deployment is recorded as dirty rather than clean.
2387
+ dirty: status === null || status.length > 0
2388
+ };
2389
+ }
2390
+ async function read(run, args, cwd) {
2391
+ try {
2392
+ return (await run(args, cwd)).trim();
2393
+ } catch {
2394
+ return null;
2395
+ }
2396
+ }
2397
+ function clamp(value, maxLength) {
2398
+ if (!value) return null;
2399
+ return value.length > maxLength ? value.slice(0, maxLength) : value;
2400
+ }
2401
+
1815
2402
  // src/progress.ts
1816
2403
  var FRAMES = ["|", "/", "-", "\\"];
1817
2404
  var TerminalProgress = class {
@@ -1909,9 +2496,9 @@ function createDependencies(json = false) {
1909
2496
  return {
1910
2497
  api: new TonboApi(fetch, accountOrigin, managementOrigin),
1911
2498
  auth: new AuthClient(new FileCredentialStore(), fetch, accountOrigin),
1912
- config: new FileConfigStore(),
1913
2499
  cwd: () => process.cwd(),
1914
2500
  defaultSshPublicKeys: readDefaultSshPublicKeys,
2501
+ gitProvenance: collectGitProvenance,
1915
2502
  interactive: () => !json && process.stdin.isTTY === true && process.stderr.isTTY === true,
1916
2503
  inspectSource: inspectLocalAgentSource,
1917
2504
  output: (value) => {
@@ -1921,54 +2508,115 @@ function createDependencies(json = false) {
1921
2508
  progress: json ? silentProgress : new TerminalProgress(process.stderr),
1922
2509
  prompt: terminalPrompt,
1923
2510
  select: terminalSelect,
1924
- secretValue: async (name, fromEnvironment) => {
2511
+ secretValue: (name, fromEnvironment) => {
1925
2512
  const environmentName = fromEnvironment ?? name;
1926
2513
  const value = process.env[environmentName];
1927
2514
  if (!value)
1928
2515
  throw new Error(
1929
2516
  `Environment variable ${environmentName} is empty. Set it before running tonbo secret set.`
1930
2517
  );
1931
- return value;
2518
+ return Promise.resolve(value);
1932
2519
  }
1933
2520
  };
1934
2521
  }
2522
+ function jsonOutput(program) {
2523
+ return program.opts().json === true;
2524
+ }
1935
2525
  function createProgram(dependencies = createDependencies) {
1936
2526
  const program = new Command().name("tonbo").description("Deploy a persistent Project Agent to Tonbo.").version(packageVersion.version).option("--json", "print machine-readable JSON");
1937
2527
  program.command("init").description("interactively create a Tonbo Agent declaration in this directory").option("--harness <harness>", "Agent Harness (currently pi)").option("--model <model>", "inference model").option("--driver <driver>", "PI driver: native or command").option("--agent-entry <file>", "Node entry file for the command driver").option("--build-command <argv...>", "build command argv for the command driver").option("--force", `replace an existing ${DECLARATION_FILENAME}`).action(
1938
- async (options) => initCommand(dependencies(program.opts().json), options)
2528
+ async (options) => initCommand(dependencies(jsonOutput(program)), options)
1939
2529
  );
1940
- program.command("login").description("sign in through the browser and store the session in the user config").action(async () => loginCommand(dependencies(program.opts().json)));
1941
- const project = program.command("project").description("manage the Project bound to this Agent directory");
1942
- project.command("create <slug>").description("create and bind a Project to this Agent directory").option("--name <name>", "display name for the Project").action(
1943
- async (slug, options) => projectCreateCommand(dependencies(program.opts().json), slug, options.name)
1944
- );
1945
- const sshKey = program.command("ssh-key").description("manage public keys used by native Project SSH");
1946
- sshKey.command("add <public-key>").description("register an OpenSSH public key with the current Tonbo account").action(async (path7) => sshKeyAddCommand(dependencies(program.opts().json), path7));
2530
+ program.command("login").description("sign in through the browser and store the session in the user config").action(async () => loginCommand(dependencies(jsonOutput(program))));
2531
+ const machine = program.command("machine").description("manage independent account-owned Machines");
2532
+ machine.command("list").option("--account <account>", "account UUID").action(async (options) => {
2533
+ const deps = dependencies(jsonOutput(program));
2534
+ deps.output(
2535
+ await deps.api.machineRequest(
2536
+ await deps.auth.accessToken(),
2537
+ options.account ? `?account=${encodeURIComponent(options.account)}` : ""
2538
+ )
2539
+ );
2540
+ });
2541
+ machine.command("allocate").requiredOption("--account <account>", "owning account UUID").requiredOption("--region <region>", "compute region (currently us-east-1)").action(async (options) => {
2542
+ const deps = dependencies(jsonOutput(program));
2543
+ deps.output(
2544
+ await deps.api.machineRequest(await deps.auth.accessToken(), "", "POST", {
2545
+ accountId: options.account,
2546
+ region: options.region
2547
+ })
2548
+ );
2549
+ });
2550
+ for (const operation of ["show", "metrics", "release"]) {
2551
+ machine.command(`${operation} <machine>`).action(async (id) => {
2552
+ const deps = dependencies(jsonOutput(program));
2553
+ const result = await deps.api.machineRequest(
2554
+ await deps.auth.accessToken(),
2555
+ `/${encodeURIComponent(id)}`,
2556
+ operation === "release" ? "DELETE" : "GET"
2557
+ );
2558
+ deps.output(operation === "metrics" ? result.metrics : result);
2559
+ });
2560
+ }
2561
+ machine.command("bind <machine>").requiredOption("--agent <agent>", "Agent UUID").action(async (id, options) => {
2562
+ const deps = dependencies(jsonOutput(program));
2563
+ deps.output(
2564
+ await deps.api.machineRequest(
2565
+ await deps.auth.accessToken(),
2566
+ `/${encodeURIComponent(id)}/binding`,
2567
+ "POST",
2568
+ { agentId: options.agent }
2569
+ )
2570
+ );
2571
+ });
2572
+ machine.command("unbind <machine>").description("detach Agent and reset system disk; retain Agent Workspace and Sessions").action(async (id) => {
2573
+ const deps = dependencies(jsonOutput(program));
2574
+ const token = await deps.auth.accessToken();
2575
+ const path6 = `/${encodeURIComponent(id)}`;
2576
+ const current = await deps.api.machineRequest(token, path6);
2577
+ if (!current.binding) throw new Error("Machine is not bound to an Agent.");
2578
+ deps.output(
2579
+ await deps.api.machineRequest(token, `${path6}/binding`, "DELETE", {
2580
+ agentId: current.binding.agent_id,
2581
+ bindingId: current.binding.id,
2582
+ generation: current.binding.generation
2583
+ })
2584
+ );
2585
+ });
2586
+ const project = program.command("project").description("manage the project bound to this Agent directory");
2587
+ project.command("create <name>").description("create and bind a project to this Agent directory").action(async (name) => projectCreateCommand(dependencies(jsonOutput(program)), name));
2588
+ const sshKey = program.command("ssh-key").description("manage public keys used by native project SSH");
2589
+ sshKey.command("add <public-key>").description("register an OpenSSH public key with the current Tonbo account").action(async (path6) => sshKeyAddCommand(dependencies(jsonOutput(program)), path6));
1947
2590
  sshKey.command("remove <fingerprint>").description("revoke an SSH public key from the current Tonbo account").action(
1948
- async (fingerprint) => sshKeyRemoveCommand(dependencies(program.opts().json), fingerprint)
2591
+ async (fingerprint) => sshKeyRemoveCommand(dependencies(jsonOutput(program)), fingerprint)
1949
2592
  );
1950
- project.command("use <project>").description("bind this .tonbo directory to a Project slug or ID").action(
1951
- async (selector) => projectUseCommand(dependencies(program.opts().json), selector)
2593
+ project.command("use <project>").description("write a project ID into this Agent's .tonbo declaration").option("--force", "replace an existing project binding without confirmation").action(
2594
+ async (selector, options) => projectUseCommand(dependencies(jsonOutput(program)), selector, options.force === true)
1952
2595
  );
1953
- program.command("deploy").description("upload this directory as the selected Project deployment").option("--project <project>", "override the bound Project for this deploy").action(
1954
- async (options) => deployCommand(dependencies(program.opts().json), options.project)
2596
+ project.command("show").description("show the project bound to this Agent directory and its connection addresses").action(async () => projectShowCommand(dependencies(jsonOutput(program))));
2597
+ program.command("deploy").description("upload this directory as a new Deployment and promote it to Production").option("--no-promote", "create the Deployment without moving Production").action(
2598
+ async (options) => deployCommand(dependencies(jsonOutput(program)), { promote: options.promote })
1955
2599
  );
1956
- program.command("run <prompt>").description("run one prompt in a durable Project session").option("--project <project>", "override the bound Project for this turn").option("--session <session>", "resume an existing Agent Session UUID").action(
1957
- async (prompt, options) => runCommand(dependencies(program.opts().json), prompt, options)
2600
+ const deployments = program.command("deployments").description("list and move between the immutable Deployments of the bound project");
2601
+ deployments.command("list", { isDefault: true }).description("list Deployments, newest first").action(async () => deploymentsListCommand(dependencies(jsonOutput(program))));
2602
+ deployments.command("show <deployment>").description("show one Deployment and its Rollouts by id prefix").action(
2603
+ async (prefix) => deploymentsShowCommand(dependencies(jsonOutput(program)), prefix)
1958
2604
  );
1959
- program.command("ssh").description("open the selected Project's singleton runtime over SSH").option("--project <project>", "override the bound Project").action(
1960
- async (options) => sshCommand(dependencies(program.opts().json), options.project)
2605
+ deployments.command("promote <deployment>").description("move Production to a Deployment by id prefix").action(
2606
+ async (prefix) => deploymentsPromoteCommand(dependencies(jsonOutput(program)), prefix)
1961
2607
  );
1962
- const secret = program.command("secret").description("manage encrypted environment secrets for the Project service");
1963
- secret.command("list").option("--project <project>", "override the bound Project").action(
1964
- async (options) => secretListCommand(dependencies(program.opts().json), options.project)
2608
+ deployments.command("rollback [deployment]").description("move Production back to the previous Production Deployment, or to a named one").action(
2609
+ async (prefix) => deploymentsRollbackCommand(dependencies(jsonOutput(program)), prefix)
1965
2610
  );
1966
- secret.command("set <name>").description("set a secret from an environment variable (the same name by default)").option("--from-env <name>", "read the value from another environment variable").option("--project <project>", "override the bound Project").action(
1967
- async (name, options) => secretSetCommand(dependencies(program.opts().json), name, options)
2611
+ program.command("run <prompt>").description("run one prompt in a durable project session").option("--session <session>", "resume an existing Agent Session UUID").action(
2612
+ async (prompt, options) => runCommand(dependencies(jsonOutput(program)), prompt, options)
1968
2613
  );
1969
- secret.command("remove <name>").option("--project <project>", "override the bound Project").action(
1970
- async (name, options) => secretRemoveCommand(dependencies(program.opts().json), name, options.project)
2614
+ const secret = program.command("secret").description("manage encrypted environment secrets for the project service");
2615
+ secret.command("list").action(async () => secretListCommand(dependencies(jsonOutput(program))));
2616
+ secret.command("set <name>").description("set a secret from an environment variable (the same name by default)").option("--from-env <name>", "read the value from another environment variable").action(
2617
+ async (name, options) => secretSetCommand(dependencies(jsonOutput(program)), name, options)
1971
2618
  );
2619
+ secret.command("remove <name>").action(async (name) => secretRemoveCommand(dependencies(jsonOutput(program)), name));
1972
2620
  return program;
1973
2621
  }
1974
2622