@anvil-works/anvil-cli 0.4.1 → 0.4.3

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 (4) hide show
  1. package/README.md +35 -30
  2. package/dist/cli.js +275 -63
  3. package/dist/index.js +149 -23
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -85,9 +85,7 @@ Or use the short form:
85
85
  anvil w
86
86
  ```
87
87
 
88
- > **Note:** `anvil sync` still works but is deprecated. Use `anvil watch` instead.
89
-
90
- You can specify the app ID explicitly:
88
+ You can specify the app ID explicitly:
91
89
 
92
90
  ```bash
93
91
  anvil watch -A YOUR_APP_ID
@@ -126,7 +124,6 @@ The diagram shows bidirectional sync:
126
124
  | -------------------------------------- | -------------------------------------------------------------------- |
127
125
  | `anvil watch [path]` | Watch directory for changes and sync to Anvil |
128
126
  | `anvil watch -V` or `--verbose` | Watch with verbose logging (detailed output) |
129
- | `anvil sync [path]` | Deprecated alias for `watch` |
130
127
  | `anvil w [path]` | Short form for watch |
131
128
  | `anvil login [anvil-server-url]` | Authenticate with Anvil using OAuth (supports smart URL handling) |
132
129
  | `anvil l [anvil-server-url]` | Short form for login |
@@ -153,13 +150,15 @@ anvil watch -A YOUR_APP_ID
153
150
 
154
151
  ### Anvil URL Detection
155
152
 
156
- anvil-cli automatically detects which Anvil server to use:
157
-
158
- 1. **Explicit URL**: If you specify `--url` or `-u` flag
159
- 2. **Git Remotes**: Automatically extracts URL from git remotes in your repository
160
- 3. **User Prompt**: If multiple accounts are available, prompts you to select one
161
- 4. **Global Config**: Falls back to `anvilUrl` in config file
162
- 5. **Default**: Uses `https://anvil.works` (or `http://localhost:3000` in dev mode)
153
+ anvil-cli automatically detects which Anvil server to use:
154
+
155
+ 1. **Explicit URL**: If you specify `--url` or `-u` flag
156
+ 2. **Git Remotes**: Automatically extracts URL from git remotes in your repository
157
+ 3. **Logged-in URLs**:
158
+ - If one logged-in URL is available, uses it automatically
159
+ - If multiple logged-in URLs are available, prompts you to select one
160
+ 4. **Global Config**: Falls back to `anvilUrl` in config file
161
+ 5. **Default**: Uses `https://anvil.works` (or `http://localhost:3000` in dev mode)
163
162
 
164
163
  **Examples:**
165
164
 
@@ -172,12 +171,12 @@ anvil watch --url anvil.works
172
171
  anvil watch --url localhost:3000
173
172
  anvil watch --url anvil.mycompany.com
174
173
 
175
- # If multiple accounts, you'll be prompted to select
176
- anvil watch
177
- # ? Multiple Anvil installations found. Which one would you like to use?
178
- # ❯ https://anvil.works
179
- # https://anvil.company.com
180
- # Cancel
174
+ # If multiple logged-in URLs, you'll be prompted to select
175
+ anvil watch
176
+ # ? Multiple Anvil installations found. Which one would you like to use?
177
+ # ❯ https://anvil.works
178
+ # https://anvil.company.com
179
+ # Cancel
181
180
  ```
182
181
 
183
182
  ## Configuration
@@ -269,18 +268,21 @@ anvil login anvil.works
269
268
  anvil login anvil.company-a.com
270
269
  anvil login anvil.company-b.com
271
270
 
272
- # When syncing, you'll be prompted to select which one to use
273
- # Or specify explicitly:
274
- anvil watch --url anvil.company-a.com
275
- ```
271
+ # If URL is not resolved from git remotes and multiple logged-in URLs exist,
272
+ # you'll be prompted to select which one to use
273
+ # Or specify explicitly:
274
+ anvil watch --url anvil.company-a.com
275
+ ```
276
276
 
277
277
  **URL Resolution Priority:**
278
278
 
279
- 1. Explicit `--url` flag
280
- 2. Git remote detection (from current repository)
281
- 3. User prompt (if multiple accounts available)
282
- 4. `anvilUrl` in config file
283
- 5. Default based on `NODE_ENV`:
279
+ 1. Explicit `--url` flag
280
+ 2. Git remote detection (from current repository)
281
+ 3. Logged-in URL selection:
282
+ - One URL: auto-select
283
+ - Multiple URLs: prompt to select
284
+ 4. `anvilUrl` in config file
285
+ 5. Default based on `NODE_ENV`:
284
286
  - Production: `https://anvil.works`
285
287
  - Development: `http://localhost:3000`
286
288
 
@@ -308,10 +310,13 @@ anvil logout anvil.mycompany.com
308
310
  anvil logout --url localhost:3000
