@anvil-works/anvil-cli 0.5.11 → 0.5.13

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 +225 -54
  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) {
@@ -52501,10 +52647,19 @@ var __webpack_exports__ = {};
52501
52647
  return appId;
52502
52648
  }
52503
52649
  async function resolveCheckoutUrl(explicitUrl, parsedUrl) {
52504
- if (explicitUrl) return normalizeAnvilUrl(explicitUrl);
52505
- if (parsedUrl) return normalizeAnvilUrl(parsedUrl);
52650
+ if (explicitUrl) {
52651
+ logger_logger.verbose(chalk_source.cyan("Using Anvil URL from --url: ") + chalk_source.bold(normalizeAnvilUrl(explicitUrl)));
52652
+ return normalizeAnvilUrl(explicitUrl);
52653
+ }
52654
+ if (parsedUrl) {
52655
+ logger_logger.verbose(chalk_source.cyan("Using Anvil URL from checkout input: ") + chalk_source.bold(normalizeAnvilUrl(parsedUrl)));
52656
+ return normalizeAnvilUrl(parsedUrl);
52657
+ }
52506
52658
  const decision = decideFallbackUrl(void 0);
52507
- if ("available-multiple" !== decision.source) return decision.url;
52659
+ if ("available-multiple" !== decision.source) {
52660
+ if (decision.url) logger_logger.verbose(chalk_source.cyan("Using configured Anvil URL: ") + chalk_source.bold(decision.url));
52661
+ return decision.url;
52662
+ }
52508
52663
  const choices = decision.urls.map((url)=>({
52509
52664
  name: url,
52510
52665
  value: url
@@ -52550,6 +52705,7 @@ var __webpack_exports__ = {};
52550
52705
  }
52551
52706
  async function ensureCheckoutAuthToken(anvilUrl, username, deps = defaultCheckoutDeps) {
52552
52707
  try {
52708
+ logger_logger.verbose(chalk_source.cyan("Checking auth token for: ") + chalk_source.bold(username ? `${username} @ ${anvilUrl}` : anvilUrl));
52553
52709
  return await deps.getValidAuthToken(anvilUrl, username);
52554
52710
  } catch (e) {
52555
52711
  const interactive = process.stdin.isTTY && process.stdout.isTTY;
@@ -52596,11 +52752,15 @@ var __webpack_exports__ = {};
52596
52752
  if (!selectedInput) return void logger_logger.info("Checkout cancelled.");
52597
52753
  checkoutInput = selectedInput;
52598
52754
  }
52755
+ logger_logger.verbose(chalk_source.cyan("Checkout input: ") + chalk_source.bold(checkoutInput));
52599
52756
  const parsed = parseCheckoutInput(checkoutInput);
52757
+ logger_logger.verbose(chalk_source.cyan("Resolved app ID: ") + chalk_source.bold(parsed.appId));
52600
52758
  const anvilUrl = preselectedAnvilUrl || await resolveCheckoutUrl(options.url, parsed.detectedUrl);
52601
52759
  if (!anvilUrl) return void logger_logger.info("Checkout cancelled.");
52602
52760
  const resolvedUsername = preselectedUsername || await resolveCheckoutUsername(anvilUrl, options.user);
52603
52761
  if (null === resolvedUsername) return void logger_logger.info("Checkout cancelled.");
52762
+ if (resolvedUsername) logger_logger.verbose(chalk_source.cyan("Using account: ") + chalk_source.bold(resolvedUsername));
52763
+ else logger_logger.verbose(chalk_source.cyan("No account preselected; resolving after auth token lookup."));
52604
52764
  const authToken = await ensureCheckoutAuthToken(anvilUrl, resolvedUsername, deps);
52605
52765
  let checkoutUsername = resolvedUsername;
52606
52766
  if (!checkoutUsername) {
@@ -52609,13 +52769,22 @@ var __webpack_exports__ = {};
52609
52769
  if (!inferredUsername) throw new Error(`Could not determine account for ${anvilUrl}. Use --user <USERNAME> so checkout can bind repository credentials.`);
52610
52770
  checkoutUsername = inferredUsername;
52611
52771
  }
52772
+ logger_logger.verbose(chalk_source.cyan("Checkout account: ") + chalk_source.bold(checkoutUsername));
52773
+ logger_logger.verbose(chalk_source.blue(`Validating app ID ${parsed.appId} with Anvil server...`));
52612
52774
  const validation = await deps.validateAppId(parsed.appId, anvilUrl, checkoutUsername);
52613
52775
  if (!validation.valid) throw new Error(validation.error || `App '${parsed.appId}' is not accessible on ${anvilUrl}.`);
52776
+ logger_logger.verbose(chalk_source.green(`✔ App ID validated successfully`));
52777
+ if (validation.app_name) logger_logger.verbose(chalk_source.gray(` App name: ${validation.app_name}`));
52614
52778
  const destinationDir = options.directory || getDefaultDestinationDirectory(parsed.appId, validation.app_name);
52615
52779
  const destinationPath = external_path_default().resolve(process.cwd(), destinationDir);
52616
52780
  const destinationDisplay = formatPathForDisplay(destinationPath);
52781
+ logger_logger.verbose(chalk_source.cyan("Resolved destination: ") + chalk_source.bold(destinationDisplay));
52617
52782
  await validateCheckoutDestination(destinationPath, options.force);
52618
52783
  const cloneUrl = getGitPushUrl(parsed.appId, authToken, anvilUrl);
52784
+ logger_logger.verbose(chalk_source.cyan("Clone source: ") + chalk_source.bold(`${anvilUrl}/git/${parsed.appId}.git`));
52785
+ if (options.branch) logger_logger.verbose(chalk_source.cyan("Requested branch: ") + chalk_source.bold(options.branch));
52786
+ if ("number" == typeof options.depth) logger_logger.verbose(chalk_source.cyan("Clone depth: ") + chalk_source.bold(String(options.depth)));
52787
+ if (options.singleBranch) logger_logger.verbose(chalk_source.cyan("Single-branch clone enabled"));
52619
52788
  logger_logger.progress("checkout", `Checking out app ${parsed.appId} from ${anvilUrl}`);
52620
52789
  logger_logger.info(chalk_source.gray(` Destination directory: ${destinationDisplay}`));
52621
52790
  await deps.clone(cloneUrl, destinationPath, {
@@ -52635,6 +52804,7 @@ var __webpack_exports__ = {};
52635
52804
  username: checkoutUsername,
52636
52805
  remoteName
52637
52806
  });
52807
+ logger_logger.verbose(chalk_source.cyan("Configured Git auth bridge for remote: ") + chalk_source.bold(remoteName));
52638
52808
  } catch (e) {
52639
52809
  throw new Error(`Checkout clone succeeded, but failed to configure repository credentials: ${errors_getErrorMessage(e)}. The repository exists at ${destinationDisplay}, but Git auth bridge setup is incomplete.`);
52640
52810
  }
@@ -52747,10 +52917,11 @@ var __webpack_exports__ = {};
52747
52917
  });
52748
52918
  }
52749
52919
  function registerLoginCommand(program) {
52750
- const loginCommand = program.command("login [anvil-server-url]").description("Log in to Anvil using OAuth").alias("l").action(async (anvilUrl)=>{
52920
+ 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
52921
  try {
52752
- if (anvilUrl) {
52753
- anvilUrl = normalizeAnvilUrl(anvilUrl.trim());
52922
+ const requestedUrl = anvilUrl || options.url;
52923
+ if (requestedUrl) {
52924
+ anvilUrl = normalizeAnvilUrl(requestedUrl.trim());
52754
52925
  setConfig("anvilUrl", anvilUrl);
52755
52926
  } else anvilUrl = resolveAnvilUrl();
52756
52927
  const result = await runInteractiveLoginFlow(anvilUrl);
@@ -52760,7 +52931,7 @@ var __webpack_exports__ = {};
52760
52931
  process.exit(1);
52761
52932
  }
52762
52933
  });
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");
52934
+ 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
52935
  }
52765
52936
  function getTotalLoggedInAccounts(urls) {
52766
52937
  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.13",
4
4
  "description": "CLI tool for developing Anvil apps locally",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",