@anvil-works/anvil-cli 0.5.11 → 0.5.12

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 +14 -0
  2. package/dist/cli.js +198 -51
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -172,6 +172,7 @@ Useful options:
172
172
 
173
173
  ```bash
174
174
  anvil login
175
+ anvil login anvil.company.com
175
176
  anvil logout
176
177
  anvil config list
177
178
  anvil config get <key>
@@ -260,6 +261,19 @@ If you want to change the default server permanently:
260
261
  anvil config set anvilUrl https://anvil.company.com
261
262
  ```
262
263
 
264
+ When you run `anvil login`, the CLI now supports both local and headless flows:
265
+
266
+ - press `Enter` to open the default browser
267
+ - or copy the printed device-login URL and code to continue in a browser of your choice
268
+
269
+ For `anvil login`, prefer the positional server argument in docs and examples:
270
+
271
+ ```bash
272
+ anvil login anvil.company.com
273
+ ```
274
+
275
+ `--url` is also supported if needed.
276
+
263
277
  ## Troubleshooting
264
278
 
265
279
  ### `anvil` command not found
package/dist/cli.js CHANGED
@@ -51890,34 +51890,134 @@ var __webpack_exports__ = {};
51890
51890
  </body>
51891
51891
  </html>`;
51892
51892
  }
51893
+ const CLIENT_ID = "anvil-sync";
51894
+ const SCOPES = "apps:read apps:write user:read";
51893
51895
  function oauth_login_base64url(buf) {
51894
51896
  return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
51895
51897
  }
51896
- async function waitForOAuthWithPrompt(authUrl, oauthPromise) {
51898
+ function sleep(ms) {
51899
+ return new Promise((resolve)=>setTimeout(resolve, ms));
51900
+ }
51901
+ function createBrowserLaunchPrompt(authUrl) {
51902
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return {
51903
+ waitForOpen: Promise.resolve(false),
51904
+ close: ()=>{}
51905
+ };
51897
51906
  const rl = external_readline_namespaceObject.createInterface({
51898
51907
  input: process.stdin,
51899
51908
  output: process.stdout
51900
51909
  });
51901
- const enterPromise = new Promise((resolve)=>{
51902
- rl.question(chalk_source.gray("Press Enter to open browser (or visit URL manually)... "), ()=>resolve("enter"));
51903
- });
51904
- const result = await Promise.race([
51905
- enterPromise,
51906
- oauthPromise.then((r)=>({
51907
- oauth: r
51908
- }))
51909
- ]);
51910
- if ("enter" === result) {
51910
+ let closed = false;
51911
+ let resolveWait = null;
51912
+ const close = (opened = false)=>{
51913
+ if (closed) return;
51914
+ closed = true;
51911
51915
  rl.close();
51912
- await node_modules_open(authUrl.toString());
51913
- logger_logger.info(chalk_source.cyan("Waiting for login to complete in browser...\n"));
51914
- return await oauthPromise;
51916
+ resolveWait?.(opened);
51917
+ resolveWait = null;
51918
+ };
51919
+ const waitForOpen = new Promise((resolve)=>{
51920
+ resolveWait = resolve;
51921
+ rl.on("SIGINT", ()=>{
51922
+ close();
51923
+ process.kill(process.pid, "SIGINT");
51924
+ });
51925
+ rl.question("", async ()=>{
51926
+ try {
51927
+ await node_modules_open(authUrl);
51928
+ logger_logger.info(chalk_source.dim("Opened your browser to continue login."));
51929
+ close(true);
51930
+ } catch {
51931
+ logger_logger.warn("Could not open a browser automatically.");
51932
+ close(false);
51933
+ }
51934
+ });
51935
+ });
51936
+ return {
51937
+ waitForOpen,
51938
+ close
51939
+ };
51940
+ }
51941
+ async function parseOAuthError(response) {
51942
+ const contentType = response.headers.get("content-type") || "";
51943
+ if (contentType.includes("application/json")) {
51944
+ const data = await response.json();
51945
+ return data.error || `HTTP ${response.status}`;
51946
+ }
51947
+ const text = (await response.text()).trim();
51948
+ if (!text) return `HTTP ${response.status}`;
51949
+ try {
51950
+ const parsed = JSON.parse(text);
51951
+ return parsed.error || text;
51952
+ } catch {
51953
+ return text;
51915
51954
  }
51916
- rl.close();
51917
- process.stdout.write("\r" + " ".repeat(60) + "\r");
51918
- return result.oauth;
51919
51955
  }
51920
- async function runInteractiveLoginFlow(anvilUrl) {
51956
+ async function exchangeAuthorizationCodeForTokens(anvilUrl, redirectUri, code, codeVerifier) {
51957
+ const tokenResponse = await fetch(`${anvilUrl}/oauth/token`, {
51958
+ method: "POST",
51959
+ headers: {
51960
+ "Content-Type": "application/x-www-form-urlencoded"
51961
+ },
51962
+ body: new URLSearchParams({
51963
+ grant_type: "authorization_code",
51964
+ code,
51965
+ redirect_uri: redirectUri,
51966
+ client_id: CLIENT_ID,
51967
+ code_verifier: codeVerifier
51968
+ })
51969
+ });
51970
+ if (!tokenResponse.ok) throw new Error(`Failed to exchange authorization code for token. ${await parseOAuthError(tokenResponse)}`);
51971
+ return await tokenResponse.json();
51972
+ }
51973
+ async function requestDeviceAuthorization(anvilUrl) {
51974
+ const response = await fetch(`${anvilUrl}/oauth/device_authorization`, {
51975
+ method: "POST",
51976
+ headers: {
51977
+ "Content-Type": "application/x-www-form-urlencoded"
51978
+ },
51979
+ body: new URLSearchParams({
51980
+ client_id: CLIENT_ID,
51981
+ scope: SCOPES
51982
+ })
51983
+ });
51984
+ if (!response.ok) throw new Error(`Failed to start device login. ${await parseOAuthError(response)}`);
51985
+ return await response.json();
51986
+ }
51987
+ async function pollDeviceAuthorization(anvilUrl, deviceAuth, options) {
51988
+ const expiresAt = Date.now() + 1000 * deviceAuth.expires_in;
51989
+ let intervalMs = 1000 * Math.max(deviceAuth.interval ?? 5, 1);
51990
+ while(Date.now() < expiresAt){
51991
+ if (options?.isCancelled?.()) throw new Error("cancelled");
51992
+ const response = await fetch(`${anvilUrl}/oauth/token`, {
51993
+ method: "POST",
51994
+ headers: {
51995
+ "Content-Type": "application/x-www-form-urlencoded"
51996
+ },
51997
+ body: new URLSearchParams({
51998
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
51999
+ device_code: deviceAuth.device_code,
52000
+ client_id: CLIENT_ID
52001
+ })
52002
+ });
52003
+ if (response.ok) return await response.json();
52004
+ const error = await parseOAuthError(response);
52005
+ if ("authorization_pending" === error) {
52006
+ await sleep(intervalMs);
52007
+ continue;
52008
+ }
52009
+ if ("slow_down" === error) {
52010
+ intervalMs += 1000;
52011
+ await sleep(intervalMs);
52012
+ continue;
52013
+ }
52014
+ if ("access_denied" === error) throw new Error("Device login was denied.");
52015
+ if ("expired_token" === error || "invalid_grant" === error) break;
52016
+ throw new Error(`Device login failed. ${error}`);
52017
+ }
52018
+ throw new Error("Device login expired before it was approved.");
52019
+ }
52020
+ async function createPkceLoginFlow(anvilUrl) {
51921
52021
  const codeVerifier = external_crypto_.randomBytes(48).toString("hex");
51922
52022
  const codeChallenge = oauth_login_base64url(external_crypto_.createHash("sha256").update(codeVerifier, "ascii").digest());
51923
52023
  const state = external_crypto_.randomBytes(16).toString("hex");
@@ -51927,10 +52027,9 @@ var __webpack_exports__ = {};
51927
52027
  if (!address || "string" == typeof address) throw new Error("No address");
51928
52028
  const port = address.port;
51929
52029
  const redirectUri = `http://127.0.0.1:${port}/oauth-callback`;