309
311
  ```
310
312
 
311
- **Multiple accounts:** If you have multiple accounts and run `anvil logout` without specifying a URL, you'll be prompted to:
312
-
313
- - Logout from one account (then select which one)
314
- - Logout from all accounts
313
+ **Without URL:** `anvil logout` uses total logged-in account count across all URLs:
314
+
315
+ - If you have **one account total**, it logs out immediately
316
+ - If you have **multiple accounts total**, you'll be prompted to:
317
+
318
+ - Logout from one account (then select which one)
319
+ - Logout from all accounts
315
320
  - Cancel
316
321
 
317
322
  ## Troubleshooting
package/dist/cli.js CHANGED
@@ -47506,9 +47506,15 @@ var __webpack_exports__ = {};
47506
47506
  username;
47507
47507
  reconnectAttempts = 0;
47508
47508
  reconnectTimer = null;
47509
+ heartbeatTimer = null;
47510
+ pongTimeoutTimer = null;
47511
+ reconnectDelayMs;
47512
+ isClosing = false;
47509
47513
  sessionId;
47510
- MAX_RECONNECT_ATTEMPTS = 5;
47511
- RECONNECT_DELAY = 5000;
47514
+ RECONNECT_DELAY_BASE_MS = 5000;
47515
+ RECONNECT_DELAY_MAX_MS = 60000;
47516
+ HEARTBEAT_INTERVAL_MS = 30000;
47517
+ HEARTBEAT_TIMEOUT_MS = 10000;
47512
47518
  constructor(options){
47513
47519
  super();
47514
47520
  this.appId = options.appId;
@@ -47518,12 +47524,14 @@ var __webpack_exports__ = {};
47518
47524
  this.editSession = options.editSession;
47519
47525
  this.username = options.username;
47520
47526
  this.sessionId = Math.random().toString(36).substring(2, 10);
47527
+ this.reconnectDelayMs = this.RECONNECT_DELAY_BASE_MS;
47521
47528
  }
47522
47529
  async connect() {
47530
+ this.isClosing = false;
47523
47531
  if (this.ws?.readyState === ws_wrapper.OPEN) return void logger_logger.debug(`[WebSocket ${this.sessionId}]`, "WebSocket already open, skipping connection");
47524
47532
  if (this.ws) {
47525
47533
  logger_logger.debug(`[WebSocket ${this.sessionId}]`, "Closing existing WebSocket before reconnecting");
47526
- this.close();
47534
+ this.teardownSocket();
47527
47535
  }
47528
47536
  this.authToken = await auth_getValidAuthToken(this.anvilUrl, this.username);
47529
47537
  const wsUrl = getWebSocketUrl(this.appId, this.authToken, this.anvilUrl);
@@ -47533,6 +47541,8 @@ var __webpack_exports__ = {};
47533
47541
  logger_logger.verbose(chalk_source.green("🔌 Connected to Anvil WebSocket"));
47534
47542
  logger_logger.debug(`[WebSocket ${this.sessionId}]`, "WebSocket opened");
47535
47543
  this.reconnectAttempts = 0;
47544
+ this.reconnectDelayMs = this.RECONNECT_DELAY_BASE_MS;
47545
+ this.startHeartbeat();
47536
47546
  const subscribeMsg = {
47537
47547
  cmd: "WATCH_APP",
47538
47548
  app: this.appId,
@@ -47543,6 +47553,7 @@ var __webpack_exports__ = {};
47543
47553
  this.emit("connected", void 0);
47544
47554
  });
47545
47555
  this.ws.on("message", (data)=>{
47556
+ this.markSocketResponsive();
47546
47557
  try {
47547
47558
  const msg = JSON.parse(data.toString());
47548
47559
  logger_logger.debug(`[WebSocket ${this.sessionId}]`, `Message: ${JSON.stringify(msg)}`);
@@ -47551,8 +47562,12 @@ var __webpack_exports__ = {};
47551
47562
  logger_logger.error(chalk_source.red(`Failed to parse WebSocket message: ${error.message}`));
47552
47563
  }
47553
47564
  });
47565
+ this.ws.on("pong", ()=>{
47566
+ this.markSocketResponsive();
47567
+ });
47554
47568
  this.ws.on("close", (code, reason)=>{
47555
47569
  logger_logger.debug(`[WebSocket ${this.sessionId}]`, `Closed: code=${code}, reason=${reason.toString()}`);
47570
+ this.stopHeartbeat();
47556
47571
  this.ws = null;
47557
47572
  if (1008 === code || reason.toString().includes("unauthenticated")) {
47558
47573
  logger_logger.warn(chalk_source.yellow(" WebSocket authentication failed - changes from the Anvil Editor won't be detected"));
@@ -47560,21 +47575,12 @@ var __webpack_exports__ = {};
47560
47575
  this.emit("auth-failed", void 0);
47561
47576
  return;
47562
47577
  }
47578
+ if (this.isClosing) return;
47563
47579
  this.emit("disconnected", {
47564
47580
  code,
47565
47581
  reason: reason.toString()
47566
47582
  });
47567
- if (this.reconnectAttempts < this.MAX_RECONNECT_ATTEMPTS) {
47568
- this.reconnectAttempts++;
47569
- logger_logger.verbose(chalk_source.gray(`WebSocket disconnected, reconnecting in ${this.RECONNECT_DELAY / 1000}s (attempt ${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS})...`));
47570
- this.reconnectTimer = setTimeout(()=>{
47571
- this.connect().catch((error)=>{
47572
- this.emit("error", {
47573
- error: error instanceof Error ? error : new Error(String(error))
47574
- });
47575
- });
47576
- }, this.RECONNECT_DELAY);
47577
- } else logger_logger.warn(chalk_source.yellow(` WebSocket reconnection failed after ${this.MAX_RECONNECT_ATTEMPTS} attempts - changes from the Anvil Editor won't be detected`));
47583
+ this.scheduleReconnect();
47578
47584
  });
