@maxceem/agw 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +17 -2
  2. package/dist/agw.mjs +742 -143
  3. package/package.json +2 -2
package/dist/agw.mjs CHANGED
@@ -6,7 +6,7 @@ var __export = (target, all) => {
6
6
  };
7
7
 
8
8
  // <define:__AGW_MANIFEST__>
9
- var define_AGW_MANIFEST_default = { version: "0.4.0", wrangler: "4.96.0", upgradeFrom: ["0.4.0"], release: { url: "https://github.com/maxceem/app-ai-gateway/releases/download/v0.4.0/gateway-0.4.0.tar.gz", sha256: "82e5cea16db3e01d3058bf966c3ea634adf8565658de1f4cddbe02997b2402d9", manifest: "56646f283de6e8683194e48f70a113f69120a35d724b7e0f1b5e4df92473c0cc" } };
9
+ var define_AGW_MANIFEST_default = { version: "0.5.0", wrangler: "4.96.0", upgradeFrom: ["0.5.0", "0.4.0"], release: { url: "https://github.com/maxceem/app-ai-gateway/releases/download/v0.5.0/gateway-0.5.0.tar.gz", sha256: "04aae870521afb7cf669fbd0c7692aae0d551d1b492b954b33618a0fc7bea931", manifest: "69ffad488e129f5487a24d48b0aaead9c4f247200b79336c88dc5e4c49c63b27" } };
10
10
 
11
11
  // src/main.ts
12
12
  import { Writable as Writable2 } from "node:stream";
