warpmetal 0.8.13 → 0.9.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/src/cli.js CHANGED
@@ -10,12 +10,23 @@ import {
10
10
  rejectUnknownOptions,
11
11
  stringOption,
12
12
  } from "./args.js";
13
+ import {
14
+ AccountGatewayClient,
15
+ accountSessionContext,
16
+ authenticatedAccountClient,
17
+ } from "./account-gateway.js";
13
18
  import { WarpMetalClient } from "./api.js";
14
19
  import { connectionProfile, writeConnectionProfile } from "./connection.js";
15
20
  import { CliError, toErrorMessage } from "./errors.js";
16
21
  import { establishHostTrust } from "./host-trust.js";
17
22
  import { installSkill } from "./install-skill.js";
18
23
  import { installRuntime } from "./installer.js";
24
+ import { openVerificationPage } from "./customer-auth.js";
25
+ import {
26
+ filterModelCatalog,
27
+ modelCatalogHuman,
28
+ MODEL_AUTH_MODES,
29
+ } from "./models.js";
19
30
  import {
20
31
  createPaymentRequestEnvelope,
21
32
  defaultPaymentPaths,
@@ -74,12 +85,19 @@ const COMMON_OPTIONS = ["base-url", "json", "state-dir", "help"];
74
85
  const HELP = `WarpMetal CLI ${VERSION}
75
86
 
76
87
  Usage:
88
+ warpmetal login [--no-browser] [--read-only] [--json]
89
+ warpmetal auth status [--json]
90
+ warpmetal account orders [--task <taskId>] [--json]
91
+ warpmetal account devices [--server <serverId>] [--json]
92
+ warpmetal logout [--json]
77
93
  warpmetal health [--json]
78
94
  warpmetal catalog [--plan <planId>] [--json]
95
+ warpmetal models [--provider <id>] [--auth-mode <api_key|chatgpt_subscription>] [--json]
79
96
  warpmetal order prepare --plan <planId> --hostname <name> --os <exact-name>
80
97
  (--generate-ssh-key [--ssh-key-name <name>] | --ssh-public-key-file <path>)
81
98
  [--runtime-file <path>] [--confirm TEMPORARY]
82
99
  [--email <address>] [--idempotency-key <key>]
100
+ [--account [--without-agent-boxes | --without-team]]
83
101
  warpmetal order status --task <taskId> [--wait] [--timeout-seconds <n>]
84
102
  warpmetal checkout challenge --task <taskId> [--request-envelope-out <path>]
85
103
  warpmetal checkout submit --task <taskId>
@@ -138,6 +156,8 @@ Usage:
138
156
 
139
157
  Global options:
140
158
  --base-url <url> Override https://api.warpmetal.com
159
+ --identity-url <url> Override https://identity.warpmetal.com for account auth
160
+ --account-url <url> Override https://warpmetal.com for account requests
141
161
  --state-dir <path> Override the private state directory
142
162
  --json Emit structured, secret-redacted JSON
143
163
  --help Show help
@@ -827,6 +847,204 @@ async function handleCatalog(client, options, { json, stdout }) {
827
847
  return 0;
828
848
  }
829
849
 
850
+ async function handleModels(client, options, { json, stdout }) {
851
+ const provider = stringOption(options, "provider");
852
+ const authMode = stringOption(options, "auth-mode");
853
+ if (authMode !== undefined && !MODEL_AUTH_MODES.has(authMode)) {
854
+ throw new CliError(
855
+ "--auth-mode must be api_key or chatgpt_subscription.",
856
+ { exitCode: 2 },
857
+ );
858
+ }
859
+ const result = await client.modelCatalog();
860
+ const data = filterModelCatalog(result.data, { provider, authMode });
861
+ emit(stdout, data, json, modelCatalogHuman(data));
862
+ return 0;
863
+ }
864
+
865
+ function accountOptions(options, context, stateDirectory) {
866
+ return {
867
+ stateDirectory,
868
+ identityOrigin: stringOption(options, "identity-url"),
869
+ accountOrigin: stringOption(options, "account-url"),
870
+ fetchImpl: context.fetchImpl,
871
+ };
872
+ }
873
+
874
+ function publicAccountIdentity(value) {
875
+ if (
876
+ !value ||
877
+ typeof value.principalId !== "string" ||
878
+ typeof value.email !== "string" ||
879
+ typeof value.emailVerified !== "boolean" ||
880
+ !Array.isArray(value.scopes) ||
881
+ value.scopes.some((scope) => !["cli:read", "cli:write"].includes(scope))
882
+ ) {
883
+ throw new CliError("The account gateway returned an invalid identity response.", {
884
+ exitCode: 3,
885
+ code: "invalid_response",
886
+ });
887
+ }
888
+ return {
889
+ authenticated: true,
890
+ principalId: value.principalId,
891
+ email: value.email,
892
+ emailVerified: value.emailVerified,
893
+ scopes: value.scopes,
894
+ expiresAt:
895
+ typeof value.expiresAt === "string" ? value.expiresAt : undefined,
896
+ };
897
+ }
898
+
899
+ async function handleAccountLogin(options, context, stateDirectory) {
900
+ const readOnlyValue = options["read-only"];
901
+ if (readOnlyValue !== undefined && typeof readOnlyValue !== "boolean") {
902
+ throw new CliError("--read-only does not take a value.", { exitCode: 2 });
903
+ }
904
+ if (readOnlyValue === false) {
905
+ throw new CliError("Use --read-only to request a read-only session.", {
906
+ exitCode: 2,
907
+ });
908
+ }
909
+ if (options.browser === true) {
910
+ throw new CliError("Browser launch is automatic; use --no-browser to disable it.", {
911
+ exitCode: 2,
912
+ });
913
+ }
914
+ const account = accountSessionContext(
915
+ accountOptions(options, context, stateDirectory),
916
+ );
917
+ const scopes = readOnlyValue ? ["cli:read"] : ["cli:read", "cli:write"];
918
+ const controller = new AbortController();
919
+ const cancel = () => controller.abort();
920
+ process.once("SIGINT", cancel);
921
+ process.once("SIGTERM", cancel);
922
+ try {
923
+ const device = await account.auth.begin(scopes, {
924
+ signal: controller.signal,
925
+ });
926
+ writeLine(context.stderr, `Open ${device.verificationUriComplete}`);
927
+ writeLine(context.stderr, `Enter code: ${device.userCode}`);
928
+ if (options.browser !== false) {
929
+ openVerificationPage(device.verificationUriComplete, {
930
+ spawnImpl: context.spawnImpl,
931
+ });
932
+ }
933
+ const session = await account.auth.poll(device, {
934
+ signal: controller.signal,
935
+ });
936
+ const identity = publicAccountIdentity(
937
+ await new AccountGatewayClient({
938
+ accountOrigin: account.accountOrigin,
939
+ accessToken: session.accessToken,
940
+ fetchImpl: context.fetchImpl,
941
+ }).whoami(),
942
+ );
943
+ await account.store.withLock(async () => {
944
+ await account.store.write({
945
+ ...session,
946
+ createdAt: new Date().toISOString(),
947
+ });
948
+ });
949
+ emit(
950
+ context.stdout,
951
+ identity,
952
+ context.json,
953
+ `Authenticated as ${identity.email} (${identity.scopes.join(", ")}).`,
954
+ );
955
+ return 0;
956
+ } finally {
957
+ process.removeListener("SIGINT", cancel);
958
+ process.removeListener("SIGTERM", cancel);
959
+ }
960
+ }
961
+
962
+ async function handleAccountStatus(options, context, stateDirectory) {
963
+ const authenticated = await authenticatedAccountClient(
964
+ accountOptions(options, context, stateDirectory),
965
+ );
966
+ if (!authenticated) {
967
+ emit(
968
+ context.stdout,
969
+ { authenticated: false },
970
+ context.json,
971
+ "Not authenticated. Run warpmetal login.",
972
+ );
973
+ return 0;
974
+ }
975
+ const identity = publicAccountIdentity(await authenticated.client.whoami());
976
+ emit(
977
+ context.stdout,
978
+ identity,
979
+ context.json,
980
+ `Authenticated as ${identity.email} (${identity.scopes.join(", ")}).`,
981
+ );
982
+ return 0;
983
+ }
984
+
985
+ async function requireAccount(options, context, stateDirectory) {
986
+ const account = await authenticatedAccountClient(accountOptions(options, context, stateDirectory));
987
+ if (!account) {
988
+ throw new CliError("Sign in first with warpmetal login; no order details are needed to sign in.", { exitCode: 4 });
989
+ }
990
+ return account;
991
+ }
992
+
993
+ async function handleAccountInventory(kind, options, context, stateDirectory) {
994
+ const account = await requireAccount(options, context, stateDirectory);
995
+ const id = stringOption(options, kind === "orders" ? "task" : "server");
996
+ const data = kind === "orders"
997
+ ? await (id ? account.client.order(id) : account.client.orders())
998
+ : await (id ? account.client.device(id) : account.client.devices());
999
+ emit(context.stdout, data, context.json, JSON.stringify(data, null, 2));
1000
+ return 0;
1001
+ }
1002
+
1003
+ async function handleAccountLogout(options, context, stateDirectory) {
1004
+ const account = accountSessionContext(
1005
+ accountOptions(options, context, stateDirectory),
1006
+ );
1007
+ const outcome = await account.store.withLock(async () => {
1008
+ const session = await account.store.read();
1009
+ if (!session) {
1010
+ return {
1011
+ authenticated: false,
1012
+ localSessionCleared: false,
1013
+ remoteRevoked: false,
1014
+ remoteStatus: "no_session",
1015
+ };
1016
+ }
1017
+ let remoteRevoked = false;
1018
+ let remoteError;
1019
+ try {
1020
+ await account.auth.revoke(session.refreshToken);
1021
+ remoteRevoked = true;
1022
+ } catch (error) {
1023
+ remoteError = error?.code || "unavailable";
1024
+ } finally {
1025
+ await account.store.clear();
1026
+ }
1027
+ return {
1028
+ authenticated: false,
1029
+ localSessionCleared: true,
1030
+ remoteRevoked,
1031
+ remoteStatus: remoteRevoked ? "revoked" : "failed",
1032
+ remoteError,
1033
+ };
1034
+ });
1035
+ emit(
1036
+ context.stdout,
1037
+ outcome,
1038
+ context.json,
1039
+ outcome.remoteRevoked
1040
+ ? "Logged out and revoked the remote session."
1041
+ : outcome.remoteStatus === "no_session"
1042
+ ? "No account session is stored for these origins."
1043
+ : "Local session cleared, but remote revocation could not be confirmed.",
1044
+ );
1045
+ return outcome.remoteStatus === "failed" ? 3 : 0;
1046
+ }
1047
+
830
1048
  async function pathExists(path) {
831
1049
  try {
832
1050
  await stat(path);
@@ -898,6 +1116,20 @@ async function resolveServerIdentity(store, serverId, explicit) {
898
1116
  }
899
1117
 
900
1118
  async function handlePrepareOrder(client, store, options, context) {
1119
+ const accountMode = booleanOption(options, "account");
1120
+ const account = accountMode ? await requireAccount(options, context, store.directory) : null;
1121
+ const identity = account ? publicAccountIdentity(await account.client.whoami()) : null;
1122
+ if (account && !account.session.scopes.includes("cli:write")) {
1123
+ throw new CliError("This account session is read-only. Run warpmetal login to request order preparation.", { exitCode: 4 });
1124
+ }
1125
+ const withoutBoxes = booleanOption(options, "without-agent-boxes");
1126
+ const withoutTeam = booleanOption(options, "without-team");
1127
+ if (!account && (withoutBoxes || withoutTeam || options["identity-url"] || options["account-url"])) {
1128
+ throw new CliError("Account options require --account. Existing owner-token preparation is unchanged.", { exitCode: 2 });
1129
+ }
1130
+ if (withoutBoxes && withoutTeam) {
1131
+ throw new CliError("Use only one of --without-agent-boxes or --without-team.", { exitCode: 2 });
1132
+ }
901
1133
  const planId = stringOption(options, "plan", { required: true });
902
1134
  const hostname = stringOption(options, "hostname", { required: true });
903
1135
  const osName = stringOption(options, "os", { required: true });
@@ -910,10 +1142,16 @@ async function handlePrepareOrder(client, store, options, context) {
910
1142
  );
911
1143
  }
912
1144
  const email = stringOption(options, "email");
1145
+ if (account && email) throw new CliError("Account contact comes from your signed-in account; omit --email.", { exitCode: 2 });
913
1146
  const runtimeFile = stringOption(options, "runtime-file");
1147
+ if (runtimeFile && withoutBoxes) throw new CliError("--runtime-file conflicts with --without-agent-boxes.", { exitCode: 2 });
1148
+ if (account && !runtimeFile && !withoutBoxes && !withoutTeam) {
1149
+ throw new CliError("Agent Boxes and Agent team are enabled by default. Select your team in --runtime-file, or use --without-team / --without-agent-boxes.", { exitCode: 2 });
1150
+ }
914
1151
  const runtimeIntent = runtimeFile
915
1152
  ? await readSandboxFile(runtimeFile)
916
- : undefined;
1153
+ : account && withoutTeam ? [{ name: "main", size: "small" }] : undefined;
1154
+ if (withoutTeam && runtimeIntent?.teams) throw new CliError("--without-team conflicts with teams in --runtime-file.", { exitCode: 2 });
917
1155
  const sandboxes = Array.isArray(runtimeIntent)
918
1156
  ? runtimeIntent
919
1157
  : runtimeIntent?.sandboxes;
@@ -969,14 +1207,22 @@ async function handlePrepareOrder(client, store, options, context) {
969
1207
  }
970
1208
  const key =
971
1209
  stringOption(options, "idempotency-key") || idempotencyKey("order");
972
- const response = await client.prepareOrder(request, key);
1210
+ const response = account
1211
+ ? { data: await account.client.prepareOrder(request, key) }
1212
+ : await client.prepareOrder(request, key);
973
1213
  const checkoutBody = JSON.stringify({ taskId: response.data.task.id });
974
1214
  await store.savePreparedOrder(
975
1215
  response.data,
976
1216
  checkoutBody,
977
1217
  selected.identity.identityId,
1218
+ account ? { principalId: identity.principalId, accountOrigin: account.context.accountOrigin, identityOrigin: account.context.identityOrigin } : null,
978
1219
  );
979
1220
  const safe = safePreparedOrder(response.data, store.path);
1221
+ if (account) {
1222
+ safe.ownershipMode = "account";
1223
+ safe.credential = { kind: "account_session", stored: true, printed: false };
1224
+ safe.stateFile = store.path;
1225
+ }
980
1226
  if (runtimeIntent) {
981
1227
  safe.agentRuntimeIntent = {
982
1228
  sandboxes: sandboxes.map((sandbox) => ({
@@ -988,13 +1234,16 @@ async function handlePrepareOrder(client, store, options, context) {
988
1234
  };
989
1235
  if (!Array.isArray(runtimeIntent)) {
990
1236
  safe.agentRuntimeIntent.setup = runtimeIntent.setup;
1237
+ safe.agentRuntimeIntent.teams = runtimeIntent.teams;
991
1238
  }
992
1239
  }
993
1240
  emit(
994
1241
  context.stdout,
995
1242
  safe,
996
1243
  context.json,
997
- `Prepared ${safe.task.id} for server ${safe.task.serverId}.\nRecovery credential saved to ${store.path} and not printed.`,
1244
+ account
1245
+ ? `Prepared ${safe.task.id} for your account. Ownership recorded in ${store.path}. No owner token was issued. Use warpmetal account orders --task ${safe.task.id} to view it.`
1246
+ : `Prepared ${safe.task.id} for server ${safe.task.serverId}.\nRecovery credential saved to ${store.path} and not printed.`,
998
1247
  );
999
1248
  return 0;
1000
1249
  }
@@ -2431,7 +2680,13 @@ async function resolveCreateSandboxes(options) {
2431
2680
  exitCode: 2,
2432
2681
  });
2433
2682
  }
2434
- if (file) return readSandboxFile(file);
2683
+ if (file) {
2684
+ const intent = await readSandboxFile(file);
2685
+ if (!Array.isArray(intent)) {
2686
+ throw new CliError("Team and setup intents are supported only during order preparation; sandbox create accepts sandboxes only.", { exitCode: 2 });
2687
+ }
2688
+ return intent;
2689
+ }
2435
2690
  return sandboxFromOptions({
2436
2691
  name: stringOption(options, "name", { required: true }),
2437
2692
  size: stringOption(options, "size", { required: true }),
@@ -3068,10 +3323,46 @@ async function dispatch(positionals, options, passthrough, context) {
3068
3323
  return handleAccessRemoveSsh(options, context);
3069
3324
  }
3070
3325
 
3071
- const baseUrl = stringOption(options, "base-url");
3072
3326
  const stateDir =
3073
3327
  stringOption(options, "state-dir") ||
3074
3328
  resolveStateDirectory({ env: context.env });
3329
+ if (command === "login") {
3330
+ rejectUnknownOptions(options, [
3331
+ "base-url",
3332
+ "json",
3333
+ "help",
3334
+ "state-dir",
3335
+ "identity-url",
3336
+ "account-url",
3337
+ "browser",
3338
+ "read-only",
3339
+ ]);
3340
+ return handleAccountLogin(options, context, stateDir);
3341
+ }
3342
+ if (command === "auth status") {
3343
+ rejectUnknownOptions(options, [
3344
+ "base-url",
3345
+ "json",
3346
+ "help",
3347
+ "state-dir",
3348
+ "identity-url",
3349
+ "account-url",
3350
+ ]);
3351
+ return handleAccountStatus(options, context, stateDir);
3352
+ }
3353
+ if (command === "logout") {
3354
+ rejectUnknownOptions(options, [
3355
+ "base-url",
3356
+ "json",
3357
+ "help",
3358
+ "state-dir",
3359
+ "identity-url",
3360
+ "account-url",
3361
+ ]);
3362
+ return handleAccountLogout(options, context, stateDir);
3363
+ }
3364
+
3365
+ const baseUrl = stringOption(options, "base-url");
3075
3366
  const client = new WarpMetalClient({
3076
3367
  baseUrl: baseUrl || context.env.WARPMETAL_API_URL,
3077
3368
  fetchImpl: context.fetchImpl,
@@ -3085,9 +3376,21 @@ async function dispatch(positionals, options, passthrough, context) {
3085
3376
  case "catalog":
3086
3377
  rejectUnknownOptions(options, [...COMMON_OPTIONS, "plan"]);
3087
3378
  return handleCatalog(client, options, context);
3379
+ case "account orders":
3380
+ case "account devices":
3381
+ rejectUnknownOptions(options, [...COMMON_OPTIONS, "identity-url", "account-url", command === "account orders" ? "task" : "server"]);
3382
+ return handleAccountInventory(command === "account orders" ? "orders" : "devices", options, context, stateDir);
3383
+ case "models":
3384
+ rejectUnknownOptions(options, [...COMMON_OPTIONS, "provider", "auth-mode"]);
3385
+ return handleModels(client, options, context);
3088
3386
  case "order prepare":
3089
3387
  rejectUnknownOptions(options, [
3090
3388
  ...COMMON_OPTIONS,
3389
+ "account",
3390
+ "identity-url",
3391
+ "account-url",
3392
+ "without-agent-boxes",
3393
+ "without-team",
3091
3394
  "plan",
3092
3395
  "hostname",
3093
3396
  "os",