47579
47585
  this.ws.on("error", (error)=>{
47580
47586
  logger_logger.debug(`[WebSocket ${this.sessionId}]`, `Error: ${error.message}`);
@@ -47619,24 +47625,74 @@ var __webpack_exports__ = {};
47619
47625
  return this.editSession;
47620
47626
  }
47621
47627
  close() {
47628
+ this.isClosing = true;
47622
47629
  if (this.reconnectTimer) {
47623
47630
  logger_logger.debug(`[WebSocket ${this.sessionId}]`, "Clearing reconnect timer");
47624
47631
  clearTimeout(this.reconnectTimer);
47625
47632
  this.reconnectTimer = null;
47626
47633
  }
47627
- if (this.ws) {
47634
+ this.teardownSocket();
47635
+ }
47636
+ isConnected() {
47637
+ return this.ws?.readyState === ws_wrapper.OPEN;
47638
+ }
47639
+ scheduleReconnect() {
47640
+ if (this.reconnectTimer || this.isClosing) return;
47641
+ this.reconnectAttempts++;
47642
+ const delayMs = this.reconnectDelayMs;
47643
+ logger_logger.verbose(chalk_source.gray(`WebSocket disconnected, reconnecting in ${Math.round(delayMs / 1000)}s (attempt ${this.reconnectAttempts})...`));
47644
+ this.reconnectTimer = setTimeout(()=>{
47645
+ this.reconnectTimer = null;
47646
+ this.connect().catch((error)=>{
47647
+ this.emit("error", {
47648
+ error: error instanceof Error ? error : new Error(String(error))
47649
+ });
47650
+ });
47651
+ }, delayMs);
47652
+ this.reconnectDelayMs = Math.min(Math.round(1.5 * this.reconnectDelayMs), this.RECONNECT_DELAY_MAX_MS);
47653
+ }
47654
+ startHeartbeat() {
47655
+ this.stopHeartbeat();
47656
+ this.heartbeatTimer = setInterval(()=>{
47657
+ const ws = this.ws;
47658
+ if (!ws || ws.readyState !== ws_wrapper.OPEN) return;
47628
47659
  try {
47629
- logger_logger.debug(`[WebSocket ${this.sessionId}]`, "Closing WebSocket");
47630
- this.ws.removeAllListeners();
47631
- if (this.ws.readyState === ws_wrapper.OPEN) this.ws.close();
47632
- } catch (e) {
47633
- logger_logger.debug(`[WebSocket ${this.sessionId}]`, "Error closing WebSocket (ignoring):", e);
47660
+ ws.ping();
47661
+ } catch (error) {
47662
+ logger_logger.debug(`[WebSocket ${this.sessionId}]`, `Failed to send ping: ${error.message}`);
47663
+ ws.terminate();
47664
+ return;
47634
47665
  }
47635
- this.ws = null;
47666
+ this.pongTimeoutTimer = setTimeout(()=>{
47667
+ logger_logger.warn(chalk_source.yellow(" WebSocket heartbeat timed out - reconnecting"));
47668
+ ws.terminate();
47669
+ }, this.HEARTBEAT_TIMEOUT_MS);
47670
+ }, this.HEARTBEAT_INTERVAL_MS);
47671
+ }
47672
+ markSocketResponsive() {
47673
+ if (this.pongTimeoutTimer) {
47674
+ clearTimeout(this.pongTimeoutTimer);
47675
+ this.pongTimeoutTimer = null;
47636
47676
  }
47637
47677
  }
47638
- isConnected() {
47639
- return this.ws?.readyState === ws_wrapper.OPEN;
47678
+ stopHeartbeat() {
47679
+ if (this.heartbeatTimer) {
47680
+ clearInterval(this.heartbeatTimer);
47681
+ this.heartbeatTimer = null;
47682
+ }
47683
+ this.markSocketResponsive();
47684
+ }
47685
+ teardownSocket() {
47686
+ this.stopHeartbeat();
47687
+ if (!this.ws) return;
47688
+ try {
47689
+ logger_logger.debug(`[WebSocket ${this.sessionId}]`, "Closing WebSocket");
47690
+ this.ws.removeAllListeners();
47691
+ if (this.ws.readyState === ws_wrapper.OPEN || this.ws.readyState === ws_wrapper.CONNECTING) this.ws.terminate();
47692
+ } catch (e) {
47693
+ logger_logger.debug(`[WebSocket ${this.sessionId}]`, "Error closing WebSocket (ignoring):", e);
47694
+ }
47695
+ this.ws = null;
47640
47696
  }
47641
47697
  }
47642
47698
  async function detectRemoteChanges(gitService, oldCommitId, newCommitId) {
@@ -49364,7 +49420,11 @@ var __webpack_exports__ = {};
49364
49420
  wsClient = null;
49365
49421
  saveProcessor = null;
49366
49422
  syncManager = null;
49423
+ localChangePollTimer = null;
49424
+ isPollingLocalChanges = false;
49425
+ lastLocalStatusFingerprint = null;
49367
49426
  BRANCH_CHANGE_SETTLE_MS = 2000;
49427
+ LOCAL_CHANGE_POLL_MS = 2000;
49368
49428
  stagedOnly;
49369
49429
  constructor(repoPath, appId, options){
49370
49430
  super();
@@ -49502,6 +49562,10 @@ var __webpack_exports__ = {};
49502
49562
  this.fileWatcher.cleanup();
49503
49563
  this.fileWatcher = null;
49504
49564
  }
49565
+ if (this.localChangePollTimer) {
49566
+ clearInterval(this.localChangePollTimer);
49567
+ this.localChangePollTimer = null;
49568
+ }
49505
49569
  logger_logger.debug(`[Session ${this.sessionId}]`, "Cleanup complete");
49506
49570
  }
49507
49571
  async startWatching() {
@@ -49535,8 +49599,70 @@ var __webpack_exports__ = {};
49535
49599
  }
49536
49600
  });
49537
49601
  await this.fileWatcher.start(this.currentBranch);
49602
+ await this.initializeLocalChangeFingerprint();
49603
+ this.startLocalChangeFallbackPolling();
49538
49604
  await new Promise(()=>{});
49539
49605
  }