@@ -14561,19 +14561,19 @@ function hasUnsafeCharacter(raw) {
14561
14561
  function reject(message) {
14562
14562
  return { ok: false, message };
14563
14563
  }
14564
- function hostProblem(hostname5) {
14565
- if (hostname5.startsWith("[")) {
14564
+ function hostProblem(hostname6) {
14565
+ if (hostname6.startsWith("[")) {
14566
14566
  return "Base URL must name a host, not an IPv6 address";
14567
14567
  }
14568
- if (IPV4_PATTERN.test(hostname5) || NUMERIC_HOST_PATTERN.test(hostname5)) {
14568
+ if (IPV4_PATTERN.test(hostname6) || NUMERIC_HOST_PATTERN.test(hostname6)) {
14569
14569
  return "Base URL must name a host, not an IP address";
14570
14570
  }
14571
- if (hostname5.endsWith(".")) {
14571
+ if (hostname6.endsWith(".")) {
14572
14572
  return "Base URL host must not end with a dot";
14573
14573
  }
14574
- const labels = hostname5.split(".");
14574
+ const labels = hostname6.split(".");
14575
14575
  if (labels.length < 2) {
14576
- return `Base URL host must be a public domain name; ${hostname5} has no domain suffix`;
14576
+ return `Base URL host must be a public domain name; ${hostname6} has no domain suffix`;
14577
14577
  }
14578
14578
  for (const label of labels) {
14579
14579
  if (!LABEL_PATTERN.test(label)) {
@@ -15512,6 +15512,7 @@ var RELEASE = manifest.release ?? null;
15512
15512
 
15513
15513
  // src/common.ts
15514
15514
  var CLOUD = "https://api.appaigateway.com";
15515
+ var MANAGEMENT_KEY_ENV = "AGW_MANAGEMENT_KEY";
15515
15516
  var CliError = class extends Error {
15516
15517
  code;
15517
15518
  nextAction;
@@ -15657,7 +15658,7 @@ async function confirm(message, flags) {
15657
15658
  // src/parser.ts
15658
15659
  var secret2 = "key-prompt! key-stdin! browser! no-open!";
15659
15660
  var browser = "no-open!";
15660
- var managementKey = "key-prompt! key-stdin!";
15661
+ var managementKey = "key-prompt! key-stdin! no-open!";
15661
15662
  var common = "json! no-input!";
15662
15663
  var commands = {
15663
15664
  "deployment setup": {
@@ -15815,7 +15816,7 @@ function parse3(argv) {
15815
15816
  }
15816
15817
  if (flags["key-prompt"] && flags["no-input"])
15817
15818
  fail("conflicting_flags", "--key-prompt cannot be used with --no-input.");
15818
- if (flags["no-open"] && !flags.browser && command !== "account claim")
15819
+ if (flags["no-open"] && !flags.browser && command !== "account claim" && command !== "account login" && command !== "deployment connect")
15819
15820
  fail("invalid_input", "--no-open requires --browser.");
15820
15821
  if (flags["no-open"] && (flags["key-prompt"] || flags["key-stdin"]))
15821
15822
  fail(
@@ -15841,8 +15842,10 @@ ${matches.map(
15841
15842
  )}
15842
15843
 
15843
15844
  --json: structured stdout; --no-input: never prompt; --help, -h: this help.
15845
+ Login: account login and deployment connect open the browser; check that it shows the pairing code the terminal prints. --no-open prints the URL instead; --key-stdin or --key-prompt takes a management key.
15846
+ Credentials: ${MANAGEMENT_KEY_ENV}, when set, is the management key for every command and overrides the saved login. account logout revokes the saved key.
15844
15847
  Secrets: hidden prompt by default; --key-stdin reads one line. Provider --browser submits credentials directly to your gateway.
15845
- Browser operations: --no-open, --json or --no-input returns pending; operation wait resumes.
15848
+ Browser operations: --json or --no-input returns pending; operation wait resumes.
15846
15849
  No account is created by help, status, list, validation or dry runs.
15847
15850
  `;
15848
15851
  }
@@ -16985,7 +16988,7 @@ function attestEnvironments(value) {
16985
16988
  return value.split(",").map((name) => name.trim()).filter((name) => name.length > 0);
16986
16989
  }
16987
16990
  async function remoteValidation(ctx, doc, id) {
16988
- if (!ctx.active?.credential)
16991
+ if (!ctx.managementKey)
16989
16992
  return {
16990
16993
  local: true,
16991
16994
  remote: false,
@@ -17601,7 +17604,14 @@ var ManagementKeySummarySchema = external_exports.object({
17601
17604
  }),
17602
17605
  expiresAt: external_exports.string().nullable(),
17603
17606
  createdAt: external_exports.string(),
17604
- revokedAt: external_exports.string().nullable()
17607
+ revokedAt: external_exports.string().nullable(),
17608
+ source: external_exports.string().meta({
17609
+ description: "Where the key was issued: `console`, `cli` for a key a CLI login received, or `bootstrap` for the key a CLI account starts with. Display only.",
17610
+ example: "cli"
17611
+ }),
17612
+ label: external_exports.string().nullable().meta({
17613
+ description: "Who holds it, as the issuing client described itself, e.g. `CLI on mac-studio`. Display only."
17614
+ })
17605
17615
  });
17606
17616
  var ManagementKeyListResponseSchema = external_exports.object({
17607
17617
  keys: external_exports.array(ManagementKeySummarySchema)
@@ -17940,8 +17950,11 @@ var PricesResponseSchema = external_exports.object({
17940
17950
  // ../src/contracts/cli.ts
17941
17951
  var CliProofSchema = external_exports.string().regex(/^[A-Za-z0-9_-]{32,256}$/);
17942
17952
  var CliBootstrapRequestSchema = external_exports.object({ token: CliProofSchema }).strict();
17953
+ var CliUserCodeSchema = external_exports.string().regex(/^[A-Za-z0-9]{4}-?[A-Za-z0-9]{4}$/);
17954
+ var CliSubmissionTokenSchema = external_exports.string().regex(/^(?:[A-Za-z0-9_-]{32,256}|[A-Za-z0-9]{4}-?[A-Za-z0-9]{4})$/);
17943
17955
  var CliOperationKindSchema = external_exports.enum([
17944
17956
  "bootstrap",
17957
+ "login",
17945
17958
  "claim",
17946
17959
  "app.add",
17947
17960
  "app.key.add",
@@ -17970,15 +17983,16 @@ var CliOperationRequestSchema = external_exports.discriminatedUnion("kind", [
17970
17983
  ),
17971
17984
  operationRequest("provider-gateway.rotate-key", HandoffRotatePayloadSchema, "always")
17972
17985
  ]);
17973
- var CliBrowserProofSchema = external_exports.object({ submissionToken: CliProofSchema }).strict();
17986
+ var CliBrowserProofSchema = external_exports.object({ submissionToken: CliSubmissionTokenSchema }).strict();
17974
17987
  var CliBrowserSubmitRequestSchema = external_exports.object({
17975
- submissionToken: CliProofSchema,
17988
+ submissionToken: CliSubmissionTokenSchema,
17976
17989
  approve: external_exports.literal(true),
17977
17990
  // Flag-free, because the source is published verbatim as an OpenAPI `pattern`.
17978
- secret: external_exports.string().max(16384).regex(/\S/, { error: "must not be blank" }).optional()
17991
+ secret: external_exports.string().max(16384).regex(/\S/, { error: "must not be blank" }).optional(),
17992
+ organizationId: external_exports.string().min(1).max(256).optional()
17979
17993
  }).strict();
17980
17994
  var CliBrowserRegisterRequestSchema = external_exports.object({
17981
- submissionToken: CliProofSchema,
17995
+ submissionToken: CliSubmissionTokenSchema,
17982
17996
  email: external_exports.email(),
17983
17997
  password: external_exports.string().min(8).max(256),
17984
17998
  name: external_exports.string().min(1).max(100)
@@ -17991,35 +18005,89 @@ var CliDeploymentSchema = external_exports.object({
17991
18005
  });
17992
18006
  var CliApprovalRefusalSchema = external_exports.enum([
17993
18007
  "registration_required",
17994
- "sign_out_required"
18008
+ "sign_out_required",
18009
+ /**
18010
+ * A login's approver belongs to no account they could log the CLI in to, and
18011
+ * this deployment does not give a person one of their own. Nothing on the
18012
+ * page can fix that; an owner has to add them to an account first.
18013
+ */
18014
+ "no_eligible_organization"
17995
18015
  ]);
17996
18016
  var CliViewerSchema = external_exports.object({
17997
18017
  name: external_exports.string().nullable(),
17998
18018
  email: external_exports.string().nullable()
17999
18019
  });
18020
+ var CliOperationClientSchema = external_exports.object({
18021
+ /** E.g. `CLI on mac-studio`. */
18022
+ label: external_exports.string().nullable(),
18023
+ os: external_exports.string().nullable(),
18024
+ /** The address the request came from, as the edge saw it. */
18025
+ ip: external_exports.string().nullable(),
18026
+ requestedAt: external_exports.string()
18027
+ });
18028
+ var CliLoginOrganizationSchema = external_exports.object({
18029
+ id: external_exports.string(),
18030
+ name: external_exports.string(),
18031
+ role: external_exports.enum(["owner", "admin", "member"])
18032
+ });
18000
18033
  var CliBrowserDetailsResponseSchema = external_exports.object({
18001
18034
  kind: CliOperationKindSchema,
18002
18035
  payload: external_exports.record(external_exports.string(), external_exports.unknown()),
18003
- account: OrganizationSummarySchema,
18036
+ /** Null only for a login, which lands in whichever account its approver picks. */
18037
+ account: OrganizationSummarySchema.nullable(),
18004
18038
  viewer: CliViewerSchema.nullable(),
18005
18039
  /**
18006
18040
  * What stands between this browser and the Approve button, or null when
18007
- * nothing does — which is every non-claim kind, since only a claim asks who
18008
- * is holding the browser. One field decides the whole screen: each value
18041
+ * nothing does — which is every kind but a claim and a login, since only
18042
+ * those two ask who is holding the browser. One field decides the whole screen: each value
18009
18043
  * names the single thing the page may offer, so the page never has to
18010
18044
  * consult the handoff kind to know what to put in front of a person.
18011
18045
  */
18012
18046
  blockedBy: CliApprovalRefusalSchema.nullable(),
18013
18047
  googleEnabled: external_exports.boolean(),
18014
- expiresAt: external_exports.string()
18048
+ expiresAt: external_exports.string(),
18049
+ /**
18050
+ * The code the terminal shows, however the page was reached, so a person
18051
+ * can check it against their terminal before approving. Null for a kind
18052
+ * that has none — every kind but a login.
18053
+ */
18054
+ userCode: external_exports.string().nullable(),
18055
+ /** Who asked, for a login; null for every other kind. */
18056
+ client: CliOperationClientSchema.nullable(),
18057
+ /** Whether approving hands the browser back to the CLI's local listener (`redirectUrl`). */
18058
+ hasLoopbackRedirect: external_exports.boolean(),
18059
+ /**
18060
+ * For a login only: the accounts the signed-in person may log the CLI in to,
18061
+ * so the page can ask which one when there is more than one.
18062
+ */
18063
+ organizations: external_exports.array(CliLoginOrganizationSchema).optional()
18015
18064
  });
18016
18065
  var CliHandoffContinuationSchema = external_exports.enum(["cli", "console"]);
18017
18066
  var CliBrowserSubmitResponseSchema = external_exports.object({
18018
18067
  state: external_exports.literal("completed"),
18019
18068
  /** The whole of what the page says once it is approved. */
18020
18069
  message: external_exports.string(),
18021
- continueTo: CliHandoffContinuationSchema
18070
+ continueTo: CliHandoffContinuationSchema,
18071
+ /**
18072
+ * Set when the CLI registered a local listener: the page navigates here, and
18073
+ * the one-time code in it is what the CLI redeems for its credential.
18074
+ */
18075
+ redirectUrl: external_exports.url().optional()
18076
+ });
18077
+ var CliBrowserDenyResponseSchema = external_exports.object({
18078
+ state: external_exports.literal("denied"),
18079
+ message: external_exports.string()
18022
18080
  });
18081
+ var CliBrowserLookupRequestSchema = external_exports.object({ userCode: external_exports.string().min(1).max(32) }).strict();
18082
+ var CliBrowserLookupResponseSchema = external_exports.discriminatedUnion("found", [
18083
+ external_exports.object({ found: external_exports.literal(false) }),
18084
+ external_exports.object({
18085
+ found: external_exports.literal(true),
18086
+ id: external_exports.string(),
18087
+ kind: CliOperationKindSchema,
18088
+ expiresAt: external_exports.string()
18089
+ })
18090
+ ]);
18023
18091
  var CliBrowserRegisterResponseSchema = external_exports.object({
18024
18092
  token: external_exports.string().nullable().optional(),
18025
18093
  redirect: external_exports.boolean().optional()
@@ -18031,16 +18099,43 @@ var CliBrowserGoogleResponseSchema = external_exports.object({
18031
18099
  var CliCredentialSchema = external_exports.object({
18032
18100
  token: external_exports.string()
18033
18101
  });
18102
+ var CliLoginRequestSchema = external_exports.object({
18103
+ token: CliProofSchema,
18104
+ client: external_exports.object({
18105
+ label: external_exports.string().trim().min(1).max(200),
18106
+ os: external_exports.string().trim().min(1).max(64).optional()
18107
+ }).strict(),
18108
+ // Ports 1–65535, spelled out because the source is published as an
18109
+ // OpenAPI `pattern` and has no range syntax.
18110
+ loopbackRedirect: external_exports.string().regex(
18111
+ /^http:\/\/127\.0\.0\.1:(?:[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])\/callback$/
18112
+ ).optional()
18113
+ }).strict();
18114
+ var CliLoginSchema = external_exports.object({
18115
+ id: external_exports.string(),
18116
+ kind: external_exports.literal("login"),
18117
+ /** `completed` when a login already approved is sent again: poll it, or redeem it, for the key. */
18118
+ state: external_exports.enum(["pending", "completed", "denied", "expired"]),
18119
+ url: external_exports.url().nullable(),
18120
+ userCode: external_exports.string().nullable(),
18121
+ expiresAt: external_exports.string()
18122
+ });
18123
+ var CliLoginRedeemRequestSchema = external_exports.object({ redeemCode: CliProofSchema }).strict();
18124
+ var CliLoginRedeemResponseSchema = external_exports.object({
18125
+ credential: CliCredentialSchema,
18126
+ account: OrganizationSummarySchema
18127
+ });
18128
+ var CliCredentialRevokeResponseSchema = external_exports.object({ revoked: external_exports.literal(true) });
18034
18129
  var UnclaimedAccessSchema = external_exports.object({ endsAt: external_exports.string(), limit: external_exports.number().int().optional() }).nullable();
18035
18130
  var CliOperationResultSchema = external_exports.object({
18036
- /** A bootstrap's management key. Never print or log it. */
18131
+ /** A bootstrap's or a login's management key. Never print or log it. */
18037
18132
  credential: CliCredentialSchema.optional(),
18038
18133
  /**
18039
18134
  * On a hosted deployment, when a bootstrapped account's free access ends and
18040
18135
  * the request allowance it has until then; null on a self-host.
18041
18136
  */
18042
18137
  unclaimedAccess: UnclaimedAccessSchema.optional(),
18043
- /** The account a claim acted on. */
18138
+ /** The account a claim acted on, or a login landed in. */
18044
18139
  accountId: external_exports.string().optional(),
18045
18140
  app: AppResponseSchema.shape.app.optional(),
18046
18141
  /**
@@ -18055,6 +18150,8 @@ var CliOperationSchema = external_exports.object({
18055
18150
  id: external_exports.string(),
18056
18151
  kind: CliOperationKindSchema,
18057
18152
  state: external_exports.enum(["pending", "completed", "expired"]),
18153
+ /** Set beside `state: "expired"` when a person declined the browser step rather than letting it lapse. */
18154
+ denied: external_exports.literal(true).optional(),
18058
18155
  expiresAt: external_exports.string(),
18059
18156
  url: external_exports.url().optional(),
18060
18157
  deployment: CliDeploymentSchema,
@@ -18092,7 +18189,12 @@ var CliCapabilitiesResponseSchema = external_exports.object({
18092
18189
  endpointStyles: external_exports.array(external_exports.string())
18093
18190
  })
18094
18191
  ),
18095
- providerGateways: external_exports.array(external_exports.object({ type: external_exports.string(), name: external_exports.string() }))
18192
+ providerGateways: external_exports.array(external_exports.object({ type: external_exports.string(), name: external_exports.string() })),
18193
+ /** What this deployment supports beyond protocol 1's baseline. Absent on older deployments. */
18194
+ features: external_exports.object({
18195
+ /** `POST /v1/cli/login`: a person approves a CLI in a browser and it receives a key of its own. */
18196
+ browserLogin: external_exports.boolean()
18197
+ }).optional()
18096
18198
  });
18097
18199
  var [SelfHostedAccessSchema, UnavailableAccessSchema, BilledAccessSchema] = GatewayBillingAccessSchema.options;
18098
18200
  var CliBillingAccessSchema = external_exports.discriminatedUnion("state", [
@@ -18131,7 +18233,7 @@ var CLI_ERRORS = {
18131
18233
  410: "The protected credential exchange has expired; no replacement is minted.",
18132
18234
  429: "The durable initiation or submission rate limit was reached."
18133
18235
  };
18134
- var BROWSER_HANDOFF_DESCRIPTION = "First-party browser only: both the request URL origin and exact Origin header must match consoleOrigin; a separate submissionToken is required. Identity approval also requires an interactive human session; registration is limited to a valid pending claim. Provider secret values are write-only.";
18236
+ var BROWSER_HANDOFF_DESCRIPTION = "First-party browser only: both the request URL origin and exact Origin header must match consoleOrigin; a separate submissionToken is required \u2014 the proof from the approval URL's fragment, or, for a login, the user code the terminal shows. Identity approval (a claim or a login) also requires an interactive human session; registration is limited to a valid pending claim or login. Provider secret values are write-only.";
18135
18237
  var SignUpRequestSchema = external_exports.object({
18136
18238
  name: external_exports.string(),
18137
18239
  email: external_exports.email(),
@@ -18954,12 +19056,51 @@ var CATALOG = {
18954
19056
  responseDescription: "Where the operation stands: completed with its result, or pending with the approval URL.",
18955
19057
  errors: CLI_ERRORS
18956
19058
  },
19059
+ openCliLogin: {
19060
+ method: "POST",
19061
+ path: "/v1/cli/login",
19062
+ tags: ["CLI"],
19063
+ summary: "Ask a person to log this CLI in",
19064
+ description: "Persist the random token before sending: its digest is the operation's id, and sending it again answers with where the login stands \u2014 `completed` once approved, when polling or redeeming collects the key \u2014 and never hands the key over itself. Needs no credential. Open `url` in a browser, or show `userCode` for the person to type on the console's code-entry page; once a signed-in person approves, the CLI receives a management key of its own in the account they chose. Without `loopbackRedirect`, poll `pollCliOperation` until it completes: the first completed answer carries the key in `result.credential`, and no later one does. With `loopbackRedirect`, the approval page sends the browser there with a one-time `code`, which `redeemCliLogin` exchanges for the key; polling then never returns it. A login expires after 15 minutes, and its record is kept for a day. Each network address may ask for 20 logins an hour \u2014 sending the same token again is not counted \u2014 and may have 10 waiting at once.",
19065
+ security: "public",
19066
+ request: CliLoginRequestSchema,
19067
+ response: CliLoginSchema,
19068
+ responseDescription: "The pending login, its approval URL and its user code.",
19069
+ errors: CLI_ERRORS
19070
+ },
19071
+ redeemCliLogin: {
19072
+ method: "POST",
19073
+ path: "/v1/cli/login/{id}/redeem",
19074
+ tags: ["CLI"],
19075
+ summary: "Exchange a login's redeem code for its credential",
19076
+ description: "For a login opened with `loopbackRedirect`: send the operation token as the bearer credential and the `code` the approval page delivered to the local listener. Answers once; a second redemption is refused with `already_completed`. Never print or log the credential.",
19077
+ security: "cliPoll",
19078
+ params: { id: {} },
19079
+ request: CliLoginRedeemRequestSchema,
19080
+ response: CliLoginRedeemResponseSchema,
19081
+ responseDescription: "The management key the login issued, and the account it belongs to.",
19082
+ errors: CLI_ERRORS
19083
+ },
19084
+ revokeCliCredential: {
19085
+ method: "DELETE",
19086
+ path: "/v1/cli/credential",
19087
+ tags: ["CLI"],
19088
+ summary: "Revoke the management key this request is sent with",
19089
+ description: "Logging a CLI out. Any management key may end itself, whatever its role and whatever the account's standing; a browser session cannot use this and signs out instead.",
19090
+ security: "management",
19091
+ // Holding a key is authority enough to end it: a member may, and an
19092
+ // account past its free window may.
19093
+ policy: { role: "member", access: "read" },
19094
+ response: CliCredentialRevokeResponseSchema,
19095
+ responseDescription: "The key is revoked; the next request made with it is refused.",
19096
+ errors: CLI_ERRORS
19097
+ },
18957
19098
  pollCliOperation: {
18958
19099
  method: "GET",
18959
19100
  path: "/v1/cli/operations/{id}",
18960
19101
  tags: ["CLI"],
18961
19102
  summary: "Poll a CLI operation",
18962
- description: "Only the operation's token can read it. Completed claims report the account they landed on; the CLI keeps the access it already had. Provider secrets are never returned; a one-time key an operation created is, for 15 minutes.",
19103
+ description: "Only the operation's token can read it. Completed claims report the account they landed on; the CLI keeps the access it already had. Provider secrets are never returned; a one-time key an operation created is, for 15 minutes. A login's key is returned once, by the first completed answer, and never when the login registered a loopback redirect. A step a person declined answers `expired` with `denied: true`.",
18963
19104
  security: "cliPoll",
18964
19105
  params: { id: {} },
18965
19106
  response: CliOperationSchema,
@@ -19020,7 +19161,32 @@ var CATALOG = {
19020
19161
  params: { id: {} },
19021
19162
  request: CliBrowserSubmitRequestSchema,
19022
19163
  response: CliBrowserSubmitResponseSchema,
19023
- responseDescription: "The handoff is approved and consumed. No submitted secret is ever echoed.",
19164
+ responseDescription: "The handoff is approved and consumed. No submitted secret is ever echoed. For a login with a loopback redirect, `redirectUrl` is where to send the browser next.",
19165
+ errors: CLI_ERRORS
19166
+ },
19167
+ cliBrowserDeny: {
19168
+ method: "POST",
19169
+ path: "/v1/cli/browser/{id}/deny",
19170
+ tags: ["CLI"],
19171
+ summary: "Browser handoff: deny",
19172
+ description: `${BROWSER_HANDOFF_DESCRIPTION} Anyone holding the link or the code may decline, signed in or not.`,
19173
+ security: "public",
19174
+ params: { id: {} },
19175
+ request: CliBrowserProofSchema,
19176
+ response: CliBrowserDenyResponseSchema,
19177
+ responseDescription: "The handoff is declined; the CLI's next poll reports it. Repeating a denial answers the same.",
19178
+ errors: CLI_ERRORS
19179
+ },
19180
+ cliBrowserLookup: {
19181
+ method: "POST",
19182
+ path: "/v1/cli/browser/lookup",
19183
+ tags: ["CLI"],
19184
+ summary: "Browser handoff: find a step by its user code",
19185
+ description: "First-party browser only, like the other handoff steps. Case and dashes are ignored. Continue with the code as `submissionToken` on the step it names. Rate limited per network address, because a code is short enough to guess in bulk.",
19186
+ security: "public",
19187
+ request: CliBrowserLookupRequestSchema,
19188
+ response: CliBrowserLookupResponseSchema,
19189
+ responseDescription: "Whether a pending step answers to the code, and which one.",
19024
19190
  errors: CLI_ERRORS
19025
19191
  },
19026
19192
  cliBrowserRegister: {
@@ -19033,7 +19199,7 @@ var CATALOG = {
19033
19199
  params: { id: {} },
19034
19200
  request: CliBrowserRegisterRequestSchema,
19035
19201
  response: CliBrowserRegisterResponseSchema,
19036
- responseDescription: "A new human identity for a pending claim, with its session set as a cookie.",
19202
+ responseDescription: "A new human identity for a pending claim or login, with its session set as a cookie.",
19037
19203
  errors: CLI_ERRORS
19038
19204
  },
19039
19205
  cliBrowserGoogle: {
@@ -19046,7 +19212,7 @@ var CATALOG = {
19046
19212
  params: { id: {} },
19047
19213
  request: CliBrowserProofSchema,
19048
19214
  response: CliBrowserGoogleResponseSchema,
19049
- responseDescription: "Where to send the browser to start Google consent for a pending claim.",
19215
+ responseDescription: "Where to send the browser to start Google consent for a pending claim or login.",
19050
19216
  errors: CLI_ERRORS
19051
19217
  }
19052
19218
  };
@@ -19068,6 +19234,167 @@ function operationPath(name, ...rest) {
19068
19234
  return query === void 0 ? filled : filled + searchSuffix(query);
19069
19235
  }
19070
19236
 
19237
+ // src/login.ts
19238
+ import { createServer } from "node:http";
19239
+ import { hostname as hostname4, platform as platform2, release } from "node:os";
19240
+ var REDEEM_CODE_PARAMETER = "code";
19241
+ var PAGE_HEADERS = {
19242
+ "content-type": "text/html; charset=utf-8",
19243
+ "cache-control": "no-store",
19244
+ "referrer-policy": "no-referrer",
19245
+ "x-content-type-options": "nosniff",
19246
+ connection: "close"
19247
+ };
19248
+ function page(title, text2) {
19249
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title></head><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;line-height:1.5"><h1 style="font-size:1.4rem">${title}</h1><p>${text2}</p></body></html>`;
19250
+ }
19251
+ var CONNECTED_PAGE = page(
19252
+ "Your terminal is connected",
19253
+ "The CLI has its key. You can close this tab."
19254
+ );
19255
+ var FAILED_PAGE = page(
19256
+ "The login did not complete",
19257
+ "Return to your terminal to see what happened."
19258
+ );
19259
+ async function listenLoopback() {
19260
+ let deliver;
19261
+ const code = new Promise((resolve3) => {
19262
+ deliver = resolve3;
19263
+ });
19264
+ let waiting = null;
19265
+ let delivered = false;
19266
+ const server = createServer((request, response) => {
19267
+ const url2 = new URL(request.url ?? "/", "http://127.0.0.1");
19268
+ if (request.method !== "GET" || url2.pathname !== "/callback") {
19269
+ response.writeHead(404, PAGE_HEADERS).end(page("Not found", "Nothing is served here."));
19270
+ return;
19271
+ }
19272
+ const value = url2.searchParams.get(REDEEM_CODE_PARAMETER) ?? "";
19273
+ if (delivered || !CliProofSchema.safeParse(value).success) {
19274
+ response.writeHead(400, PAGE_HEADERS).end(FAILED_PAGE);
19275
+ return;
19276
+ }
19277
+ delivered = true;
19278
+ waiting = response;
19279
+ deliver(value);
19280
+ });
19281
+ await new Promise((resolve3, reject2) => {
19282
+ server.once("error", reject2);
19283
+ server.listen(0, "127.0.0.1", () => {
19284
+ server.off("error", reject2);
19285
+ resolve3();
19286
+ });
19287
+ });
19288
+ const { port } = server.address();
19289
+ let answered = Promise.resolve();
19290
+ const loopback = {
19291
+ redirect: `http://127.0.0.1:${port}/callback`,
19292
+ code,
19293
+ answer(connected) {
19294
+ const response = waiting;
19295
+ if (!response) return;
19296
+ waiting = null;
19297
+ answered = new Promise((resolve3) => {
19298
+ response.once("finish", resolve3);
19299
+ response.once("close", resolve3);
19300
+ });
19301
+ response.writeHead(200, PAGE_HEADERS).end(connected ? CONNECTED_PAGE : FAILED_PAGE);
19302
+ },
19303
+ async close() {
19304
+ loopback.answer(false);
19305
+ await answered;
19306
+ await new Promise((resolve3) => {
19307
+ const force = setTimeout(() => server.closeAllConnections(), 1e3);
19308
+ force.unref();
19309
+ server.close(() => {
19310
+ clearTimeout(force);
19311
+ resolve3();
19312
+ });
19313
+ server.closeIdleConnections();
19314
+ });
19315
+ }
19316
+ };
19317
+ return loopback;
19318
+ }
19319
+ function clientDescription() {
19320
+ return {
19321
+ label: `CLI on ${hostname4() || "this computer"}`.slice(0, 200),
19322
+ os: `${platform2()} ${release()}`.trim().slice(0, 64)
19323
+ };
19324
+ }
19325
+ function browserAvailable({
19326
+ env,
19327
+ platform: system,
19328
+ terminal
19329
+ } = {
19330
+ env: process.env,
19331
+ platform: platform2(),
19332
+ terminal: Boolean(process.stdin.isTTY && process.stderr.isTTY)
19333
+ }) {
19334
+ if (!terminal) return false;
19335
+ if (env["SSH_CONNECTION"] || env["SSH_TTY"]) return false;
19336
+ if (system === "linux" && !env["DISPLAY"] && !env["WAYLAND_DISPLAY"]) return false;
19337
+ return true;
19338
+ }
19339
+
19340
+ // src/style.ts
19341
+ import { Writable } from "node:stream";
19342
+ import { styleText } from "node:util";
19343
+ var STATES = /* @__PURE__ */ new Map([
19344
+ ["active", "green"],
19345
+ ["ready", "green"],
19346
+ ["completed", "green"],
19347
+ ["yes", "green"],
19348
+ ["pending", "yellow"],
19349
+ ["failed", "red"],
19350
+ ["expired", "red"],
19351
+ ["blocked", "red"],
19352
+ ["inactive", "red"],
19353
+ ["disabled", "red"],
19354
+ ["revoked", "red"],
19355
+ ["no", "red"]
19356
+ ]);
19357
+ function colorEnabled(sink, env = process.env) {
19358
+ if (env["NO_COLOR"]) return false;
19359
+ if (!(sink instanceof Writable)) return false;
19360
+ if (env["FORCE_COLOR"] && env["FORCE_COLOR"] !== "0") return true;
19361
+ if (!("isTTY" in sink && sink.isTTY === true)) return false;
19362
+ return styleText("dim", "?", { stream: sink, validateStream: true }) !== "?";
19363
+ }
19364
+ var plain = style(false);
19365
+ var styleFor = (sink) => style(colorEnabled(sink));
19366
+ function style(enabled) {
19367
+ const paint = (format, text2) => enabled ? styleText(format, text2, { validateStream: false }) : text2;
19368
+ const self = {
19369
+ enabled,
19370
+ headline: (text2) => paint("bold", text2),
19371
+ dim: (text2) => paint("dim", text2),
19372
+ alert: (text2) => paint("red", text2),
19373
+ warn: (text2) => paint("yellow", text2),
19374
+ state: (value) => {
19375
+ const tone = STATES.get(value.toLowerCase());
19376
+ return tone ? paint(tone, value) : value;
19377
+ },
19378
+ code: (text2, language) => {
19379
+ const rule = `\u2500\u2500 ${language} \u2500\u2500`;
19380
+ return [
19381
+ self.dim(rule),
19382
+ ...text2.split("\n").map((line) => self.dim(line)),
19383
+ self.dim("\u2500".repeat(rule.length))
19384
+ ];
19385
+ },
19386
+ json: (value) => JSON.stringify(value, null, 2).split("\n").map((line) => jsonLine(line, paint)).join("\n")
19387
+ };
19388
+ return self;
19389
+ }
19390
+ function jsonLine(line, paint) {
19391
+ const match = /^(\s*)(?:("(?:[^"\\]|\\.)*")(: ))?(.*?)(,?)$/.exec(line);
19392
+ if (!match) return line;
19393
+ const [, indent = "", key, colon, value = "", comma = ""] = match;
19394
+ const painted = value === "" ? "" : /^[[\]{}]+$/.test(value) ? paint("dim", value) : value.startsWith('"') ? value : paint("green", value);
19395
+ return indent + (key && colon ? paint("bold", key) + paint("dim", colon) : "") + painted + (comma ? paint("dim", comma) : "");
19396
+ }
19397
+
19071
19398
  // src/context.ts
19072
19399
  var CLI_OPERATIONS = /* @__PURE__ */ new Set([
19073
19400
  "getCliCapabilities",
@@ -19075,8 +19402,21 @@ var CLI_OPERATIONS = /* @__PURE__ */ new Set([
19075
19402
  "createCliOperation",
19076
19403
  "pollCliOperation",
19077
19404
  "getCliAccount",
19078
- "getCliUsage"
19405
+ "getCliUsage",
19406
+ "openCliLogin",
19407
+ "redeemCliLogin",
19408
+ "revokeCliCredential"
19079
19409
  ]);
19410
+ var LOGIN_TIMEOUT_SECONDS = 300;
19411
+ var LOGIN_RECORD_MS = 36e5;
19412
+ var LOOPBACK_GRACE_MS = 1e4;
19413
+ var defaultIo = () => ({
19414
+ stderr: process.stderr,
19415
+ openBrowser,
19416
+ browserAvailable: () => browserAvailable(),
19417
+ listen: listenLoopback,
19418
+ env: process.env
19419
+ });
19080
19420
  var KEY_OPERATION_KINDS = /* @__PURE__ */ new Set(["app.add", "app.key.add"]);
19081
19421
  var OPERATION_RETENTION_MS = 90 * 864e5;
19082
19422
  var pathFor = operationPath;
@@ -19106,14 +19446,16 @@ var Context = class {
19106
19446
  state;
19107
19447
  transport;
19108
19448
  flags;
19449
+ io;
19109
19450
  /** Operations whose result this run has printed, released once stdout has taken it. */
19110
19451
  delivered = /* @__PURE__ */ new Set();
19111
19452
  onboarding;
19112
- constructor(store, state, transport, flags) {
19453
+ constructor(store, state, transport, flags, io = {}) {
19113
19454
  this.store = store;
19114
19455
  this.state = state;
19115
19456
  this.transport = transport;
19116
19457
  this.flags = flags;
19458
+ this.io = { ...defaultIo(), ...io };
19117
19459
  }
19118
19460
  get active() {
19119
19461
  return this.state.active;
@@ -19121,6 +19463,28 @@ var Context = class {
19121
19463
  get url() {
19122
19464
  return this.active?.url || CLOUD;
19123
19465
  }
19466
+ /** `AGW_MANAGEMENT_KEY`, when it is set to anything. */
19467
+ get environmentKey() {
19468
+ const value = this.io.env[MANAGEMENT_KEY_ENV]?.trim();
19469
+ return value ? value : void 0;
19470
+ }
19471
+ /**
19472
+ * The management credential every call is sent with: the environment's, which
19473
+ * always wins, or the one the selected connection stored.
19474
+ */
19475
+ get managementKey() {
19476
+ return this.environmentKey ?? this.active?.credential;
19477
+ }
19478
+ /** Refuses a command that would store a key the environment would then override. */
19479
+ assertNoEnvironmentKey() {
19480
+ if (this.environmentKey)
19481
+ fail(
19482
+ "environment_credential",
19483
+ `${MANAGEMENT_KEY_ENV} is set, so it is the credential for every command and a stored login would never be used.`,
19484
+ `Unset ${MANAGEMENT_KEY_ENV} to log in, or keep using it as is.`,
19485
+ 4
19486
+ );
19487
+ }
19124
19488
  async save() {
19125
19489
  await this.store.write(this.state);
19126
19490
  }
@@ -19158,7 +19522,20 @@ var Context = class {
19158
19522
  }
19159
19523
  /** The same call, with the connection's management credential attached. */
19160
19524
  async call(name, options = {}) {
19161
- return this.publicCall(name, { ...options, key: options.key ?? this.credential(name) });
19525
+ const key = options.key ?? this.credential(name);
19526
+ try {
19527
+ return await this.publicCall(name, { ...options, key });
19528
+ } catch (error51) {
19529
+ if (error51 instanceof CliError && error51.details?.status === 401 && options.key === void 0 && this.environmentKey)
19530
+ throw new CliError(
19531
+ error51.code,
19532
+ error51.message,
19533
+ `Check ${MANAGEMENT_KEY_ENV}: the deployment refused the key it holds.`,
19534
+ error51.exitCode,
19535
+ error51.details
19536
+ );
19537
+ throw error51;
19538
+ }
19162
19539
  }
19163
19540
  /**
19164
19541
  * The current management credential, or the refusal that names the way back.
@@ -19166,12 +19543,12 @@ var Context = class {
19166
19543
  * which is why those get the wider instruction.
19167
19544
  */
19168
19545
  credential(name) {
19169
- const token = this.active?.credential;
19546
+ const token = this.managementKey;
19170
19547
  if (!token)
19171
19548
  fail(
19172
19549
  "login_required",
19173
19550
  "The selected connection is not authenticated.",
19174
- CLI_OPERATIONS.has(name) ? "Run agw account login." : "Run agw account login or agw deployment connect.",
19551
+ CLI_OPERATIONS.has(name) ? `Run agw account login, or set ${MANAGEMENT_KEY_ENV}.` : `Run agw account login or agw deployment connect, or set ${MANAGEMENT_KEY_ENV}.`,
19175
19552
  4
19176
19553
  );
19177
19554
  return token;
@@ -19247,11 +19624,11 @@ var Context = class {
19247
19624
  * to, in which case it answers pending with the URL and the id to resume.
19248
19625
  */
19249
19626
  async operation(kind, payload, { browser: browser2 = false, url: url2 = this.url } = {}) {
19250
- if (!this.active?.credential)
19627
+ if (!this.managementKey)
19251
19628
  fail(
19252
19629
  "login_required",
19253
19630
  "This operation requires account management access.",
19254
- "Run agw account login.",
19631
+ `Run agw account login, or set ${MANAGEMENT_KEY_ENV}.`,
19255
19632
  4
19256
19633
  );
19257
19634
  const target = origin(url2);
@@ -19262,7 +19639,7 @@ var Context = class {
19262
19639
  // which is the correlation the compiler cannot follow through a generic.
19263
19640
  body: { kind, payload, token: record2.token, ...browser2 ? { browser: true } : {} },
19264
19641
  url: target,
19265
- key: this.active?.credential
19642
+ key: this.managementKey
19266
19643
  })).data);
19267
19644
  if (data.state === "expired") {
19268
19645
  await this.forget(id);
@@ -19273,15 +19650,11 @@ var Context = class {
19273
19650
  return data;
19274
19651
  }
19275
19652
  const { data: capabilities } = await this.publicCall("getCliCapabilities", { url: target });
19276
- const browserUrl = new URL(data.url);
19277
- const trustedOrigin = origin(
19278
- capabilities.consoleOrigin ?? capabilities.deployment.consoleOrigin ?? target
19279
- );
19280
- if (browserUrl.origin !== trustedOrigin || browserUrl.username || browserUrl.password)
19281
- fail("invalid_response", "The approval URL is not on this deployment\u2019s trusted console origin.");
19282
- if (!this.flags["no-open"]) await openBrowser(data.url);
19653
+ assertTrustedApprovalUrl(data.url, capabilities, target);
19654
+ if (!this.flags["no-open"] && !await this.io.openBrowser(data.url))
19655
+ this.io.stderr.write("Browser could not open. Use the returned handoff URL.\n");
19283
19656
  if (this.flags["no-open"] || this.flags.json || this.flags["no-input"]) return data;
19284
- process.stderr.write(`Complete the browser step: ${data.url}
19657
+ this.io.stderr.write(`Complete the browser step: ${data.url}
19285
19658
  Operation: ${data.id}
19286
19659
  `);
19287
19660
  return this.wait(data.id, 300);
@@ -19297,8 +19670,13 @@ Operation: ${data.id}
19297
19670
  * Once the file holds the key, the record keeps only its metadata.
19298
19671
  */
19299
19672
  async keyOperation(kind, payload, requestedPath) {
19300
- if (!this.active?.credential)
19301
- fail("login_required", "The selected connection is not authenticated.", "Run agw account login.", 4);
19673
+ if (!this.managementKey)
19674
+ fail(
19675
+ "login_required",
19676
+ "The selected connection is not authenticated.",
19677
+ `Run agw account login, or set ${MANAGEMENT_KEY_ENV}.`,
19678
+ 4
19679
+ );
19302
19680
  const target = origin(this.url);
19303
19681
  const [id, record2] = await this.reserveOperation(kind, payload, target);
19304
19682
  const chosen = requestedPath ? resolve2(requestedPath) : record2.output?.path;
@@ -19331,7 +19709,7 @@ Operation: ${data.id}
19331
19709
  const operation2 = await this.send(id, async () => (await this.publicCall("createCliOperation", {
19332
19710
  body: { kind, payload, token: record2.token },
19333
19711
  url: target,
19334
- key: this.active?.credential
19712
+ key: this.managementKey
19335
19713
  })).data);
19336
19714
  const minted = operation2.result?.api_key;
19337
19715
  const appId = operation2.result?.app?.id ?? payload.app ?? "";
@@ -19375,7 +19753,7 @@ Operation: ${data.id}
19375
19753
  }
19376
19754
  }
19377
19755
  async bootstrap() {
19378
- if (this.active?.credential) return;
19756
+ if (this.managementKey) return;
19379
19757
  if (this.active)
19380
19758
  fail(
19381
19759
  "login_required",
@@ -19421,7 +19799,9 @@ Operation: ${data.id}
19421
19799
  };
19422
19800
  await this.save();
19423
19801
  }
19424
- async login(url2 = this.url) {
19802
+ /** `account login --key-stdin` or `--key-prompt`: a management key a person already holds. */
19803
+ async keyLogin(url2 = this.url) {
19804
+ this.assertNoEnvironmentKey();
19425
19805
  const token = await secret(this.flags, "Management API key");
19426
19806
  if (!token)
19427
19807
  fail("input_required", "A management API key is required.", "Supply it with --key-stdin.");
@@ -19429,6 +19809,239 @@ Operation: ${data.id}
19429
19809
  await this.select(url2, { ...data, credential: { token } });
19430
19810
  return { connected: true, ...data };
19431
19811
  }
19812
+ /**
19813
+ * `deployment connect` while `AGW_MANAGEMENT_KEY` is set: selects the
19814
+ * deployment the key is checked against, and stores no key of its own.
19815
+ */
19816
+ async connectWithEnvironment(url2) {
19817
+ const key = this.environmentKey;
19818
+ const target = origin(url2);
19819
+ const { data } = await this.publicCall("getCliAccount", { key, url: target });
19820
+ this.state.active = {
19821
+ url: target,
19822
+ account: data.account,
19823
+ deployment: data.deployment,
19824
+ authenticated: false
19825
+ };
19826
+ await this.save();
19827
+ this.io.stderr.write(
19828
+ `${MANAGEMENT_KEY_ENV} is set, so it stays the credential for every command; no key was stored.
19829
+ `
19830
+ );
19831
+ return { connected: true, ...data };
19832
+ }
19833
+ /**
19834
+ * `account login`: a person approves this CLI in a browser, and it receives
19835
+ * a management key of its own, stored exactly as a pasted one is.
19836
+ *
19837
+ * The login is recorded like every other operation before it is opened, so
19838
+ * one left waiting — by `--json`, `--no-input`, a timeout — is finished by
19839
+ * `agw operation wait`. When this process is going to wait and a browser on
19840
+ * this machine is in front of the person, it also listens on `127.0.0.1`,
19841
+ * and the approval page hands the browser back to it with a one-time code;
19842
+ * the key is then released only for that code and never to a poll.
19843
+ * Otherwise — `--no-open`, SSH, no display, nobody at the terminal — the
19844
+ * approval may happen on any device, so nothing listens and the key is
19845
+ * polled for. A browser that turns out not to open is the same case, found
19846
+ * late: that login is abandoned for one that is polled.
19847
+ */
19848
+ async browserLogin(url2 = this.url) {
19849
+ this.assertNoEnvironmentKey();
19850
+ const target = origin(url2);
19851
+ const { data: capabilities } = await this.publicCall("getCliCapabilities", { url: target });
19852
+ if (!capabilities.features?.browserLogin)
19853
+ fail(
19854
+ "browser_login_unsupported",
19855
+ "This deployment does not offer browser login.",
19856
+ "Update the deployment, or pipe a management key to agw account login --key-stdin.",
19857
+ 3
19858
+ );
19859
+ const opens = !this.flags["no-open"] && this.io.browserAvailable();
19860
+ const detached = Boolean(this.flags.json || this.flags["no-input"]);
19861
+ if (detached) {
19862
+ const { login } = await this.openLogin(target, capabilities, null);
19863
+ if (opens && !await this.io.openBrowser(login.url))
19864
+ this.io.stderr.write("Browser could not open. Use the returned URL.\n");
19865
+ return login;
19866
+ }
19867
+ let loopback = opens ? await this.io.listen() : null;
19868
+ try {
19869
+ let opened = await this.openLogin(target, capabilities, loopback);
19870
+ if (loopback && !await this.io.openBrowser(opened.login.url)) {
19871
+ await loopback.close();
19872
+ loopback = null;
19873
+ await this.forget(opened.id);
19874
+ this.io.stderr.write(
19875
+ "The browser could not be opened, so the approval will be collected by polling.\n"
19876
+ );
19877
+ opened = await this.openLogin(target, capabilities, null);
19878
+ }
19879
+ const { id, token, login } = opened;
19880
+ const style2 = styleFor(this.io.stderr);
19881
+ this.io.stderr.write(
19882
+ [
19883
+ ...login.userCode ? [
19884
+ `Pairing code: ${style2.headline(login.userCode)}`,
19885
+ "Check that your browser shows this same code before you approve."
19886
+ ] : [],
19887
+ `Approve at: ${login.url}`,
19888
+ ""
19889
+ ].join("\n")
19890
+ );
19891
+ if (loopback) {
19892
+ try {
19893
+ await this.collectLoopbackLogin(id, token, target, capabilities, loopback);
19894
+ } finally {
19895
+ if (this.state.operations[id]) await this.forget(id);
19896
+ }
19897
+ } else await this.wait(id, LOGIN_TIMEOUT_SECONDS);
19898
+ const { data } = await this.call("getCliAccount", { url: target });
19899
+ return { connected: true, ...data };
19900
+ } finally {
19901
+ await loopback?.close();
19902
+ }
19903
+ }
19904
+ /** Records and opens one login, refusing one that is not pending on the trusted console. */
19905
+ async openLogin(target, capabilities, loopback) {
19906
+ const token = randomToken();
19907
+ const id = operationIdFor(token);
19908
+ await this.recordLogin(id, token, target);
19909
+ const { data: login } = await this.send(id, () => this.publicCall("openCliLogin", {
19910
+ body: {
19911
+ token,
19912
+ client: clientDescription(),
19913
+ ...loopback ? { loopbackRedirect: loopback.redirect } : {}
19914
+ },
19915
+ url: target
19916
+ }));
19917
+ if (login.state !== "pending" || !login.url) {
19918
+ await this.forget(id);
19919
+ expired({
19920
+ id,
19921
+ state: "expired",
19922
+ expiresAt: login.expiresAt,
19923
+ ...login.state === "denied" ? { denied: true } : {}
19924
+ });
19925
+ }
19926
+ assertTrustedApprovalUrl(login.url, capabilities, target);
19927
+ return { id, token, login: { ...login, url: login.url } };
19928
+ }
19929
+ /** Writes a login's record before it is opened, and drops logins long since expired. */
19930
+ async recordLogin(id, token, url2) {
19931
+ const now = Date.now();
19932
+ for (const [key, record2] of Object.entries(this.state.operations))
19933
+ if (record2.kind === "login" && now - Date.parse(record2.createdAt) > LOGIN_RECORD_MS)
19934
+ delete this.state.operations[key];
19935
+ this.state.operations[id] = {
19936
+ url: url2,
19937
+ token,
19938
+ kind: "login",
19939
+ // Never matched: every login is a new one, since a listener's port is.
19940
+ requestHash: createHash2("sha256").update(id).digest("hex"),
19941
+ accountId: null,
19942
+ createdAt: new Date(now).toISOString()
19943
+ };
19944
+ await this.save();
19945
+ }
19946
+ /**
19947
+ * Waits for the approval page to send the browser back with the redeem code,
19948
+ * and redeems it. Polls meanwhile, since a declined or expired login never
19949
+ * comes back to the listener.
19950
+ */
19951
+ async collectLoopbackLogin(id, token, url2, capabilities, loopback) {
19952
+ let code;
19953
+ const arrived = loopback.code.then((value) => {
19954
+ code = value;
19955
+ });
19956
+ const end = Date.now() + LOGIN_TIMEOUT_SECONDS * 1e3;
19957
+ let approvedAt;
19958
+ while (code === void 0) {
19959
+ const { data: status2 } = await this.publicCall("pollCliOperation", {
19960
+ params: { id },
19961
+ key: token,
19962
+ url: url2
19963
+ });
19964
+ if (code !== void 0) break;
19965
+ if (status2.state === "expired") {
19966
+ await this.forget(id);
19967
+ expired(status2);
19968
+ }
19969
+ if (status2.state === "completed") {
19970
+ approvedAt ??= Date.now();
19971
+ if (Date.now() - approvedAt > LOOPBACK_GRACE_MS) {
19972
+ await this.forget(id);
19973
+ fail(
19974
+ "login_not_delivered",
19975
+ "The login was approved, but the browser never came back to this terminal.",
19976
+ "Run agw account login --no-open to approve it from another device, or run agw account login again here.",
19977
+ 3,
19978
+ { id }
19979
+ );
19980
+ }
19981
+ }
19982
+ if (Date.now() >= end) {
19983
+ await this.forget(id);
19984
+ fail(
19985
+ "wait_timeout",
19986
+ "The login was not approved in time.",
19987
+ "Run agw account login again.",
19988
+ 5,
19989
+ { id, state: status2.state, expiresAt: status2.expiresAt }
19990
+ );
19991
+ }
19992
+ await Promise.race([delay2(Math.min(1500, Math.max(0, end - Date.now()))), arrived]);
19993
+ }
19994
+ try {
19995
+ const { data } = await this.publicCall("redeemCliLogin", {
19996
+ params: { id },
19997
+ body: { redeemCode: code },
19998
+ key: token,
19999
+ url: url2
20000
+ });
20001
+ await this.select(url2, { ...data, deployment: capabilities.deployment });
20002
+ delete this.state.operations[id];
20003
+ await this.save();
20004
+ loopback.answer(true);
20005
+ } catch (error51) {
20006
+ loopback.answer(false);
20007
+ throw error51;
20008
+ }
20009
+ }
20010
+ /**
20011
+ * `account logout`: the deployment revokes the stored key, then this machine
20012
+ * forgets it. A key the deployment no longer accepts is already as good as
20013
+ * revoked; any other failure still removes it here, and says so, because a
20014
+ * machine must always be able to sign out.
20015
+ */
20016
+ async logout() {
20017
+ const active = this.active;
20018
+ const key = active?.credential;
20019
+ let revoked = false;
20020
+ if (active && key) {
20021
+ try {
20022
+ await this.publicCall("revokeCliCredential", { key, url: active.url });
20023
+ revoked = true;
20024
+ } catch (error51) {
20025
+ const status2 = error51 instanceof CliError ? error51.details?.status : void 0;
20026
+ if (status2 !== 401)
20027
+ this.io.stderr.write(
20028
+ `The key could not be revoked (${error51 instanceof Error ? error51.message : "unknown error"}). It was removed from this machine; revoke it on the console's keys page.
20029
+ `
20030
+ );
20031
+ }
20032
+ }
20033
+ if (active) {
20034
+ delete active.credential;
20035
+ active.authenticated = false;
20036
+ await this.save();
20037
+ }
20038
+ if (this.environmentKey)
20039
+ this.io.stderr.write(
20040
+ `${MANAGEMENT_KEY_ENV} is still set, so it stays the credential for every command; unset it to sign out completely.
20041
+ `
20042
+ );
20043
+ return { loggedOut: true, revoked };
20044
+ }
19432
20045
  async poll(id) {
19433
20046
  const record2 = this.state.operations[id];
19434
20047
  if (!record2)
@@ -19438,7 +20051,7 @@ Operation: ${data.id}
19438
20051
  "Resume it from the machine that initiated the operation.",
19439
20052
  4
19440
20053
  );
19441
- if (this.active && this.active.url !== record2.url)
20054
+ if (this.active && this.active.url !== record2.url && record2.kind !== "login")
19442
20055
  fail(
19443
20056
  "operation_context",
19444
20057
  "This operation belongs to another selected deployment.",
@@ -19458,6 +20071,7 @@ Operation: ${data.id}
19458
20071
  url: record2.url
19459
20072
  });
19460
20073
  if (data.state === "expired") await this.forget(id);
20074
+ if (data.state === "completed" && record2.kind === "login") return this.polledLogin(id, record2, data);
19461
20075
  if (data.state === "completed") {
19462
20076
  if (record2.kind === "claim" && data.account && this.active?.account?.id === record2.accountId && data.account.id === record2.accountId) {
19463
20077
  this.active.account = data.account;
@@ -19467,6 +20081,36 @@ Operation: ${data.id}
19467
20081
  }
19468
20082
  return data;
19469
20083
  }
20084
+ /**
20085
+ * A completed login, as a poll first sees it: its key is stored the way a
20086
+ * pasted one is, and taken out of what the command prints. A login whose key
20087
+ * was already handed to another poll, or is only ever redeemed, cannot be
20088
+ * collected here.
20089
+ */
20090
+ async polledLogin(id, record2, data) {
20091
+ const { credential, ...result } = data.result ?? {};
20092
+ if (credential) {
20093
+ if (!data.account)
20094
+ fail(
20095
+ "invalid_response",
20096
+ "The login did not say which account it was approved into.",
20097
+ "Run agw account login again.",
20098
+ 3
20099
+ );
20100
+ await this.select(record2.url, { credential, account: data.account, deployment: data.deployment });
20101
+ } else if (!(this.active?.credential && this.active.url === record2.url && data.account && this.active.account?.id === data.account.id)) {
20102
+ await this.forget(id);
20103
+ fail(
20104
+ "login_not_delivered",
20105
+ "This login was approved, but its key can no longer be collected here.",
20106
+ "Run agw account login again.",
20107
+ 3,
20108
+ { id }
20109
+ );
20110
+ }
20111
+ this.delivered.add(id);
20112
+ return { ...data, result };
20113
+ }
19470
20114
  async wait(id, timeout) {
19471
20115
  const end = Date.now() + timeout * 1e3;
19472
20116
  for (; ; ) {
@@ -19488,6 +20132,14 @@ Operation: ${data.id}
19488
20132
  }
19489
20133
  };
19490
20134
  function expired(operation2) {
20135
+ if (operation2.denied)
20136
+ fail(
20137
+ "operation_denied",
20138
+ "The browser step was declined.",
20139
+ "Run the same command again to start a new one.",
20140
+ 3,
20141
+ { id: operation2.id, state: "denied", expiresAt: operation2.expiresAt }
20142
+ );
19491
20143
  fail(
19492
20144
  "operation_expired",
19493
20145
  "The browser step expired before it was approved.",
@@ -19517,20 +20169,23 @@ async function storeKey(minted, output, appId) {
19517
20169
  contentHash: createHash2("sha256").update(minted.key + "\n").digest("hex")
19518
20170
  };
19519
20171
  }
20172
+ function assertTrustedApprovalUrl(url2, capabilities, target) {
20173
+ const browserUrl = new URL(url2);
20174
+ const trustedOrigin = origin(
20175
+ capabilities.consoleOrigin ?? capabilities.deployment.consoleOrigin ?? target
20176
+ );
20177
+ if (browserUrl.origin !== trustedOrigin || browserUrl.username || browserUrl.password)
20178
+ fail("invalid_response", "The approval URL is not on this deployment\u2019s trusted console origin.");
20179
+ }
19520
20180
  async function openBrowser(url2) {
19521
20181
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
19522
20182
  const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url2] : [url2];
19523
- await new Promise((resolve3) => {
20183
+ return new Promise((resolve3) => {
19524
20184
  const child = spawn(command, args, { stdio: "ignore", detached: true });
19525
- child.once("error", () => {
19526
- process.stderr.write(
19527
- "Browser could not open. Use the returned handoff URL.\n"
19528
- );
19529
- resolve3();
19530
- });
20185
+ child.once("error", () => resolve3(false));
19531
20186
  child.once("spawn", () => {
19532
20187
  child.unref();
19533
- resolve3();
20188
+ resolve3(true);
19534
20189
  });
19535
20190
  });
19536
20191
  }
@@ -19721,7 +20376,7 @@ async function cachedRelease(version2, integrity, source) {
19721
20376
  }
19722
20377
  return directory;
19723
20378
  }
19724
- async function release(requested = VERSION, base = dirname(fileURLToPath(import.meta.url)), source = {}) {
20379
+ async function release2(requested = VERSION, base = dirname(fileURLToPath(import.meta.url)), source = {}) {
19725
20380
  if (requested !== VERSION)
19726
20381
  fail(
19727
20382
  "unsupported_release",
@@ -19976,14 +20631,14 @@ var Cloudflare = class {
19976
20631
  }
19977
20632
  async all(path) {
19978
20633
  const result = [];
19979
- for (let page = 1; page <= 1e4; page++) {
20634
+ for (let page2 = 1; page2 <= 1e4; page2++) {
19980
20635
  const data = await this.request(
19981
- path + (path.includes("?") ? "&" : "?") + "page=" + page + "&per_page=50"
20636
+ path + (path.includes("?") ? "&" : "?") + "page=" + page2 + "&per_page=50"
19982
20637
  );
19983
20638
  if (!data || !Array.isArray(data.result))
19984
20639
  fail("cloudflare_error", "Expected a Cloudflare resource list.");
19985
20640
  result.push(...data.result);
19986
- if (data.result_info?.total_pages ? page >= data.result_info.total_pages : data.result.length < 50)
20641
+ if (data.result_info?.total_pages ? page2 >= data.result_info.total_pages : data.result.length < 50)
19987
20642
  return result;
19988
20643
  }
19989
20644
  fail("cloudflare_error", "Cloudflare pagination exceeded its safety bound.");
@@ -20304,7 +20959,7 @@ async function matchExisting(ctx, cf, flags, target) {
20304
20959
  }
20305
20960
 
20306
20961
  // src/deployment.ts
20307
- function hostname4(value) {
20962
+ function hostname5(value) {
20308
20963
  const u = origin("https://" + value);
20309
20964
  if (new URL(u).hostname !== value || !value.includes(".") || value.includes(":"))
20310
20965
  fail("invalid_input", "Supply a hostname without protocol, path or port.");
@@ -20317,10 +20972,10 @@ async function authorized(flags, cf, loadRelease) {
20317
20972
  await cf.authenticate(flags);
20318
20973
  return artifact;
20319
20974
  }
20320
- async function deploymentDomain(ctx, flags, cf = new Cloudflare(), loadRelease = release) {
20975
+ async function deploymentDomain(ctx, flags, cf = new Cloudflare(), loadRelease = release2) {
20321
20976
  const artifact = await authorized(flags, cf, loadRelease);
20322
20977
  const journal = await matchExisting(ctx, cf, flags, selectedInstallation(ctx));
20323
- const domain2 = hostname4(flags.hostname ?? await prompt("Custom hostname", flags));
20978
+ const domain2 = hostname5(flags.hostname ?? await prompt("Custom hostname", flags));
20324
20979
  if (!flags["dry-run"])
20325
20980
  await confirm(
20326
20981
  `Attach ${domain2} to ${journal.name} in Cloudflare account ${journal.accountId}?`,
@@ -20328,7 +20983,7 @@ async function deploymentDomain(ctx, flags, cf = new Cloudflare(), loadRelease =
20328
20983
  );
20329
20984
  return attachDomain(ctx, cf, journal, domain2, artifact, Boolean(flags["dry-run"]));
20330
20985
  }
20331
- async function deploymentUpdate(ctx, flags, cf = new Cloudflare(), loadRelease = release) {
20986
+ async function deploymentUpdate(ctx, flags, cf = new Cloudflare(), loadRelease = release2) {
20332
20987
  const artifact = await authorized(flags, cf, loadRelease);
20333
20988
  const journal = await matchExisting(ctx, cf, flags, selectedInstallation(ctx));
20334
20989
  if (!artifact.manifest.upgradeFrom.includes(journal.version))
@@ -20459,12 +21114,12 @@ async function install(ctx, cf, journal, artifact, url2, domain2) {
20459
21114
  await advance(ctx, journal, journal.phase, { pendingDomain: void 0 });
20460
21115
  }
20461
21116
  }
20462
- async function deploymentSetup(ctx, flags, cf = new Cloudflare(), loadRelease = release) {
21117
+ async function deploymentSetup(ctx, flags, cf = new Cloudflare(), loadRelease = release2) {
20463
21118
  const artifact = await authorized(flags, cf, loadRelease);
20464
21119
  const name = flags.name ?? await prompt("Worker name", flags, { defaultValue: "app-ai-gateway" });
20465
21120
  if (!/^[a-z][a-z0-9-]{0,62}$/.test(name))
20466
21121
  fail("invalid_input", "Worker name must be a lowercase slug of at most 63 characters.");
20467
- const domain2 = flags.domain ? hostname4(flags.domain) : void 0;
21122
+ const domain2 = flags.domain ? hostname5(flags.domain) : void 0;
20468
21123
  const accountId = await cf.account(flags);
20469
21124
  const workers = await cf.all("/accounts/" + accountId + "/workers/scripts");
20470
21125
  const installations = ctx.state.installations ??= {};
@@ -20537,7 +21192,7 @@ async function deploymentSetup(ctx, flags, cf = new Cloudflare(), loadRelease =
20537
21192
  throw error51;
20538
21193
  }
20539
21194
  }
20540
- async function deploymentCommand(ctx, command, flags, cf = new Cloudflare(), loadRelease = release) {
21195
+ async function deploymentCommand(ctx, command, flags, cf = new Cloudflare(), loadRelease = release2) {
20541
21196
  if (command === "deployment setup") return deploymentSetup(ctx, flags, cf, loadRelease);
20542
21197
  if (command === "deployment domain") return deploymentDomain(ctx, flags, cf, loadRelease);
20543
21198
  if (command === "deployment update") return deploymentUpdate(ctx, flags, cf, loadRelease);
@@ -20576,64 +21231,6 @@ var CliErrorDetailsSchema = external_exports.object({
20576
21231
  apiErrors: external_exports.string().optional()
20577
21232
  });
20578
21233
 
20579
- // src/style.ts
20580
- import { Writable } from "node:stream";
20581
- import { styleText } from "node:util";
20582
- var STATES = /* @__PURE__ */ new Map([
20583
- ["active", "green"],
20584
- ["ready", "green"],
20585
- ["completed", "green"],
20586
- ["yes", "green"],
20587
- ["pending", "yellow"],
20588
- ["failed", "red"],
20589
- ["expired", "red"],
20590
- ["blocked", "red"],
20591
- ["inactive", "red"],
20592
- ["disabled", "red"],
20593
- ["revoked", "red"],
20594
- ["no", "red"]
20595
- ]);
20596
- function colorEnabled(sink, env = process.env) {
20597
- if (env["NO_COLOR"]) return false;
20598
- if (!(sink instanceof Writable)) return false;
20599
- if (env["FORCE_COLOR"] && env["FORCE_COLOR"] !== "0") return true;
20600
- if (!("isTTY" in sink && sink.isTTY === true)) return false;
20601
- return styleText("dim", "?", { stream: sink, validateStream: true }) !== "?";
20602
- }
20603
- var plain = style(false);
20604
- var styleFor = (sink) => style(colorEnabled(sink));
20605
- function style(enabled) {
20606
- const paint = (format, text2) => enabled ? styleText(format, text2, { validateStream: false }) : text2;
20607
- const self = {
20608
- enabled,
20609
- headline: (text2) => paint("bold", text2),
20610
- dim: (text2) => paint("dim", text2),
20611
- alert: (text2) => paint("red", text2),
20612
- warn: (text2) => paint("yellow", text2),
20613
- state: (value) => {
20614
- const tone = STATES.get(value.toLowerCase());
20615
- return tone ? paint(tone, value) : value;
20616
- },
20617
- code: (text2, language) => {
20618
- const rule = `\u2500\u2500 ${language} \u2500\u2500`;
20619
- return [
20620
- self.dim(rule),
20621
- ...text2.split("\n").map((line) => self.dim(line)),
20622
- self.dim("\u2500".repeat(rule.length))
20623
- ];
20624
- },
20625
- json: (value) => JSON.stringify(value, null, 2).split("\n").map((line) => jsonLine(line, paint)).join("\n")
20626
- };
20627
- return self;
20628
- }
20629
- function jsonLine(line, paint) {
20630
- const match = /^(\s*)(?:("(?:[^"\\]|\\.)*")(: ))?(.*?)(,?)$/.exec(line);
20631
- if (!match) return line;
20632
- const [, indent = "", key, colon, value = "", comma = ""] = match;
20633
- const painted = value === "" ? "" : /^[[\]{}]+$/.test(value) ? paint("dim", value) : value.startsWith('"') ? value : paint("green", value);
20634
- return indent + (key && colon ? paint("bold", key) + paint("dim", colon) : "") + painted + (comma ? paint("dim", comma) : "");
20635
- }
20636
-
20637
21234
  // src/human.ts
20638
21235
  function kv(entries, style2) {
20639
21236
  const rows = [];
@@ -20675,8 +21272,13 @@ function render(command, result, context, style2) {
20675
21272
  return [
20676
21273
  style2.headline(`Waiting for browser approval (${result.id}).`),
20677
21274
  result.url,
21275
+ // A login's page shows the same code; checking it is what tells a person
21276
+ // the request they are approving is this one.
21277
+ ..."userCode" in result && result.userCode ? [`Pairing code: ${result.userCode} (check that the browser shows the same code)`] : [],
20678
21278
  `Resume: agw operation wait ${result.id}`
20679
21279
  ];
21280
+ if ((command === "operation wait" || command === "operation status") && "kind" in result && result.kind === "login" && result.state === "completed")
21281
+ return [style2.headline(`Signed in to ${context.url}.`), ...operation(result, style2)];
20680
21282
  switch (command) {
20681
21283
  case "account status":
20682
21284
  case "account login":
@@ -20858,7 +21460,7 @@ function show(command, result, style2) {
20858
21460
  function mutation(command, result, context, style2) {
20859
21461
  if ("loggedOut" in result)
20860
21462
  return [
20861
- style2.headline(`Signed out of ${context.url}.`) + " The stored credential was removed."
21463
+ style2.headline(`Signed out of ${context.url}.`) + (result.revoked ? " The key was revoked and removed from this machine." : " The stored credential was removed.")
20862
21464
  ];
20863
21465
  if ("deleted" in result) {
20864
21466
  if ("provider_id" in result)
@@ -21315,27 +21917,23 @@ async function execute(parsed, ctx) {
21315
21917
  if (command.startsWith("operation "))
21316
21918
  return command === "operation wait" ? ctx.wait(args[0] ?? "", positive(flags.timeout ?? 300)) : ctx.poll(args[0] ?? "");
21317
21919
  if (command === "account status") return (await ctx.call("getCliAccount")).data;
21318
- if (command === "account login") return ctx.login();
21920
+ if (command === "account login")
21921
+ return flags["key-stdin"] || flags["key-prompt"] ? ctx.keyLogin() : ctx.browserLogin();
21319
21922
  if (command === "account claim") return ctx.operation("claim", {});
21320
- if (command === "account logout") {
21321
- if (ctx.active) {
21322
- delete ctx.active.credential;
21323
- ctx.active.authenticated = false;
21324
- await ctx.save();
21325
- }
21326
- return { loggedOut: true };
21327
- }
21923
+ if (command === "account logout") return ctx.logout();
21328
21924
  if (command === "deployment connect") {
21329
21925
  const url2 = flags.cloud ? CLOUD : origin(
21330
21926
  flags.url ?? await required2(flags, "url", "Deployment HTTPS URL")
21331
21927
  );
21332
- return ctx.login(url2);
21928
+ if (flags["key-stdin"] || flags["key-prompt"]) return ctx.keyLogin(url2);
21929
+ if (ctx.environmentKey) return ctx.connectWithEnvironment(url2);
21930
+ return ctx.browserLogin(url2);
21333
21931
  }
21334
21932
  if (command === "deployment status") {
21335
21933
  const { data } = await ctx.publicCall("getCliCapabilities");
21336
21934
  return {
21337
21935
  url: ctx.url,
21338
- authenticated: Boolean(ctx.active?.credential),
21936
+ authenticated: Boolean(ctx.managementKey),
21339
21937
  ...data,
21340
21938
  connected: true
21341
21939
  };
@@ -21346,7 +21944,8 @@ async function main(argv = process.argv.slice(2), {
21346
21944
  store = new StateStore(),
21347
21945
  transport = new Transport(),
21348
21946
  stdout = process.stdout,
21349
- stderr: stderr2 = process.stderr
21947
+ stderr: stderr2 = process.stderr,
21948
+ io = {}
21350
21949
  } = {}) {
21351
21950
  const json2 = argv.includes("--json");
21352
21951
  try {
@@ -21361,7 +21960,7 @@ async function main(argv = process.argv.slice(2), {
21361
21960
  }
21362
21961
  const run = async () => {
21363
21962
  const state = await store.read((line) => stderr2.write(line + "\n"));
21364
- const ctx = new Context(store, state, transport, parsed.flags);
21963
+ const ctx = new Context(store, state, transport, parsed.flags, { ...io, stderr: stderr2 });
21365
21964
  const result = await execute(parsed, ctx);
21366
21965
  const context = {
21367
21966
  url: ctx.url,