flightdesk 0.2.0 → 0.2.2

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/main.js +404 -81
  2. package/main.js.map +4 -4
  3. package/package.json +1 -1
package/main.js CHANGED
@@ -3579,6 +3579,86 @@ var fs2 = __toESM(require("node:fs"));
3579
3579
  var readline2 = __toESM(require("node:readline"));
3580
3580
  var playwright = null;
3581
3581
  var USER_DATA_DIR = path2.join(os2.homedir(), ".flightdesk", "chromium-profile");
3582
+ var STORAGE_STATE_FILE = path2.join(os2.homedir(), ".flightdesk", "auth-state.json");
3583
+ var PersistentBrowser = class {
3584
+ constructor(headless = true) {
3585
+ this.browser = null;
3586
+ this.context = null;
3587
+ this.page = null;
3588
+ this.headless = headless;
3589
+ }
3590
+ /**
3591
+ * Initialize the browser context (if not already initialized)
3592
+ */
3593
+ async init() {
3594
+ if (this.context) return;
3595
+ if (!await isPlaywrightAvailable()) {
3596
+ throw new Error("Playwright not available");
3597
+ }
3598
+ ensureUserDataDir();
3599
+ this.browser = await playwright.chromium.launch({
3600
+ headless: this.headless
3601
+ });
3602
+ const hasAuthState = fs2.existsSync(STORAGE_STATE_FILE);
3603
+ const contextOptions = {
3604
+ viewport: { width: 1280, height: 720 },
3605
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
3606
+ };
3607
+ if (hasAuthState) {
3608
+ contextOptions.storageState = STORAGE_STATE_FILE;
3609
+ }
3610
+ this.context = await this.browser.newContext(contextOptions);
3611
+ this.page = await this.context.newPage();
3612
+ this.page.setDefaultTimeout(3e4);
3613
+ }
3614
+ /**
3615
+ * Check if user is logged into Claude
3616
+ */
3617
+ async checkAuth() {
3618
+ await this.init();
3619
+ try {
3620
+ await this.page.goto("https://claude.ai/", { waitUntil: "networkidle", timeout: 3e4 });
3621
+ await this.page.waitForTimeout(2e3);
3622
+ const url = this.page.url();
3623
+ console.log(" Final URL:", url);
3624
+ return !url.includes("/login") && !url.includes("/oauth") && !url.includes("accounts.google") && url.includes("claude.ai");
3625
+ } catch (error) {
3626
+ console.error("Auth check failed:", error.message);
3627
+ return false;
3628
+ }
3629
+ }
3630
+ /**
3631
+ * Monitor a Claude Code session URL
3632
+ * Uses shared scrapeSession helper for consistent behavior with one-shot mode.
3633
+ */
3634
+ async monitorSession(sessionUrl, options = {}) {
3635
+ await this.init();
3636
+ if (!this.page) {
3637
+ return { status: "error", error: "Browser page not initialized" };
3638
+ }
3639
+ return scrapeSession(this.page, sessionUrl, options);
3640
+ }
3641
+ /**
3642
+ * Close the browser context and browser
3643
+ */
3644
+ async close() {
3645
+ if (this.context) {
3646
+ try {
3647
+ await this.context.close();
3648
+ } catch {
3649
+ }
3650
+ this.context = null;
3651
+ this.page = null;
3652
+ }
3653
+ if (this.browser) {
3654
+ try {
3655
+ await this.browser.close();
3656
+ } catch {
3657
+ }
3658
+ this.browser = null;
3659
+ }
3660
+ }
3661
+ };
3582
3662
  async function isPlaywrightAvailable() {
3583
3663
  try {
3584
3664
  playwright = await import("playwright");
@@ -3589,7 +3669,23 @@ async function isPlaywrightAvailable() {
3589
3669
  }
3590
3670
  function ensureUserDataDir() {
3591
3671
  if (!fs2.existsSync(USER_DATA_DIR)) {
3592
- fs2.mkdirSync(USER_DATA_DIR, { recursive: true });
3672
+ fs2.mkdirSync(USER_DATA_DIR, { recursive: true, mode: 448 });
3673
+ }
3674
+ }
3675
+ async function ensureStorageDir() {
3676
+ const storageDir = path2.dirname(STORAGE_STATE_FILE);
3677
+ try {
3678
+ await fs2.promises.mkdir(storageDir, { recursive: true, mode: 448 });
3679
+ } catch (err) {
3680
+ if (err?.code !== "EEXIST") {
3681
+ throw err;
3682
+ }
3683
+ }
3684
+ }
3685
+ async function setStorageFilePermissions() {
3686
+ try {
3687
+ await fs2.promises.chmod(STORAGE_STATE_FILE, 384);
3688
+ } catch {
3593
3689
  }
3594
3690
  }
3595
3691
  async function launchBrowser(headless) {
@@ -3597,24 +3693,28 @@ async function launchBrowser(headless) {
3597
3693
  throw new Error("Playwright not available");
3598
3694
  }
3599
3695
  ensureUserDataDir();
3600
- const context = await playwright.chromium.launchPersistentContext(USER_DATA_DIR, {
3601
- headless,
3696
+ const browser = await playwright.chromium.launch({ headless });
3697
+ const hasAuthState = fs2.existsSync(STORAGE_STATE_FILE);
3698
+ const contextOptions = {
3602
3699
  viewport: { width: 1280, height: 720 },
3603
3700
  userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
3604
- });
3605
- const browser = context.browser();
3701
+ };
3702
+ if (hasAuthState) {
3703
+ contextOptions.storageState = STORAGE_STATE_FILE;
3704
+ }
3705
+ const context = await browser.newContext(contextOptions);
3606
3706
  return { browser, context };
3607
3707
  }
3608
3708
  async function checkAuth() {
3609
3709
  if (!await isPlaywrightAvailable()) {
3610
3710
  throw new Error("Playwright not installed");
3611
3711
  }
3612
- const { context } = await launchBrowser(true);
3712
+ const { browser, context } = await launchBrowser(true);
3613
3713
  try {
3614
3714
  const page = await context.newPage();
3615
3715
  page.setDefaultTimeout(3e4);
3616
- await page.goto("https://claude.ai/", { waitUntil: "domcontentloaded", timeout: 3e4 });
3617
- await page.waitForTimeout(3e3);
3716
+ await page.goto("https://claude.ai/", { waitUntil: "networkidle", timeout: 3e4 });
3717
+ await page.waitForTimeout(2e3);
3618
3718
  const url = page.url();
3619
3719
  console.log(" Final URL:", url);
3620
3720
  const isLoggedIn = !url.includes("/login") && !url.includes("/oauth") && !url.includes("accounts.google") && url.includes("claude.ai");
@@ -3624,6 +3724,7 @@ async function checkAuth() {
3624
3724
  return false;
3625
3725
  } finally {
3626
3726
  await context.close();
3727
+ await browser.close();
3627
3728
  }
3628
3729
  }
3629
3730
  async function openForLogin() {
@@ -3643,6 +3744,13 @@ async function openForLogin() {
3643
3744
  const url = page.url();
3644
3745
  console.log(" Final URL:", url);
3645
3746
  const isLoggedIn = !url.includes("/login") && !url.includes("/oauth") && !url.includes("accounts.google") && url.includes("claude.ai");
3747
+ if (isLoggedIn) {
3748
+ await ensureStorageDir();
3749
+ console.log("Saving session state...");
3750
+ await context.storageState({ path: STORAGE_STATE_FILE });
3751
+ await setStorageFilePermissions();
3752
+ console.log(` Saved to: ${STORAGE_STATE_FILE}`);
3753
+ }
3646
3754
  console.log("Closing browser...");
3647
3755
  return isLoggedIn;
3648
3756
  } finally {
@@ -3664,17 +3772,15 @@ function waitForEnter() {
3664
3772
  });
3665
3773
  });
3666
3774
  }
3667
- async function monitorSession(sessionUrl, options = {}) {
3668
- if (!await isPlaywrightAvailable()) {
3669
- return { status: "error", error: "Playwright not installed" };
3670
- }
3671
- const { headless = true, timeout = 3e4, autoPr = false } = options;
3672
- const { context } = await launchBrowser(headless);
3775
+ async function scrapeSession(page, sessionUrl, options = {}) {
3776
+ const { timeout = 3e4, autoPr = false } = options;
3673
3777
  try {
3674
- const page = await context.newPage();
3675
3778
  page.setDefaultTimeout(timeout);
3676
- await page.goto(sessionUrl, { waitUntil: "domcontentloaded" });
3677
- await page.waitForTimeout(2e3);
3779
+ await page.goto(sessionUrl, { waitUntil: "networkidle" });
3780
+ await page.waitForSelector('[data-testid="conversation-turn"], .code-spinner-animate, button:has-text("Create PR")', {
3781
+ timeout: 1e4
3782
+ }).catch(() => {
3783
+ });
3678
3784
  const result = { status: "active" };
3679
3785
  const archiveIndicator = await page.$("text=This session has been archived");
3680
3786
  if (archiveIndicator) {
@@ -3687,15 +3793,18 @@ async function monitorSession(sessionUrl, options = {}) {
3687
3793
  error: "Not logged in to Claude. Run: flightdesk auth"
3688
3794
  };
3689
3795
  }
3796
+ const isActive = await detectActiveSpinner(page);
3797
+ result.isActive = isActive;
3690
3798
  const branchName = await extractBranchName(page);
3691
3799
  if (branchName) {
3692
3800
  result.branchName = branchName;
3693
3801
  }
3694
- const prButton = await page.$('button:has-text("Create PR"):not([aria-haspopup])');
3695
- result.hasPrButton = !!prButton;
3696
- if (autoPr && prButton) {
3802
+ const createPrButton = await page.$('button:has-text("Create PR"):not([aria-haspopup])');
3803
+ const viewPrButton = await page.$('button:has-text("View PR")');
3804
+ result.hasPrButton = !!createPrButton && !viewPrButton;
3805
+ if (autoPr && createPrButton && !viewPrButton) {
3697
3806
  console.log(' Clicking "Create PR" button...');
3698
- await prButton.click();
3807
+ await createPrButton.click();
3699
3808
  await page.waitForTimeout(5e3);
3700
3809
  const prUrl = await extractPrUrl(page);
3701
3810
  if (prUrl) {
@@ -3708,29 +3817,54 @@ async function monitorSession(sessionUrl, options = {}) {
3708
3817
  status: "error",
3709
3818
  error: error instanceof Error ? error.message : String(error)
3710
3819
  };
3711
- } finally {
3712
- await context.close();
3820
+ }
3821
+ }
3822
+ function isValidBranchName(text) {
3823
+ if (!text) return false;
3824
+ const cleaned = text.trim();
3825
+ return cleaned.length > 2 && !cleaned.includes(" ");
3826
+ }
3827
+ function matchesBranchPattern(text) {
3828
+ return /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+/.test(text) && !text.includes(" ");
3829
+ }
3830
+ async function tryExtractFromElement(page, selector) {
3831
+ try {
3832
+ const element = await page.$(selector);
3833
+ if (!element) return null;
3834
+ const text = await element.textContent();
3835
+ const cleaned = text?.trim() ?? null;
3836
+ return isValidBranchName(cleaned) ? cleaned : null;
3837
+ } catch {
3838
+ return null;
3713
3839
  }
3714
3840
  }
3715
3841
  async function extractBranchName(page) {
3842
+ if (!page) return null;
3716
3843
  const primarySelectors = [
3717
- String.raw`button.group\/copy span.truncate`
3844
+ String.raw`button.group\/copy span.truncate`,
3718
3845
  // New branch name (verified)
3846
+ 'button[class*="group/copy"] span.truncate',
3847
+ // Alternative escaping
3848
+ String.raw`button[class*="group\/copy"] span.truncate`
3849
+ // CSS escape
3719
3850
  ];
3720
3851
  for (const selector of primarySelectors) {
3721
- try {
3722
- const element = await page.$(selector);
3723
- if (element) {
3724
- const text = await element.textContent();
3725
- if (text) {
3726
- const cleaned = text.trim();
3727
- if (cleaned && cleaned.length > 2 && !cleaned.includes(" ")) {
3728
- return cleaned;
3729
- }
3730
- }
3852
+ const result = await tryExtractFromElement(page, selector);
3853
+ if (result) return result;
3854
+ }
3855
+ return await tryExtractBranchFromSpans(page);
3856
+ }
3857
+ async function tryExtractBranchFromSpans(page) {
3858
+ try {
3859
+ const allSpans = await page.$$("span.truncate");
3860
+ for (const span of allSpans) {
3861
+ const text = await span.textContent();
3862
+ const cleaned = text?.trim();
3863
+ if (cleaned && matchesBranchPattern(cleaned)) {
3864
+ return cleaned;
3731
3865
  }
3732
- } catch {
3733
3866
  }
3867
+ } catch {
3734
3868
  }
3735
3869
  return null;
3736
3870
  }
@@ -3768,6 +3902,36 @@ async function extractPrUrl(page) {
3768
3902
  }
3769
3903
  return null;
3770
3904
  }
3905
+ async function detectActiveSpinner(page) {
3906
+ try {
3907
+ const spinner = await page.$(".code-spinner-animate");
3908
+ if (spinner) {
3909
+ return true;
3910
+ }
3911
+ const spinnerByContent = await page.$('span:has-text("\u273D")');
3912
+ if (spinnerByContent) {
3913
+ const hasAnimation = await spinnerByContent.evaluate((el) => {
3914
+ let current = el;
3915
+ while (current) {
3916
+ const classList = current.classList;
3917
+ if (classList?.contains("code-spinner-animate")) {
3918
+ return true;
3919
+ }
3920
+ const style = globalThis.getComputedStyle(current);
3921
+ if (style.animationName && style.animationName !== "none") {
3922
+ return true;
3923
+ }
3924
+ current = current.parentElement;
3925
+ }
3926
+ return false;
3927
+ });
3928
+ return hasAnimation;
3929
+ }
3930
+ return false;
3931
+ } catch {
3932
+ return false;
3933
+ }
3934
+ }
3771
3935
 
3772
3936
  // apps/cli/src/commands/auth.ts
3773
3937
  async function authCommand() {
@@ -3800,11 +3964,54 @@ async function authCommand() {
3800
3964
  }
3801
3965
  }
3802
3966
 
3967
+ // apps/cli/src/lib/git.ts
3968
+ var import_node_child_process = require("node:child_process");
3969
+ function detectGitRepo() {
3970
+ try {
3971
+ const remoteUrl = (0, import_node_child_process.execSync)("git remote get-url origin", {
3972
+ encoding: "utf-8",
3973
+ stdio: ["pipe", "pipe", "pipe"]
3974
+ }).trim();
3975
+ const repoFullName = parseGitRemoteUrl(remoteUrl);
3976
+ if (!repoFullName) {
3977
+ return null;
3978
+ }
3979
+ const branch = (0, import_node_child_process.execSync)("git rev-parse --abbrev-ref HEAD", {
3980
+ encoding: "utf-8",
3981
+ stdio: ["pipe", "pipe", "pipe"]
3982
+ }).trim();
3983
+ return {
3984
+ remote: repoFullName,
3985
+ branch,
3986
+ remoteUrl
3987
+ };
3988
+ } catch {
3989
+ return null;
3990
+ }
3991
+ }
3992
+ function parseGitRemoteUrl(remoteUrl) {
3993
+ const sshPattern = /git@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/;
3994
+ const sshMatch = sshPattern.exec(remoteUrl);
3995
+ if (sshMatch) {
3996
+ return sshMatch[1];
3997
+ }
3998
+ const httpsPattern = /https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/;
3999
+ const httpsMatch = httpsPattern.exec(remoteUrl);
4000
+ if (httpsMatch) {
4001
+ return httpsMatch[1];
4002
+ }
4003
+ return null;
4004
+ }
4005
+
3803
4006
  // apps/cli/src/commands/register.ts
3804
- async function registerCommand(projectId, taskId, options) {
4007
+ async function registerCommand(taskId, options) {
3805
4008
  const { config, org: org2 } = requireActiveOrg();
3806
4009
  const api = FlightDeskAPI.fromConfig(config, org2);
3807
4010
  try {
4011
+ let projectId = options.project;
4012
+ if (!projectId) {
4013
+ projectId = await autoDetectProject(api, org2);
4014
+ }
3808
4015
  let viewUrl = options.viewUrl;
3809
4016
  let teleportId = options.teleportId;
3810
4017
  if (!process.stdin.isTTY) {
@@ -3875,6 +4082,53 @@ function parseClaudeOutput(output) {
3875
4082
  }
3876
4083
  return result;
3877
4084
  }
4085
+ async function autoDetectProject(api, activeOrg) {
4086
+ const repoInfo = detectGitRepo();
4087
+ if (!repoInfo) {
4088
+ console.error("\u274C Not in a git repository. Please provide a project ID.");
4089
+ console.error(" Usage: flightdesk register --project <project-id> [task-id]");
4090
+ process.exit(1);
4091
+ }
4092
+ console.log(`\u{1F50D} Detecting project from repository: ${repoInfo.remote}`);
4093
+ const mappedOrg = getOrganizationByRepo(repoInfo.remote);
4094
+ if (!mappedOrg) {
4095
+ console.error(`\u274C Repository "${repoInfo.remote}" is not mapped to any organization.`);
4096
+ console.error(" Run: flightdesk sync");
4097
+ console.error(" Or provide a project ID: flightdesk register --project <project-id> [task-id]");
4098
+ process.exit(1);
4099
+ }
4100
+ if (mappedOrg.id !== activeOrg.id) {
4101
+ console.error(`\u274C Repository "${repoInfo.remote}" is mapped to organization "${mappedOrg.name}",`);
4102
+ console.error(` but your active organization is "${activeOrg.name}".`);
4103
+ console.error(" Switch with: flightdesk org switch " + mappedOrg.name);
4104
+ console.error(" Or provide a project ID: flightdesk register --project <project-id> [task-id]");
4105
+ process.exit(1);
4106
+ }
4107
+ const projects = await api.listProjects();
4108
+ const matchingProjects = projects.filter(
4109
+ (p) => p.githubRepo?.toLowerCase() === repoInfo.remote.toLowerCase()
4110
+ );
4111
+ if (matchingProjects.length === 0) {
4112
+ console.error(`\u274C No FlightDesk project found for repository "${repoInfo.remote}".`);
4113
+ console.error(" Available projects in this organization:");
4114
+ for (const p of projects) {
4115
+ console.error(` - ${p.name} (${p.githubRepo || "no repo"}) [${p.id}]`);
4116
+ }
4117
+ console.error("\n Create a project or provide a project ID explicitly.");
4118
+ process.exit(1);
4119
+ }
4120
+ if (matchingProjects.length > 1) {
4121
+ console.error(`\u274C Multiple projects found for repository "${repoInfo.remote}":`);
4122
+ for (const p of matchingProjects) {
4123
+ console.error(` - ${p.name} [${p.id}]`);
4124
+ }
4125
+ console.error("\n Please specify the project ID explicitly.");
4126
+ process.exit(1);
4127
+ }
4128
+ const project2 = matchingProjects[0];
4129
+ console.log(`\u2705 Found project: ${project2.name} (${project2.id})`);
4130
+ return project2.id;
4131
+ }
3878
4132
 
3879
4133
  // apps/cli/src/commands/status.ts
3880
4134
  async function statusCommand(options) {
@@ -3976,17 +4230,23 @@ async function watchCommand(options) {
3976
4230
  console.log(` Auto-PR: ${options.autoPr ? "enabled" : "disabled"}`);
3977
4231
  console.log("");
3978
4232
  const playwrightAvailable = await isPlaywrightAvailable();
4233
+ let browser = null;
3979
4234
  if (!playwrightAvailable) {
3980
4235
  console.log("\u26A0\uFE0F Playwright not installed. Session monitoring disabled.");
3981
4236
  console.log(" Install with: pnpm add playwright && npx playwright install chromium");
3982
4237
  console.log("");
3983
4238
  } else {
3984
- const isAuthenticated = await checkAuth();
4239
+ browser = new PersistentBrowser(options.headless !== false);
4240
+ console.log("Checking Claude authentication...");
4241
+ const isAuthenticated = await browser.checkAuth();
3985
4242
  if (!isAuthenticated) {
3986
4243
  console.log("\u26A0\uFE0F Not logged into Claude. Run: flightdesk auth");
3987
4244
  console.log("");
4245
+ await browser.close();
4246
+ browser = null;
3988
4247
  } else {
3989
4248
  console.log("\u2705 Playwright ready, Claude authenticated");
4249
+ console.log(" (Browser session kept alive for monitoring)");
3990
4250
  console.log("");
3991
4251
  }
3992
4252
  }
@@ -4021,38 +4281,49 @@ async function watchCommand(options) {
4021
4281
  if (reconciled) {
4022
4282
  continue;
4023
4283
  }
4024
- if (task2.sessionViewUrl && playwrightAvailable) {
4284
+ if (task2.sessionViewUrl && browser) {
4025
4285
  console.log(` Checking session...`);
4026
- const sessionInfo = await monitorSession(task2.sessionViewUrl, {
4027
- headless: options.headless !== false,
4286
+ const sessionInfo = await browser.monitorSession(task2.sessionViewUrl, {
4028
4287
  autoPr: options.autoPr && !task2.prUrl
4029
4288
  // Only auto-PR if no PR exists
4030
4289
  });
4031
4290
  await processSessionInfo(api, task2, sessionInfo);
4032
4291
  } else if (!task2.sessionViewUrl) {
4033
4292
  console.log(" No session URL registered");
4293
+ } else if (!browser) {
4294
+ console.log(" Browser not available - cannot monitor session");
4034
4295
  }
4035
4296
  }
4036
4297
  } catch (error) {
4037
4298
  console.error(` Error: ${error}`);
4038
4299
  }
4039
4300
  }
4040
- await runCheck();
4041
- if (options.once) {
4042
- console.log("\nDone (--once flag specified)");
4043
- return;
4044
- }
4045
- console.log(`
4301
+ try {
4302
+ await runCheck();
4303
+ if (options.once) {
4304
+ console.log("\nDone (--once flag specified)");
4305
+ return;
4306
+ }
4307
+ console.log(`
4046
4308
  Watching... (Ctrl+C to stop)
4047
4309
  `);
4048
- const intervalId = setInterval(runCheck, intervalMinutes * 60 * 1e3);
4049
- process.on("SIGINT", () => {
4050
- console.log("\nShutting down...");
4051
- clearInterval(intervalId);
4052
- process.exit(0);
4053
- });
4054
- await new Promise((_resolve) => {
4055
- });
4310
+ const intervalId = setInterval(runCheck, intervalMinutes * 60 * 1e3);
4311
+ process.on("SIGINT", async () => {
4312
+ console.log("\nShutting down...");
4313
+ clearInterval(intervalId);
4314
+ if (browser) {
4315
+ console.log("Closing browser...");
4316
+ await browser.close();
4317
+ }
4318
+ process.exit(0);
4319
+ });
4320
+ await new Promise((_resolve) => {
4321
+ });
4322
+ } finally {
4323
+ if (browser) {
4324
+ await browser.close();
4325
+ }
4326
+ }
4056
4327
  }
4057
4328
  async function reconcileTaskStatus(api, task2) {
4058
4329
  let expectedStatus = null;
@@ -4096,7 +4367,25 @@ async function processSessionInfo(api, task2, info) {
4096
4367
  console.log(" \u{1F4E6} Session archived");
4097
4368
  return;
4098
4369
  }
4099
- const updates = {};
4370
+ let activityIcon;
4371
+ let activityLabel;
4372
+ if (info.isActive === true) {
4373
+ activityIcon = "\u26A1";
4374
+ activityLabel = "Claude is working";
4375
+ } else if (info.isActive === false) {
4376
+ activityIcon = "\u{1F4A4}";
4377
+ activityLabel = "Waiting for input";
4378
+ } else {
4379
+ activityIcon = "\u2754";
4380
+ activityLabel = "Activity unknown";
4381
+ }
4382
+ console.log(` ${activityIcon} ${activityLabel}`);
4383
+ const updates = {
4384
+ // Update session activity state and timestamp
4385
+ // Preserve null when activity detection fails (info.isActive is undefined)
4386
+ sessionActive: info.isActive ?? null,
4387
+ sessionCheckedAt: (/* @__PURE__ */ new Date()).toISOString()
4388
+ };
4100
4389
  if (info.branchName && info.branchName !== task2.branchName) {
4101
4390
  console.log(` \u{1F33F} Branch detected: ${info.branchName}`);
4102
4391
  updates.branchName = info.branchName;
@@ -4116,15 +4405,14 @@ async function processSessionInfo(api, task2, info) {
4116
4405
  if (info.hasPrButton && !info.prUrl && !task2.prUrl) {
4117
4406
  console.log(' \u{1F4DD} "Create PR" button available');
4118
4407
  }
4119
- if (Object.keys(updates).length > 0) {
4120
- try {
4121
- await api.updateTask(task2.id, updates);
4408
+ const hasNonActivityUpdates = Object.keys(updates).some((k) => !["sessionActive", "sessionCheckedAt"].includes(k));
4409
+ try {
4410
+ await api.updateTask(task2.id, updates);
4411
+ if (hasNonActivityUpdates) {
4122
4412
  console.log(" \u2705 Task updated");
4123
- } catch (error) {
4124
- console.log(` \u274C Failed to update task: ${error}`);
4125
4413
  }
4126
- } else {
4127
- console.log(" No changes detected");
4414
+ } catch (error) {
4415
+ console.log(` \u274C Failed to update task: ${error}`);
4128
4416
  }
4129
4417
  }
4130
4418
 
@@ -4941,10 +5229,27 @@ ${projects.length} project(s)`);
4941
5229
  }
4942
5230
 
4943
5231
  // apps/cli/src/commands/preview.ts
4944
- var import_child_process2 = require("child_process");
4945
- var path3 = __toESM(require("path"));
4946
- var os3 = __toESM(require("os"));
4947
- var fs4 = __toESM(require("fs"));
5232
+ var import_node_child_process2 = require("node:child_process");
5233
+ var path3 = __toESM(require("node:path"));
5234
+ var os3 = __toESM(require("node:os"));
5235
+ var fs4 = __toESM(require("node:fs"));
5236
+ function isValidContainerId(id) {
5237
+ return /^[a-fA-F0-9]+$/.test(id) && id.length >= 12 && id.length <= 64;
5238
+ }
5239
+ function validateSSHParams(instance) {
5240
+ if (instance.containerId && !isValidContainerId(instance.containerId)) {
5241
+ throw new Error(`Invalid container ID format: ${instance.containerId}`);
5242
+ }
5243
+ if (!/^[a-zA-Z0-9_-]+$/.test(instance.sshUser)) {
5244
+ throw new Error(`Invalid SSH user format: ${instance.sshUser}`);
5245
+ }
5246
+ if (!/^[a-zA-Z0-9.-]+$/.test(instance.sshHost)) {
5247
+ throw new Error(`Invalid SSH host format: ${instance.sshHost}`);
5248
+ }
5249
+ if (!Number.isInteger(instance.sshPort) || instance.sshPort < 1 || instance.sshPort > 65535) {
5250
+ throw new Error(`Invalid SSH port: ${instance.sshPort}`);
5251
+ }
5252
+ }
4948
5253
  async function previewCommand(action, options) {
4949
5254
  const { config, org: org2 } = requireActiveOrg();
4950
5255
  const api = FlightDeskAPI.fromConfig(config, org2);
@@ -5028,8 +5333,9 @@ async function handleLogs(api, options) {
5028
5333
  `);
5029
5334
  console.log(`(Press Ctrl+C to stop)
5030
5335
  `);
5336
+ validateSSHParams(instance);
5031
5337
  const sshCommand = `docker logs -f ${instance.containerId}`;
5032
- const ssh = (0, import_child_process2.spawn)("ssh", [
5338
+ const ssh = (0, import_node_child_process2.spawn)("ssh", [
5033
5339
  "-o",
5034
5340
  "StrictHostKeyChecking=no",
5035
5341
  "-o",
@@ -5069,7 +5375,7 @@ async function handleMount(api, options) {
5069
5375
  await new Promise((resolve) => setTimeout(resolve, 3e3));
5070
5376
  }
5071
5377
  try {
5072
- (0, import_child_process2.execSync)("which sshfs", { stdio: "ignore" });
5378
+ (0, import_node_child_process2.execSync)("which sshfs", { stdio: "ignore" });
5073
5379
  } catch {
5074
5380
  console.error("\u274C sshfs is not installed.");
5075
5381
  console.error("");
@@ -5083,12 +5389,14 @@ async function handleMount(api, options) {
5083
5389
  }
5084
5390
  process.exit(1);
5085
5391
  }
5086
- const mountDir = options.directory || path3.join(os3.homedir(), "flightdesk-mounts", options.taskId.substring(0, 8));
5392
+ const rawTaskIdPrefix = options.taskId.substring(0, 8);
5393
+ const safeTaskId = path3.basename(rawTaskIdPrefix).replaceAll(/[^a-zA-Z0-9_-]/g, "") || "task";
5394
+ const mountDir = options.directory || path3.join(os3.homedir(), "flightdesk-mounts", safeTaskId);
5087
5395
  if (!fs4.existsSync(mountDir)) {
5088
5396
  fs4.mkdirSync(mountDir, { recursive: true });
5089
5397
  }
5090
5398
  try {
5091
- const mounted = (0, import_child_process2.execSync)("mount", { encoding: "utf8" });
5399
+ const mounted = (0, import_node_child_process2.execSync)("mount", { encoding: "utf8" });
5092
5400
  if (mounted.includes(mountDir)) {
5093
5401
  console.log(`\u{1F4C1} Already mounted at ${mountDir}`);
5094
5402
  return;
@@ -5096,8 +5404,8 @@ async function handleMount(api, options) {
5096
5404
  } catch {
5097
5405
  }
5098
5406
  console.log(`\u{1F4C1} Mounting preview environment to ${mountDir}...`);
5099
- const sshfsCmd = [
5100
- "sshfs",
5407
+ validateSSHParams(instance);
5408
+ const sshfsArgs = [
5101
5409
  "-o",
5102
5410
  "StrictHostKeyChecking=no",
5103
5411
  "-o",
@@ -5114,7 +5422,10 @@ async function handleMount(api, options) {
5114
5422
  mountDir
5115
5423
  ];
5116
5424
  try {
5117
- (0, import_child_process2.execSync)(sshfsCmd.join(" "), { stdio: "inherit" });
5425
+ const result = (0, import_node_child_process2.spawnSync)("sshfs", sshfsArgs, { stdio: "inherit" });
5426
+ if (result.status !== 0) {
5427
+ throw new Error(`sshfs exited with code ${result.status}`);
5428
+ }
5118
5429
  console.log("");
5119
5430
  console.log("\u2705 Mounted successfully!");
5120
5431
  console.log(` Location: ${mountDir}`);
@@ -5134,21 +5445,27 @@ async function handleMount(api, options) {
5134
5445
  }
5135
5446
  }
5136
5447
  async function handleUnmount(_api, options) {
5137
- const mountDir = path3.join(os3.homedir(), "flightdesk-mounts", options.taskId.substring(0, 8));
5448
+ const rawTaskIdPrefix = options.taskId.substring(0, 8);
5449
+ const safeTaskId = path3.basename(rawTaskIdPrefix).replaceAll(/[^a-zA-Z0-9_-]/g, "") || "task";
5450
+ const mountDir = path3.join(os3.homedir(), "flightdesk-mounts", safeTaskId);
5138
5451
  if (!fs4.existsSync(mountDir)) {
5139
5452
  console.log("Mount directory does not exist");
5140
5453
  return;
5141
5454
  }
5142
5455
  console.log(`\u{1F4C1} Unmounting ${mountDir}...`);
5143
5456
  try {
5457
+ let result;
5144
5458
  if (process.platform === "darwin") {
5145
- (0, import_child_process2.execSync)(`umount ${mountDir}`, { stdio: "inherit" });
5459
+ result = (0, import_node_child_process2.spawnSync)("umount", [mountDir], { stdio: "inherit" });
5146
5460
  } else {
5147
- (0, import_child_process2.execSync)(`fusermount -u ${mountDir}`, { stdio: "inherit" });
5461
+ result = (0, import_node_child_process2.spawnSync)("fusermount", ["-u", mountDir], { stdio: "inherit" });
5462
+ }
5463
+ if (result.status !== 0) {
5464
+ throw new Error(`Unmount exited with code ${result.status}`);
5148
5465
  }
5149
5466
  console.log("\u2705 Unmounted successfully");
5150
5467
  try {
5151
- fs4.rmdirSync(mountDir);
5468
+ fs4.rmSync(mountDir, { recursive: true, force: true });
5152
5469
  } catch {
5153
5470
  }
5154
5471
  } catch (error) {
@@ -5181,7 +5498,7 @@ async function handleTeardown(api, options) {
5181
5498
 
5182
5499
  // apps/cli/src/main.ts
5183
5500
  var program2 = new Command();
5184
- program2.name("flightdesk").description("FlightDesk CLI - AI task management for Claude Code sessions").version("0.2.0").option("--dev", "Use local development API (localhost:3000)").option("--api <url>", "Use custom API URL");
5501
+ program2.name("flightdesk").description("FlightDesk CLI - AI task management for Claude Code sessions").version("0.2.2").option("--dev", "Use local development API (localhost:3000)").option("--api <url>", "Use custom API URL");
5185
5502
  program2.hook("preAction", () => {
5186
5503
  const opts = program2.opts();
5187
5504
  if (opts.api) {
@@ -5195,7 +5512,7 @@ program2.hook("preAction", () => {
5195
5512
  });
5196
5513
  program2.command("init").description("Configure FlightDesk CLI with your API credentials").action(initCommand);
5197
5514
  program2.command("auth").description("Log in to Claude for session monitoring").action(authCommand);
5198
- program2.command("register <project-id> [task-id]").description("Register a Claude Code session with a FlightDesk task").option("--view-url <url>", "Claude Code session view URL").option("--teleport-id <id>", "Claude Code teleport ID").option("--title <title>", "Task title (creates new task if task-id not provided)").option("--description <description>", "Task description").action(registerCommand);
5515
+ program2.command("register [task-id]").description("Register a Claude Code session with a FlightDesk task (auto-detects project from git repo)").option("-p, --project <id>", "Project ID (auto-detected from git repo if not provided)").option("--view-url <url>", "Claude Code session view URL").option("--teleport-id <id>", "Claude Code teleport ID").option("--title <title>", "Task title (creates new task if task-id not provided)").option("--description <description>", "Task description").action(registerCommand);
5199
5516
  var project = program2.command("project").description("Project management commands");
5200
5517
  project.command("list").description("List projects in the active organization").action(() => projectCommand("list", {}));
5201
5518
  var task = program2.command("task").description("Task management commands");
@@ -5215,7 +5532,10 @@ program2.command("context").description("Show current repository context and map
5215
5532
  program2.command("sync").description("Refresh project-to-repository mappings from all organizations").action(syncCommand);
5216
5533
  var preview = program2.command("preview").description("Preview environment management");
5217
5534
  preview.command("status <task-id>").description("Show preview environment status").action((taskId) => previewCommand("status", { taskId }));
5218
- preview.command("logs <task-id>").description("Get logs from preview environment").option("-n, --lines <lines>", "Number of log lines", "100").option("-f, --follow", "Follow log output").action((taskId, options) => previewCommand("logs", { taskId, lines: parseInt(options.lines || "100"), follow: options.follow }));
5535
+ preview.command("logs <task-id>").description("Get logs from preview environment").option("-n, --lines <lines>", "Number of log lines (1-10000)", "100").option("-f, --follow", "Follow log output").action((taskId, options) => {
5536
+ const lines = Math.min(Math.max(Number.parseInt(options.lines || "100"), 1), 1e4);
5537
+ previewCommand("logs", { taskId, lines, follow: options.follow });
5538
+ });
5219
5539
  preview.command("mount <task-id>").description("Mount preview environment filesystem via SSHFS").option("-d, --directory <path>", "Custom mount directory").action((taskId, options) => previewCommand("mount", { taskId, directory: options.directory }));
5220
5540
  preview.command("unmount <task-id>").description("Unmount preview environment filesystem").action((taskId) => previewCommand("unmount", { taskId }));
5221
5541
  preview.command("restart <task-id>").description("Restart preview environment processes").action((taskId) => previewCommand("restart", { taskId }));
@@ -5223,6 +5543,9 @@ preview.command("resume <task-id>").description("Resume a suspended preview envi
5223
5543
  preview.command("teardown <task-id>").description("Tear down preview environment").action((taskId) => previewCommand("teardown", { taskId }));
5224
5544
  program2.command("mount <task-id>").description('Mount preview environment filesystem (shorthand for "preview mount")').option("-d, --directory <path>", "Custom mount directory").action((taskId, options) => previewCommand("mount", { taskId, directory: options.directory }));
5225
5545
  program2.command("unmount <task-id>").description('Unmount preview environment filesystem (shorthand for "preview unmount")').action((taskId) => previewCommand("unmount", { taskId }));
5226
- program2.command("logs <task-id>").description('Get logs from preview environment (shorthand for "preview logs")').option("-n, --lines <lines>", "Number of log lines", "100").option("-f, --follow", "Follow log output").action((taskId, options) => previewCommand("logs", { taskId, lines: parseInt(options.lines || "100"), follow: options.follow }));
5546
+ program2.command("logs <task-id>").description('Get logs from preview environment (equivalent to "preview logs")').option("-n, --lines <lines>", "Number of log lines (1-10000)", "100").option("-f, --follow", "Follow log output").action((taskId, options) => {
5547
+ const lines = Math.min(Math.max(Number.parseInt(options.lines || "100"), 1), 1e4);
5548
+ previewCommand("logs", { taskId, lines, follow: options.follow });
5549
+ });
5227
5550
  program2.parse();
5228
5551
  //# sourceMappingURL=main.js.map