49606
+ async initializeLocalChangeFingerprint() {
49607
+ if (this.stagedOnly) return;
49608
+ try {
49609
+ const status = await this.gitService.getStatus();
49610
+ this.lastLocalStatusFingerprint = this.getLocalStatusFingerprint(status);
49611
+ } catch (error) {
49612
+ logger_logger.debug(`[Session ${this.sessionId}]`, `Failed to initialize local change fingerprint: ${error.message}`);
49613
+ }
49614
+ }
49615
+ startLocalChangeFallbackPolling() {
49616
+ if (this.stagedOnly || this.localChangePollTimer) return;
49617
+ this.localChangePollTimer = setInterval(()=>{
49618
+ this.pollForMissedLocalChanges();
49619
+ }, this.LOCAL_CHANGE_POLL_MS);
49620
+ }
49621
+ async pollForMissedLocalChanges() {
49622
+ if (this.isCleanedUp || this.isPausedForUserInput || this.isPollingLocalChanges) return;
49623
+ this.isPollingLocalChanges = true;
49624
+ try {
49625
+ const status = await this.gitService.getStatus();
49626
+ const fingerprint = this.getLocalStatusFingerprint(status);
49627
+ if (null === this.lastLocalStatusFingerprint) {
49628
+ this.lastLocalStatusFingerprint = fingerprint;
49629
+ return;
49630
+ }
49631
+ if (fingerprint !== this.lastLocalStatusFingerprint) {
49632
+ this.lastLocalStatusFingerprint = fingerprint;
49633
+ if (fingerprint) {
49634
+ logger_logger.debug(`[Session ${this.sessionId}]`, "Detected local change via fallback status poll");
49635
+ this.saveProcessor?.queueSave();
49636
+ }
49637
+ }
49638
+ } catch (error) {
49639
+ logger_logger.debug(`[Session ${this.sessionId}]`, `Fallback status poll failed: ${error.message}`);
49640
+ } finally{
49641
+ this.isPollingLocalChanges = false;
49642
+ }
49643
+ }
49644
+ getLocalStatusFingerprint(status) {
49645
+ const renamed = status.renamed.map((r)=>`${r.from}->${r.to}`).sort();
49646
+ const modified = [
49647
+ ...status.modified
49648
+ ].sort();
49649
+ const notAdded = [
49650
+ ...status.notAdded
49651
+ ].sort();
49652
+ const created = [
49653
+ ...status.created
49654
+ ].sort();
49655
+ const deleted = [
49656
+ ...status.deleted
49657
+ ].sort();
49658
+ return [
49659
+ ...renamed.map((r)=>`R:${r}`),
49660
+ ...modified.map((m)=>`M:${m}`),
49661
+ ...notAdded.map((n)=>`N:${n}`),
49662
+ ...created.map((c)=>`C:${c}`),
49663
+ ...deleted.map((d)=>`D:${d}`)
49664
+ ].join("|");
49665
+ }
49540
49666
  async handleFileChange(event, filePath, relativePath) {
49541
49667
  if (this.isCleanedUp || this.isPausedForUserInput) return;
49542
49668
  logger_logger.verbose(chalk_source.blue("File changed: ") + chalk_source.bold(relativePath));
@@ -49830,13 +49956,84 @@ var __webpack_exports__ = {};
49830
49956
  session.hasUncommittedChanges = hasUncommittedChanges;
49831
49957
  return session;
49832
49958
  }