51930
- const SCOPES = "apps:read apps:write user:read";
51931
52030
  const authUrl = new URL(`${anvilUrl}/oauth/authorize`);
51932
52031
  authUrl.searchParams.set("response_type", "code");
51933
- authUrl.searchParams.set("client_id", "anvil-sync");
52032
+ authUrl.searchParams.set("client_id", CLIENT_ID);
51934
52033
  authUrl.searchParams.set("redirect_uri", redirectUri);
51935
52034
  authUrl.searchParams.set("scope", SCOPES);
51936
52035
  authUrl.searchParams.set("state", state);
@@ -51964,37 +52063,84 @@ var __webpack_exports__ = {};
51964
52063
  server.close();
51965
52064
  });
51966
52065
  });
51967
- logger_logger.info(chalk_source.blue("Link to your Anvil account to continue."));
52066
+ let closed = false;
52067
+ server.on("close", ()=>{
52068
+ closed = true;
52069
+ });
52070
+ const close = async ()=>{
52071
+ if (closed) return;
52072
+ closed = true;
52073
+ server.closeAllConnections();
52074
+ if (!server.listening) return;
52075
+ await new Promise((resolve)=>server.close(()=>resolve()));
52076
+ };
52077
+ return {
52078
+ authUrl,
52079
+ close,
52080
+ waitForLogin: (async ()=>{
52081
+ const { code, error, recvState } = await codePromise;
52082
+ if (recvState !== state) throw new Error("Invalid state received from OAuth callback");
52083
+ if (error) throw new Error(`Error received from OAuth callback: ${error}`);
52084
+ const tokenData = await exchangeAuthorizationCodeForTokens(anvilUrl, redirectUri, code, codeVerifier);
52085
+ return login(anvilUrl, {
52086
+ access_token: tokenData.access_token,
52087
+ refresh_token: tokenData.refresh_token,
52088
+ expires_in: tokenData.expires_in,
52089
+ scope: tokenData.scope
52090
+ });
52091
+ })()
52092
+ };
52093
+ }
52094
+ function raceLoginAttempts(attempts) {
52095
+ return new Promise((resolve, reject)=>{
52096
+ let remaining = attempts.length;
52097
+ let lastError = null;
52098
+ for (const attempt of attempts)attempt.then(resolve).catch((error)=>{
52099
+ if ("cancelled" === error.message) return;
52100
+ remaining -= 1;
52101
+ lastError = error;
52102
+ if (0 === remaining && lastError) reject(lastError);
52103
+ });
52104
+ });
52105
+ }
52106
+ async function runInteractiveLoginFlow(anvilUrl) {
52107
+ const pkceFlow = await createPkceLoginFlow(anvilUrl);
52108
+ const deviceAuth = await requestDeviceAuthorization(anvilUrl);
52109
+ const browserPrompt = createBrowserLaunchPrompt(pkceFlow.authUrl.toString());
52110
+ let settled = false;
52111
+ let spinnerStarted = false;
52112
+ logger_logger.info(chalk_source.dim(`Logging in to ${anvilUrl}`));
51968
52113
  console.log();
51969
- logger_logger.info(chalk_source.gray(` ${authUrl}`));
52114
+ logger_logger.info(chalk_source.dim("Visit:"), `${deviceAuth.verification_uri_complete || deviceAuth.verification_uri}`);
52115
+ logger_logger.info(chalk_source.dim("Device code:"), `${deviceAuth.user_code}`);
51970
52116
  console.log();
51971
- const { code, error, recvState } = await waitForOAuthWithPrompt(authUrl, codePromise);
51972
- if (recvState !== state) throw new Error("Invalid state received from OAuth callback");
51973
- if (error) throw new Error(`Error received from OAuth callback: ${error}`);
51974
- const tokenResponse = await fetch(`${anvilUrl}/oauth/token`, {
51975
- method: "POST",
51976
- headers: {
51977
- "Content-Type": "application/x-www-form-urlencoded"
51978
- },
51979
- body: new URLSearchParams({
51980
- grant_type: "authorization_code",
51981
- code: code,
51982
- redirect_uri: redirectUri,
51983
- client_id: "anvil-sync",
51984
- code_verifier: codeVerifier
51985
- })
52117
+ logger_logger.info("OR Press ENTER to open a browser...");
52118
+ browserPrompt.waitForOpen.then((opened)=>{
52119
+ if (opened && !settled) {
52120
+ spinnerStarted = true;
52121
+ logger_logger.progress("login", "Waiting for login to complete...");
52122
+ }
51986
52123
  });
51987
- if (!tokenResponse.ok) {
51988
- const errorText = await tokenResponse.text();
51989
- throw new Error(`Failed to exchange authorization code for token. ${errorText}`);
52124
+ try {
52125
+ const result = await raceLoginAttempts([
52126
+ pkceFlow.waitForLogin,
52127
+ pollDeviceAuthorization(anvilUrl, deviceAuth, {
52128
+ isCancelled: ()=>settled
52129
+ }).then(async (tokenData)=>login(anvilUrl, {
52130
+ access_token: tokenData.access_token,
52131
+ refresh_token: tokenData.refresh_token,
52132
+ expires_in: tokenData.expires_in,
52133
+ scope: tokenData.scope
52134
+ }))
52135
+ ]);
52136
+ settled = true;
52137
+ return result;
52138
+ } finally{
52139
+ settled = true;
52140
+ browserPrompt.close();
52141
+ if (spinnerStarted) logger_logger.progressEnd("login");
52142
+ await pkceFlow.close();
51990
52143
  }
51991
- const tokenData = await tokenResponse.json();
51992
- return login(anvilUrl, {
51993
- access_token: tokenData.access_token,
51994
- refresh_token: tokenData.refresh_token,
51995
- expires_in: tokenData.expires_in,
51996
- scope: tokenData.scope
51997
- });
51998
52144
  }
51999
52145
  const CHECKOUT_ERROR_VALUE = "__ERROR__";
52000
52146
  function isAbortLikeError(error) {
@@ -52747,10 +52893,11 @@ var __webpack_exports__ = {};
52747
52893
  });
52748
52894
  }
52749
52895
  function registerLoginCommand(program) {
52750
- const loginCommand = program.command("login [anvil-server-url]").description("Log in to Anvil using OAuth").alias("l").action(async (anvilUrl)=>{
52896
+ const loginCommand = program.command("login [anvil-server-url]").description("Log in to Anvil using OAuth").alias("l").option("-u, --url <ANVIL_URL>", "Specify Anvil server URL").action(async (anvilUrl, options)=>{
52751
52897
  try {
52752
- if (anvilUrl) {
52753
- anvilUrl = normalizeAnvilUrl(anvilUrl.trim());
52898
+ const requestedUrl = anvilUrl || options.url;
52899
+ if (requestedUrl) {
52900
+ anvilUrl = normalizeAnvilUrl(requestedUrl.trim());
52754
52901
  setConfig("anvilUrl", anvilUrl);
52755
52902
  } else anvilUrl = resolveAnvilUrl();
52756
52903
  const result = await runInteractiveLoginFlow(anvilUrl);
@@ -52760,7 +52907,7 @@ var __webpack_exports__ = {};
52760
52907
  process.exit(1);
52761
52908
  }
52762
52909
  });
52763
- loginCommand.addHelpText("after", "\n" + chalk_source.bold("Examples:") + "\n anvil login Log in to default Anvil server\n anvil login anvil.works Log in to anvil.works\n anvil login localhost Log in to local Anvil server\n");
52910
+ loginCommand.addHelpText("after", "\n" + chalk_source.bold("Examples:") + "\n anvil login Log in to default Anvil server\n anvil login anvil.works Log in to anvil.works\n anvil login --url localhost Log in to localhost\n anvil login localhost Log in to local Anvil server\n");
52764
52911
  }
52765
52912
  function getTotalLoggedInAccounts(urls) {
52766
52913
  return urls.reduce((total, url)=>total + auth_getAccountsForUrl(url).length, 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anvil-works/anvil-cli",
3
- "version": "0.5.11",
3
+ "version": "0.5.12",
4
4
  "description": "CLI tool for developing Anvil apps locally",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",