49833
- function resolveUrlForFallback(explicitUrl) {
49834
- if (explicitUrl) return normalizeAnvilUrl(explicitUrl);
49835
- const availableUrls = getAvailableAnvilUrls();
49836
- if (availableUrls.length > 0) return availableUrls[0];
49959
+ function decideFallbackUrl(explicitUrl, availableUrls = getAvailableAnvilUrls()) {
49960
+ if (explicitUrl) return {
49961
+ source: "explicit",
49962
+ url: normalizeAnvilUrl(explicitUrl)
49963
+ };
49964
+ if (availableUrls.length > 1) return {
49965
+ source: "available-multiple",
49966
+ urls: availableUrls
49967
+ };
49968
+ if (1 === availableUrls.length) return {
49969
+ source: "available-single",
49970
+ url: availableUrls[0]
49971
+ };
49837
49972
  const fromConfig = getConfig("anvilUrl");
49838
- if ("string" == typeof fromConfig && fromConfig.trim()) return fromConfig.trim();
49839
- return isDevMode() ? "http://localhost:3000" : "https://anvil.works";
49973
+ if ("string" == typeof fromConfig && fromConfig.trim()) return {
49974
+ source: "config",
49975
+ url: normalizeAnvilUrl(fromConfig.trim())
49976
+ };
49977
+ return {
49978
+ source: "default",
49979
+ url: isDevMode() ? "http://localhost:3000" : "https://anvil.works"
49980
+ };
49981
+ }
49982
+ function decideUsernameForUrl(accounts) {
49983
+ if (0 === accounts.length) return {
49984
+ source: "none"
49985
+ };
49986
+ if (1 === accounts.length) return {
49987
+ source: "single",
49988
+ username: accounts[0]
49989
+ };
49990
+ return {
49991
+ source: "multiple",
49992
+ usernames: [
49993
+ ...accounts
49994
+ ]
49995
+ };
49996
+ }
49997
+ async function resolveUsernameForUrl(anvilUrl, explicitUsername, promptMessage = "Multiple accounts found. Which account owns this app?") {
49998
+ if (explicitUsername) return explicitUsername;
49999
+ const decision = decideUsernameForUrl(auth_getAccountsForUrl(anvilUrl));
50000
+ if ("none" === decision.source) return;
50001
+ if ("single" === decision.source) {
50002
+ logger_logger.verbose(chalk_source.cyan("Auto-selected account: ") + chalk_source.bold(decision.username));
50003
+ return decision.username;
50004
+ }
50005
+ const choices = decision.usernames.map((acct)=>({
50006
+ name: acct,
50007
+ value: acct
50008
+ }));
50009
+ choices.push({
50010
+ name: "Cancel",
50011
+ value: null
50012
+ });
50013
+ return logger_logger.select(promptMessage, choices, decision.usernames[0]);
50014
+ }
50015
+ async function confirmReverseLookupWithResolvedUser(anvilUrl, username) {
50016
+ const resolvedUsername = await resolveUsernameForUrl(anvilUrl, username, `Multiple accounts found for ${anvilUrl}. Which account should be used for app lookup?`);
50017
+ if (null === resolvedUsername) return null;
50018
+ const shouldContinue = await logger_logger.confirm(`Search ${anvilUrl} ${resolvedUsername ? `for ${resolvedUsername}` : ""} for matching app IDs? (slower)`, true);
50019
+ return {
50020
+ username: resolvedUsername,
50021
+ shouldContinue
50022
+ };
50023
+ }
50024
+ async function resolveUrlForFallback(explicitUrl) {
50025
+ const decision = decideFallbackUrl(explicitUrl);
50026
+ if ("available-multiple" !== decision.source) return decision.url;
50027
+ const choices = decision.urls.map((url)=>({
50028
+ name: url,
50029
+ value: url
50030
+ }));
50031
+ choices.push({
50032
+ name: "Cancel",
50033
+ value: null
50034
+ });
50035
+ const selectedUrl = await logger_logger.select("Multiple logged-in Anvil URLs found. Which one would you like to use?", choices, decision.urls[0]);
50036
+ return selectedUrl;
49840
50037
  }
49841
50038
  async function selectAppId(candidates) {
49842
50039
  if (0 === candidates.length) {
@@ -50348,6 +50545,7 @@ var __webpack_exports__ = {};
50348
50545
  let finalAppId;
50349
50546
  let anvilUrl;
50350
50547
  let username = explicitUsername;
50548
+ let fallbackUrl;
50351
50549
  if (explicitAppId) {
50352
50550
  finalAppId = explicitAppId;
50353
50551
  const remoteInfo = lookupRemoteInfoForAppId(explicitAppId, detectedFromAllRemotes);
@@ -50363,13 +50561,23 @@ var __webpack_exports__ = {};
50363
50561
  logger_logger.verbose(chalk_source.cyan("No app ID provided, attempting auto-detection..."));
50364
50562
  if (0 === filteredCandidates.length) {
50365
50563
  logger_logger.verbose(chalk_source.gray("No app IDs found in git remotes."));
50366
- const fallbackUrl = resolveUrlForFallback(explicitUrl);
50367
- const shouldContinue = await logger_logger.confirm(`Search ${fallbackUrl} for matching app IDs? (slower)`, true);
50368
- if (shouldContinue) {
50369
- logger_logger.progress("detect", `Searching ${fallbackUrl} ${explicitUsername ? `for ${explicitUsername}` : ''} for matching app IDs...`);
50564
+ const resolvedFallbackUrl = await resolveUrlForFallback(explicitUrl);
50565
+ if (null === resolvedFallbackUrl) {
50566
+ logger_logger.warn("Operation cancelled.");
50567
+ process.exit(0);
50568
+ }
50569
+ fallbackUrl = normalizeAnvilUrl(resolvedFallbackUrl);
50570
+ const lookupDecision = await confirmReverseLookupWithResolvedUser(fallbackUrl, username);
50571
+ if (null === lookupDecision) {
50572
+ logger_logger.warn("Operation cancelled.");
50573
+ process.exit(0);
50574
+ }
50575
+ username = lookupDecision.username;
50576
+ if (lookupDecision.shouldContinue) {
50577
+ logger_logger.progress("detect", `Searching ${fallbackUrl} ${username ? `for ${username}` : ""} for matching app IDs...`);
50370
50578
  const reverseLookupCandidates = await detectAppIdsByCommitLookup(repoPath, {
50371
50579
  anvilUrl: fallbackUrl,
50372
- username: explicitUsername,
50580
+ username,
50373
50581
  includeRemotes: false
50374
50582
  });
50375
50583
  logger_logger.progressEnd("detect");
@@ -50401,32 +50609,24 @@ var __webpack_exports__ = {};
50401
50609
  }
50402
50610
  }
50403
50611
  if (explicitUrl) anvilUrl = normalizeAnvilUrl(explicitUrl);
50404
- else if (!anvilUrl) anvilUrl = resolveUrlForFallback();
50612
+ else if (!anvilUrl) if (fallbackUrl) anvilUrl = fallbackUrl;
50613
+ else {
50614
+ const resolvedFallbackUrl = await resolveUrlForFallback();
50615
+ if (null === resolvedFallbackUrl) {
50616
+ logger_logger.warn("Operation cancelled.");
50617
+ process.exit(0);
50618
+ }
50619
+ anvilUrl = normalizeAnvilUrl(resolvedFallbackUrl);
50620
+ }
50405
50621
  anvilUrl = normalizeAnvilUrl(anvilUrl);
50406
50622
  logger_logger.verbose(chalk_source.green("Using app ID: ") + chalk_source.bold(finalAppId));
50407
50623
  logger_logger.verbose(chalk_source.cyan("Using Anvil URL: ") + chalk_source.bold(anvilUrl));
50408
- if (!username) {
50409
- const accounts = auth_getAccountsForUrl(anvilUrl);
50410
- if (1 === accounts.length) {
50411
- username = accounts[0];
50412
- logger_logger.verbose(chalk_source.cyan("Auto-selected account: ") + chalk_source.bold(username));
50413
- } else if (accounts.length > 1) {
50414
- const choices = accounts.map((acct)=>({
50415
- name: acct,
50416
- value: acct
50417
- }));
50418
- choices.push({
50419
- name: "Cancel",
50420
- value: null
50421
- });
50422
- const selected = await logger_logger.select("Multiple accounts found. Which account owns this app?", choices, accounts[0]);
50423
- if (null === selected) {
50424
- logger_logger.warn("Operation cancelled.");
50425
- process.exit(0);
50426
- }
50427
- username = selected;
50428
- }
50624
+ const resolvedUsername = await resolveUsernameForUrl(anvilUrl, username);
50625
+ if (null === resolvedUsername) {
50626
+ logger_logger.warn("Operation cancelled.");
50627
+ process.exit(0);
50429
50628
  }
50629
+ username = resolvedUsername;
50430
50630
  if (username) logger_logger.verbose(chalk_source.cyan("Using account: ") + chalk_source.bold(username));
50431
50631
  if (!hasTokensForUrl(anvilUrl, username)) {
50432
50632
  if (username) logger_logger.error(`Not logged in to ${anvilUrl} as ${username}`);
@@ -51233,6 +51433,13 @@ var __webpack_exports__ = {};
51233
51433
  });
51234
51434
  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");
51235
51435
  }
51436
+ function getTotalLoggedInAccounts(urls) {
51437
+ return urls.reduce((total, url)=>total + auth_getAccountsForUrl(url).length, 0);
51438
+ }
51439
+ function formatMultiAccountLogoutPrompt(totalAccounts, urlCount) {
51440
+ if (urlCount <= 1) return `You have ${totalAccounts} logged-in accounts. What would you like to do?`;
51441
+ return `You have ${totalAccounts} logged-in accounts across ${urlCount} URLs. What would you like to do?`;
51442
+ }
51236
51443
  function registerLogoutCommand(program) {
51237
51444
  const logoutCommand = program.command("logout [anvil-server-url]").description("Log out from Anvil (optionally specify URL to logout from specific installation)").option("-u, --url <ANVIL_URL>", "Specify Anvil server URL to logout from").option("-U, --user <USERNAME>", "Specify which user account to logout from").alias("lo").action(async (anvilUrl, options)=>{
51238
51445
  try {
@@ -51246,8 +51453,9 @@ var __webpack_exports__ = {};
51246
51453
  else logger_logger.info(result.message || `Not logged in to ${normalized}. No action needed.`);
51247
51454
  } else {
51248
51455
  const availableUrls = getAvailableAnvilUrls();
51249
- if (0 === availableUrls.length) logger_logger.warn("No logged-in accounts found.");
51250
- else if (1 === availableUrls.length) {
51456
+ const totalAccounts = getTotalLoggedInAccounts(availableUrls);
51457
+ if (0 === totalAccounts) logger_logger.warn("No logged-in accounts found.");
51458
+ else if (1 === totalAccounts) {
51251
51459
  const result = await logout();
51252
51460
  if (result.loggedOut) logger_logger.success("Logged out.");
51253
51461
  } else {
@@ -51265,7 +51473,7 @@ var __webpack_exports__ = {};
51265
51473
  value: "cancel"
51266
51474
  }
51267
51475
  ];
51268
- const action = await logger_logger.select(`You have ${availableUrls.length} logged-in accounts. What would you like to do?`, choices, "one");
51476
+ const action = await logger_logger.select(formatMultiAccountLogoutPrompt(totalAccounts, availableUrls.length), choices, "one");
51269
51477
  if ("cancel" === action) return void logger_logger.info("Logout cancelled.");
51270
51478
  if ("all" === action) {
51271
51479
  const result = await logout();
@@ -51364,17 +51572,21 @@ var __webpack_exports__ = {};
51364
51572
  let totalAccounts = 0;
51365
51573
  for (const url of urls){
51366
51574
  const urlData = authTokens[url];
51367
- if (urlData && "object" == typeof urlData && !Array.isArray(urlData)) totalAccounts += Object.keys(urlData).length;
51575
+ if (urlData && "object" == typeof urlData && !Array.isArray(urlData)) {
51576
+ const usernames = Object.keys(urlData);
51577
+ totalAccounts += usernames.filter((username)=>hasTokensForUrl(url, username)).length;
51578
+ }
51368
51579
  }
51369
- console.log(chalk_source.cyan("authTokens") + ` = ${chalk_source.gray(`${totalAccounts} account(s) across ${urls.length} URL(s)`)}`);
51580
+ console.log(chalk_source.cyan("authTokens") + ` = ${chalk_source.gray(`${totalAccounts} logged-in account(s) across ${urls.length} URL(s)`)}`);
51370
51581
  for (const url of urls){
51371
51582
  const urlData = authTokens[url];
51372
51583
  if (urlData && "object" == typeof urlData && !Array.isArray(urlData)) {
51373
51584
  const usernames = Object.keys(urlData).sort();
51374
51585
  if (usernames.length > 0) for (const username of usernames){
51375
51586
  const accountData = urlData[username];
51376
- const hasToken = !!(accountData?.authToken || accountData?.refreshToken);
51377
- const status = hasToken ? chalk_source.green("✓ logged in") : chalk_source.gray("(no tokens)");
51587
+ const hasConfigTokens = !!(accountData?.authToken || accountData?.refreshToken);
51588
+ const hasAnyTokens = hasTokensForUrl(url, username);
51589
+ const status = hasConfigTokens ? chalk_source.green("✓ logged in (config)") : hasAnyTokens ? chalk_source.green("✓ logged in (keychain)") : chalk_source.gray("(no tokens)");
51378
51590
  console.log(chalk_source.gray(` ${url}: ${username} ${status}`));
51379
51591
  }
51380
51592
  }
package/dist/index.js CHANGED
@@ -22643,9 +22643,15 @@ var __webpack_exports__ = {};
22643
22643
  username;
22644
22644
  reconnectAttempts = 0;
22645
22645
  reconnectTimer = null;
22646
+ heartbeatTimer = null;
22647
+ pongTimeoutTimer = null;
22648
+ reconnectDelayMs;
22649
+ isClosing = false;
22646
22650
  sessionId;
22647
- MAX_RECONNECT_ATTEMPTS = 5;
22648
- RECONNECT_DELAY = 5000;
22651
+ RECONNECT_DELAY_BASE_MS = 5000;
22652
+ RECONNECT_DELAY_MAX_MS = 60000;
22653
+ HEARTBEAT_INTERVAL_MS = 30000;
22654
+ HEARTBEAT_TIMEOUT_MS = 10000;
22649
22655
  constructor(options){
22650
22656
  super();
22651
22657
  this.appId = options.appId;
@@ -22655,12 +22661,14 @@ var __webpack_exports__ = {};
22655
22661
  this.editSession = options.editSession;
22656
22662
  this.username = options.username;
22657
22663
  this.sessionId = Math.random().toString(36).substring(2, 10);
22664
+ this.reconnectDelayMs = this.RECONNECT_DELAY_BASE_MS;
22658
22665
  }
22659
22666
  async connect() {
22667
+ this.isClosing = false;
22660
22668
  if (this.ws?.readyState === ws_wrapper.OPEN) return void logger_logger.debug(`[WebSocket ${this.sessionId}]`, "WebSocket already open, skipping connection");
22661
22669
  if (this.ws) {
22662
22670
  logger_logger.debug(`[WebSocket ${this.sessionId}]`, "Closing existing WebSocket before reconnecting");
22663
- this.close();
22671
+ this.teardownSocket();
22664
22672
  }
22665
22673
  this.authToken = await auth_getValidAuthToken(this.anvilUrl, this.username);
22666
22674
  const wsUrl = getWebSocketUrl(this.appId, this.authToken, this.anvilUrl);
@@ -22670,6 +22678,8 @@ var __webpack_exports__ = {};
22670
22678
  logger_logger.verbose(chalk_source.green("🔌 Connected to Anvil WebSocket"));
22671
22679
  logger_logger.debug(`[WebSocket ${this.sessionId}]`, "WebSocket opened");
22672
22680
  this.reconnectAttempts = 0;
22681
+ this.reconnectDelayMs = this.RECONNECT_DELAY_BASE_MS;
22682
+ this.startHeartbeat();
22673
22683
  const subscribeMsg = {
22674
22684
  cmd: "WATCH_APP",
22675
22685
  app: this.appId,
@@ -22680,6 +22690,7 @@ var __webpack_exports__ = {};
22680
22690
  this.emit("connected", void 0);
22681
22691
  });
22682
22692
  this.ws.on("message", (data)=>{
22693
+ this.markSocketResponsive();
22683
22694
  try {
22684
22695
  const msg = JSON.parse(data.toString());
22685
22696
  logger_logger.debug(`[WebSocket ${this.sessionId}]`, `Message: ${JSON.stringify(msg)}`);
@@ -22688,8 +22699,12 @@ var __webpack_exports__ = {};
22688
22699
  logger_logger.error(chalk_source.red(`Failed to parse WebSocket message: ${error.message}`));
22689
22700
  }
22690
22701
  });
22702
+ this.ws.on("pong", ()=>{
22703
+ this.markSocketResponsive();
22704
+ });
22691
22705
  this.ws.on("close", (code, reason)=>{
22692
22706
  logger_logger.debug(`[WebSocket ${this.sessionId}]`, `Closed: code=${code}, reason=${reason.toString()}`);
22707
+ this.stopHeartbeat();
22693
22708
  this.ws = null;
22694
22709
  if (1008 === code || reason.toString().includes("unauthenticated")) {
22695
22710
  logger_logger.warn(chalk_source.yellow(" WebSocket authentication failed - changes from the Anvil Editor won't be detected"));
@@ -22697,21 +22712,12 @@ var __webpack_exports__ = {};
22697
22712
  this.emit("auth-failed", void 0);
22698
22713
  return;
22699
22714
  }
22715
+ if (this.isClosing) return;
22700
22716
  this.emit("disconnected", {
22701
22717
  code,
22702
22718
  reason: reason.toString()
22703
22719
  });
22704
- if (this.reconnectAttempts < this.MAX_RECONNECT_ATTEMPTS) {
22705
- this.reconnectAttempts++;
22706
- logger_logger.verbose(chalk_source.gray(`WebSocket disconnected, reconnecting in ${this.RECONNECT_DELAY / 1000}s (attempt ${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS})...`));
22707
- this.reconnectTimer = setTimeout(()=>{
22708
- this.connect().catch((error)=>{
22709
- this.emit("error", {
22710
- error: error instanceof Error ? error : new Error(String(error))
22711
- });
22712
- });
22713
- }, this.RECONNECT_DELAY);
22714
- } else logger_logger.warn(chalk_source.yellow(` WebSocket reconnection failed after ${this.MAX_RECONNECT_ATTEMPTS} attempts - changes from the Anvil Editor won't be detected`));
22720
+ this.scheduleReconnect();
22715
22721
  });
22716
22722
  this.ws.on("error", (error)=>{
22717
22723
  logger_logger.debug(`[WebSocket ${this.sessionId}]`, `Error: ${error.message}`);
@@ -22756,24 +22762,74 @@ var __webpack_exports__ = {};
22756
22762
  return this.editSession;
22757
22763
  }
22758
22764
  close() {
22765
+ this.isClosing = true;
22759
22766
  if (this.reconnectTimer) {
22760
22767
  logger_logger.debug(`[WebSocket ${this.sessionId}]`, "Clearing reconnect timer");
22761
22768
  clearTimeout(this.reconnectTimer);
22762
22769
  this.reconnectTimer = null;
22763
22770
  }
22764
- if (this.ws) {
22771
+ this.teardownSocket();
22772
+ }
22773
+ isConnected() {
22774
+ return this.ws?.readyState === ws_wrapper.OPEN;
22775
+ }
22776
+ scheduleReconnect() {
22777
+ if (this.reconnectTimer || this.isClosing) return;
22778
+ this.reconnectAttempts++;
22779
+ const delayMs = this.reconnectDelayMs;
22780
+ logger_logger.verbose(chalk_source.gray(`WebSocket disconnected, reconnecting in ${Math.round(delayMs / 1000)}s (attempt ${this.reconnectAttempts})...`));
22781
+ this.reconnectTimer = setTimeout(()=>{
22782
+ this.reconnectTimer = null;
22783
+ this.connect().catch((error)=>{
22784
+ this.emit("error", {
22785
+ error: error instanceof Error ? error : new Error(String(error))
22786
+ });
22787
+ });
22788
+ }, delayMs);
22789
+ this.reconnectDelayMs = Math.min(Math.round(1.5 * this.reconnectDelayMs), this.RECONNECT_DELAY_MAX_MS);
22790
+ }
22791
+ startHeartbeat() {
22792
+ this.stopHeartbeat();
22793
+ this.heartbeatTimer = setInterval(()=>{
22794
+ const ws = this.ws;
22795
+ if (!ws || ws.readyState !== ws_wrapper.OPEN) return;
22765
22796
  try {
22766
- logger_logger.debug(`[WebSocket ${this.sessionId}]`, "Closing WebSocket");
22767
- this.ws.removeAllListeners();
22768
- if (this.ws.readyState === ws_wrapper.OPEN) this.ws.close();
22769
- } catch (e) {
22770
- logger_logger.debug(`[WebSocket ${this.sessionId}]`, "Error closing WebSocket (ignoring):", e);
22797
+ ws.ping();
22798
+ } catch (error) {
22799
+ logger_logger.debug(`[WebSocket ${this.sessionId}]`, `Failed to send ping: ${error.message}`);
22800
+ ws.terminate();
22801
+ return;
22771
22802
  }
22772
- this.ws = null;
22803
+ this.pongTimeoutTimer = setTimeout(()=>{
22804
+ logger_logger.warn(chalk_source.yellow(" WebSocket heartbeat timed out - reconnecting"));
22805
+ ws.terminate();
22806
+ }, this.HEARTBEAT_TIMEOUT_MS);
22807
+ }, this.HEARTBEAT_INTERVAL_MS);
22808
+ }
22809
+ markSocketResponsive() {
22810
+ if (this.pongTimeoutTimer) {
22811
+ clearTimeout(this.pongTimeoutTimer);
22812
+ this.pongTimeoutTimer = null;
22773
22813
  }
22774
22814
  }
22775
- isConnected() {
22776
- return this.ws?.readyState === ws_wrapper.OPEN;
22815
+ stopHeartbeat() {
22816
+ if (this.heartbeatTimer) {
22817
+ clearInterval(this.heartbeatTimer);
22818
+ this.heartbeatTimer = null;
22819
+ }
22820
+ this.markSocketResponsive();
22821
+ }
22822
+ teardownSocket() {
22823
+ this.stopHeartbeat();
22824
+ if (!this.ws) return;
22825
+ try {
22826
+ logger_logger.debug(`[WebSocket ${this.sessionId}]`, "Closing WebSocket");
22827
+ this.ws.removeAllListeners();
22828
+ if (this.ws.readyState === ws_wrapper.OPEN || this.ws.readyState === ws_wrapper.CONNECTING) this.ws.terminate();
22829
+ } catch (e) {
22830
+ logger_logger.debug(`[WebSocket ${this.sessionId}]`, "Error closing WebSocket (ignoring):", e);
22831
+ }
22832
+ this.ws = null;
22777
22833
  }
22778
22834
  }
22779
22835
  async function detectRemoteChanges(gitService, oldCommitId, newCommitId) {
@@ -25000,7 +25056,11 @@ var __webpack_exports__ = {};
25000
25056
  wsClient = null;
25001
25057
  saveProcessor = null;
25002
25058
  syncManager = null;
25059
+ localChangePollTimer = null;
25060
+ isPollingLocalChanges = false;
25061
+ lastLocalStatusFingerprint = null;
25003
25062
  BRANCH_CHANGE_SETTLE_MS = 2000;
25063
+ LOCAL_CHANGE_POLL_MS = 2000;
25004
25064
  stagedOnly;
25005
25065
  constructor(repoPath, appId, options){
25006
25066
  super();
@@ -25138,6 +25198,10 @@ var __webpack_exports__ = {};
25138
25198
  this.fileWatcher.cleanup();
25139
25199
  this.fileWatcher = null;
25140
25200
  }
25201
+ if (this.localChangePollTimer) {
25202
+ clearInterval(this.localChangePollTimer);
25203
+ this.localChangePollTimer = null;
25204
+ }
25141
25205
  logger_logger.debug(`[Session ${this.sessionId}]`, "Cleanup complete");
25142
25206
  }
25143
25207
  async startWatching() {
@@ -25171,8 +25235,70 @@ var __webpack_exports__ = {};
25171
25235
  }
25172
25236
  });
25173
25237
  await this.fileWatcher.start(this.currentBranch);
25238
+ await this.initializeLocalChangeFingerprint();
25239
+ this.startLocalChangeFallbackPolling();
25174
25240
  await new Promise(()=>{});
25175
25241
  }
25242
+ async initializeLocalChangeFingerprint() {
25243
+ if (this.stagedOnly) return;
25244
+ try {
25245
+ const status = await this.gitService.getStatus();
25246
+ this.lastLocalStatusFingerprint = this.getLocalStatusFingerprint(status);
25247
+ } catch (error) {
25248
+ logger_logger.debug(`[Session ${this.sessionId}]`, `Failed to initialize local change fingerprint: ${error.message}`);
25249
+ }
25250
+ }
25251
+ startLocalChangeFallbackPolling() {
25252
+ if (this.stagedOnly || this.localChangePollTimer) return;
25253
+ this.localChangePollTimer = setInterval(()=>{
25254
+ this.pollForMissedLocalChanges();
25255
+ }, this.LOCAL_CHANGE_POLL_MS);
25256
+ }
25257
+ async pollForMissedLocalChanges() {
25258
+ if (this.isCleanedUp || this.isPausedForUserInput || this.isPollingLocalChanges) return;
25259
+ this.isPollingLocalChanges = true;
25260
+ try {
25261
+ const status = await this.gitService.getStatus();
25262
+ const fingerprint = this.getLocalStatusFingerprint(status);
25263
+ if (null === this.lastLocalStatusFingerprint) {
25264
+ this.lastLocalStatusFingerprint = fingerprint;
25265
+ return;
25266
+ }
25267
+ if (fingerprint !== this.lastLocalStatusFingerprint) {
25268
+ this.lastLocalStatusFingerprint = fingerprint;
25269
+ if (fingerprint) {
25270
+ logger_logger.debug(`[Session ${this.sessionId}]`, "Detected local change via fallback status poll");
25271
+ this.saveProcessor?.queueSave();
25272
+ }
25273
+ }
25274
+ } catch (error) {
25275
+ logger_logger.debug(`[Session ${this.sessionId}]`, `Fallback status poll failed: ${error.message}`);
25276
+ } finally{
25277
+ this.isPollingLocalChanges = false;
25278
+ }
25279
+ }
25280
+ getLocalStatusFingerprint(status) {
25281
+ const renamed = status.renamed.map((r)=>`${r.from}->${r.to}`).sort();
25282
+ const modified = [
25283
+ ...status.modified
25284
+ ].sort();
25285
+ const notAdded = [
25286
+ ...status.notAdded
25287
+ ].sort();
25288
+ const created = [
25289
+ ...status.created
25290
+ ].sort();
25291
+ const deleted = [
25292
+ ...status.deleted
25293
+ ].sort();
25294
+ return [
25295
+ ...renamed.map((r)=>`R:${r}`),
25296
+ ...modified.map((m)=>`M:${m}`),
25297
+ ...notAdded.map((n)=>`N:${n}`),
25298
+ ...created.map((c)=>`C:${c}`),
25299
+ ...deleted.map((d)=>`D:${d}`)
25300
+ ].join("|");
25301
+ }
25176
25302
  async handleFileChange(event, filePath, relativePath) {
25177
25303
  if (this.isCleanedUp || this.isPausedForUserInput) return;
25178
25304
  logger_logger.verbose(chalk_source.blue("File changed: ") + chalk_source.bold(relativePath));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anvil-works/anvil-cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "CLI tool for developing Anvil apps locally",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",