diff --git a/AGENTS.md b/AGENTS.md index 63eca33c1f..8018f88cf7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,3 +69,12 @@ package manifests before running repository JavaScript or package scripts. Load ## Security Baseline - Never log tokens, credentials, API keys, authentication headers, cookies, or webhook secrets. Use `redactSensitiveHeaders` when headers must be retained or logged. Do not enable `sendDefaultPii` or `attachRpcInput` in Sentry. + +## Kilobot Review Remarks + +When addressing a Kilobot review remark: + +1. Verify that the remark is valid. If unsure, attempt to reproduce it with an E2E or unit test. +2. If valid, fix it and commit the change. Prefer small commits in general. +3. Reply in the review thread, then resolve the thread. +4. Push the commits. diff --git a/apps/mobile/.kilo/agent/mobile-e2e-verifier.md b/apps/mobile/.kilo/agent/mobile-e2e-verifier.md index 5e2a264ffd..ffb56fc933 100644 --- a/apps/mobile/.kilo/agent/mobile-e2e-verifier.md +++ b/apps/mobile/.kilo/agent/mobile-e2e-verifier.md @@ -1,8 +1,8 @@ --- -description: Verifies an approved mobile-app change end to end against local services, simulator, CLI, and related repositories +description: Verifies an approved mobile change end to end mode: subagent model: kilo/kilo-auto/efficient -steps: 60 +steps: 100 permission: edit: deny external_directory: allow @@ -21,12 +21,14 @@ Before testing: 1. Read `apps/mobile/e2e/AGENTS.md` and all instructions it references. 2. Translate the orchestrator's acceptance criteria into observable happy, retryable unhappy, non-retryable unhappy, and empty flows for every new user-facing feature. -3. Record pre-existing services, simulators, and tmux sessions so cleanup only removes resources you create. +3. Record pre-existing services, simulators, and tmux sessions so cleanup only removes resources you create. Claim a simulator with `pnpm dev:mobile:simulator claim`; never use one claimed by another worktree. During verification: -- Verify the app is running the bundle and services from the intended worktree. -- Inspect the current screen before selecting Maestro elements and re-inspect after UI changes. +- Run the login/logout helper preflight rather than manually inferring bundle provenance, ports, or simulator ownership. +- Run `pnpm dev:mobile:android doctor` before declaring Android tooling unavailable; do not rely on the inherited `PATH`. +- Use Maestro as the primary driver on iOS and Android. Fall back to `xcrun simctl` on iOS or repository-wrapped ADB on Android only when Maestro cannot inspect or operate the state, or when low-level device control is required. +- Inspect the current screen before selecting Maestro elements and re-inspect after UI changes. Copy exact hierarchy text; never infer selectors from screenshots or visible tab captions. - Exercise every applicable feature state that can be produced safely and deterministically. A skipped state requires an explicit rationale; do not silently omit it. - Confirm retryable and empty states show a meaningful message and actionable CTA, and that the CTA performs the expected recovery or next step. - Confirm non-retryable states show a meaningful message with no CTA at all. diff --git a/apps/mobile/.kilo/agent/mobile-implementer.md b/apps/mobile/.kilo/agent/mobile-implementer.md index 3659fecb25..163cbe9d67 100644 --- a/apps/mobile/.kilo/agent/mobile-implementer.md +++ b/apps/mobile/.kilo/agent/mobile-implementer.md @@ -2,7 +2,7 @@ description: Implements an approved mobile-app plan, including required changes in cloud services, shared packages, or sibling repositories mode: subagent model: kilo/kilo-auto/efficient -steps: 50 +steps: 80 permission: edit: allow external_directory: allow diff --git a/apps/mobile/.kilo/agent/mobile-reviewer.md b/apps/mobile/.kilo/agent/mobile-reviewer.md index d6ad444b4f..7ea057bdca 100644 --- a/apps/mobile/.kilo/agent/mobile-reviewer.md +++ b/apps/mobile/.kilo/agent/mobile-reviewer.md @@ -2,7 +2,7 @@ description: Reviews an implementation produced for an approved mobile-app plan, including cross-repository changes mode: subagent model: kilo/kilo-auto/efficient -steps: 35 +steps: 50 permission: edit: deny external_directory: allow diff --git a/apps/mobile/e2e/AGENTS.md b/apps/mobile/e2e/AGENTS.md index 7b0bc49803..2008fb03ab 100644 --- a/apps/mobile/e2e/AGENTS.md +++ b/apps/mobile/e2e/AGENTS.md @@ -4,7 +4,7 @@ Use this runbook for interactive verification against a local backend. Commands ## Fresh Worktree Quickstart -If dependencies or local env files are missing, run this once: +If dependencies or local env files are missing, run this once. This also authorizes both worktree `.envrc` files and copies local env files from the primary checkout: ```bash node --version # must be v24; activate the root .nvmrc first if needed @@ -21,52 +21,48 @@ xcrun simctl list devices booted Reuse a complete stack already running for this worktree. Do not start a competing stack or stop an unrelated `kilo-dev-*` session. -When this worktree has no stack, start the complete mobile flow with worktree-safe ports: +`kiloclaw-docker-tcp` on port 23750 is the sole intentional host-wide exception: it is a stateless loopback proxy to the same Docker socket. The runner reuses an occupied proxy. Never kill a `socat` process owned by another worktree to free this port. + +When this worktree has no stack, start the complete mobile flow. Secondary worktrees automatically receive an isolated port offset, and mobile startup generates and explicitly injects this worktree's LAN URLs before Metro starts. Do not export `KILO_PORT_OFFSET` or source `apps/mobile/.env`; stale parent-shell values must not select the bundle endpoints: ```bash -export KILO_PORT_OFFSET=auto pnpm dev:env -y cloudflare-session-ingest pnpm dev:start --no-attach mobile cloud-agent-next kiloclaw event-service pnpm drizzle migrate -pnpm dev:env:mobile -pnpm dev:restart nextjs -pnpm dev:restart mobile -sleep 3 pnpm dev:status --json ``` -`dev:env:mobile` writes this stack's LAN URLs. Restart Next.js for native auth callbacks and Metro for bundled Expo env values. The session-ingest env step creates the JWT Secrets Store binding; without it the worker can appear healthy while rejecting every session request. `event-service` is required for presence and notification behavior. +The session-ingest env step creates the JWT Secrets Store binding; without it the worker can appear healthy while rejecting every session request. `event-service` is required for presence and notification behavior. + +Secrets Store state is local to each Worker directory. `dev:start` runs env sync for its selected service graph and refreshes every source-backed secret before launching Workers; secret creation failures are fatal. Do not run bare `wrangler secrets-store` commands: use `pnpm dev:env -y ` from the repository root so values come from the canonical local source and Wrangler receives a complete non-interactive prompt. Confirm `mobile`, `nextjs`, `cloudflare-session-ingest`, `cloud-agent-next`, `kiloclaw`, and `event-service` are `up`. Restarts are asynchronous; if `mobile` is still starting, inspect its log and rerun status instead of restarting the stack. Use reported ports; never assume defaults in a secondary worktree. ## Logs and tmux -Prefer the stable log files: +Prefer the stable log files. When pane output is required, let the runner find the service wherever the dashboard moved it: ```bash pnpm dev:status --json tail -n 200 dev/logs/.log +pnpm dev:capture mobile ``` -Use tmux when you need live pane state or an interactive process: +Do not guess a tmux window from `tmux ls` or use `:` directly; a service pane can be joined into the dashboard and no longer have a same-named window. Use `pnpm dev:capture ` for inspection. Use raw tmux only for an interactive process after reading the exact `session` from `pnpm dev:status --json` and resolving the pane with `tmux list-panes -a`. ```bash tmux ls tmux list-windows -t -tmux capture-pane -p -t : -S -200 ``` Put any extra long-lived CLI, recorder, or log follower in a clearly named `kilo-e2e-*` tmux session so it is visible and easy to remove. ## iOS Simulator -Reuse a booted simulator when possible, but always replace any installed Kilo app when validating a fresh worktree. This avoids stale native builds, most app data, and saved Metro URLs. iOS Keychain credentials can survive uninstall; the login helper handles an existing session. Otherwise boot one explicitly: +Never share a simulator with another worktree. Claim one before any build, install, login, Maestro, or MCP action; the command prefers an unclaimed shutdown device and boots it. Pass a UDID only when intentionally claiming that device: ```bash -xcrun simctl list devices available -xcrun simctl boot -open -a Simulator -xcrun simctl bootstatus -b +pnpm dev:mobile:simulator claim [udid] ``` Uninstall Kilo, then build from this worktree in a dedicated tmux session. A fresh checkout generates the gitignored `ios/` directory and installs pods, so the first build takes a few minutes and is noisy: @@ -89,18 +85,20 @@ xcrun simctl openurl \ "exp+kilo-app://expo-development-client/?url=http%3A%2F%2F%3A" ``` -Before testing, capture the `mobile` pane and verify `Starting project at /apps/mobile` plus a fresh `iOS Bundled` line. Seeing the Kilo login screen alone does not prove bundle provenance. The login helper dismisses the clean-install tracking alert and Expo dev-menu introduction when present. +Before testing, capture the `mobile` pane and verify `Starting project at /apps/mobile` plus a fresh `iOS Bundled` line. Seeing the Kilo login screen alone does not prove bundle provenance. The login preflight also reads Metro's development manifest and verifies `expoConfig.extra.apiBaseUrl` and `_internal.projectRoot` against this worktree. These endpoint extras come from the Metro manifest in a dev client; after env changes, regenerate env, restart Metro, reconnect the dev client to the exact Metro URL, and reload. Rebuild only when native config/plugins changed. The login helper dismisses the clean-install tracking alert and Expo dev-menu introduction when present. ## Sign In and Out -Backend and Metro must be running. These idempotent wrappers leave the app in a known state: +Backend and Metro must be running. These idempotent wrappers first verify simulator ownership, this worktree's required services, generated API port, and Metro project provenance, then reconnect the dev client to that exact Metro URL before launch. Do not bypass their preflight or call the YAML login steps directly: ```bash apps/mobile/e2e/login.sh [email] # default: e2e-mobile@example.com apps/mobile/e2e/logout.sh ``` -Login requests an email OTP, reads it from `dev/logs/emails/`, verifies it, and accepts first-account consent. Re-run login if a dev-build cold launch loses the session. +Login requests an email OTP, waits up to 30 seconds for the worktree-local outbox, verifies it, accepts first-account consent, and asserts Home. It retries only the known dev-client launch/request boundary once. The wrappers use preflight to open the exact dev-client URL, then `flows/settle-app.yaml` handles late tracking and Expo developer-menu prompts without restarting the app. `flows/open-app.yaml` remains the standalone cold-launch flow. + +Native prompts are states in the flow, not errors to tap through blindly. The shared launch flow recognizes the iOS tracking prompt (`Allow “Kilo” to track your activity across other companies’ apps and websites?`) and chooses `Ask App Not to Track`; login handles notification permission after authentication. Feature-triggered prompts such as speech recognition and microphone access must be handled only when the acceptance flow reaches that feature: inspect the hierarchy, copy the exact button accessibility text (`Allow` or `Don’t Allow`), and choose the state required by the test. Never use a generic `tapOn: 'Allow'` before identifying which prompt is visible. Maestro can emit a large interactive transcript. For agent-driven runs, keep successful output out of context and show only a bounded failure tail: @@ -132,7 +130,11 @@ One-time machine setup: brew install maestro ``` -For MCP use stdio command `maestro mcp`, then restart the agent session so its tools appear. Inspect the screen before selecting elements and re-inspect after UI changes. Flows use `appId: com.kilocode.kiloapp`; accessibility labels match with `text:`, not `id:`. +For MCP use stdio command `maestro mcp`, then restart the agent session so its tools appear. Inspect the screen before selecting elements and re-inspect after UI changes. Never guess a selector from the visible label or screenshot: copy the exact `txt` or `a11y` value from `maestro_inspect_screen`, mapping `a11y` to Maestro `text:`. Maestro text matching is full-string regex, not substring matching. + +Use Maestro as the primary iOS automation driver. Fall back to `xcrun simctl` only when Maestro cannot inspect or operate a native state, or when low-level simulator control is required. iOS setup still uses `simctl` for boot, install/uninstall, dev-client URL reconnection, screenshots, shutdown, and cleanup. + +Tab buttons are exposed through React Navigation's full accessibility labels, not the visible uppercase text. Current iOS labels are `Home, tab, 1 of 4`, `KiloClaw, tab, 2 of 4`, `Agents, tab, 3 of 4`, and `Profile, tab, 4 of 4`. `tapOn: 'Agents'` is wrong. Inspect again before using these examples because tab count or labels can change. CLI fallback: @@ -184,21 +186,41 @@ The mobile list updates after the CLI WebSocket connects and its first heartbeat ## Android Emulator -Follow the [Expo environment guide](https://docs.expo.dev/get-started/set-up-your-environment/) for the one-time Android SDK/JDK setup. This project needs JDK 17. With an AVD already created, keep the emulator in its own tmux session: +Do not conclude that Android is unavailable from `command -v adb` or the agent's inherited `PATH`. The repository resolves the SDK and JDK 17 from `ANDROID_HOME`, `~/Library/Android/sdk`, and standard Homebrew locations. Run the doctor first; it prints resolved absolute paths and available AVDs: + +```bash +pnpm dev:mobile:android doctor +``` + +Use the wrappers for all Android tooling so the resolved SDK/JDK environment is applied, including the Expo/Gradle build: ```bash -export JAVA_HOME=$(/usr/libexec/java_home -v 17) ANDROID_SESSION="kilo-e2e-android-$(basename "$PWD")" -tmux new-session -d -s "$ANDROID_SESSION" \ - "JAVA_HOME=$JAVA_HOME emulator -avd -no-snapshot-save -no-boot-anim -gpu swiftshader_indirect" -adb wait-for-device -adb reverse tcp: tcp: -adb reverse tcp: tcp: +tmux new-session -d -s "$ANDROID_SESSION" -c "$PWD" \ + "pnpm dev:mobile:android emulator -avd -no-snapshot-save -no-boot-anim -gpu swiftshader_indirect" +pnpm dev:mobile:android adb wait-for-device +pnpm dev:mobile:android claim +pnpm dev:mobile:android adb reverse tcp: tcp: +pnpm dev:mobile:android adb reverse tcp: tcp: cd apps/mobile -npx expo run:android --no-install --no-bundler +pnpm -w dev:mobile:android build ``` -Android's `localhost` is the emulator, so restore both `adb reverse` mappings after clearing app data. The dev-client scheme is `exp+kilo-app`. `adb shell pm clear com.kilocode.kiloapp` also forgets the Metro URL, so re-open the dev-client URL afterward. +Use Maestro as the primary Android automation driver, matching iOS. Fall back to repository-wrapped ADB when Maestro cannot inspect or operate a native prompt, when direct intent/process control is required, or when diagnosing the emulator itself. Android setup still uses ADB for readiness and port reversal. Use the repository wrapper rather than bare `adb`: + +```bash +pnpm dev:mobile:android adb devices -l +pnpm dev:mobile:android adb -s shell uiautomator dump /sdcard/window.xml +pnpm dev:mobile:android adb -s shell cat /sdcard/window.xml +pnpm dev:mobile:android adb -s exec-out screencap -p > /tmp/kilo-android.png +pnpm dev:mobile:android adb -s shell input tap +pnpm dev:mobile:android adb -s shell input text '' +pnpm dev:mobile:android adb -s shell input keyevent KEYCODE_BACK +``` + +Derive tap coordinates from the current `uiautomator` bounds, not screenshots or remembered positions. Re-dump after every navigation or prompt. Android's `localhost` is the emulator, so restore both `adb reverse` mappings after clearing app data. The dev-client scheme is `exp+kilo-app`. `adb shell pm clear com.kilocode.kiloapp` also forgets the Metro URL, so re-open the dev-client URL afterward with `adb shell am start`. + +The existing login/logout helpers accept either an iOS simulator UDID or an Android ADB serial. Their shared preflight applies platform-specific device ownership and reconnects the dev client to this worktree before Maestro runs. ## Cleanup @@ -212,6 +234,8 @@ rm -rf "$CLI_SCRATCH" # if created rm -f "$LOGIN_LOG" # if created pnpm dev:stop # only if you started this worktree's stack xcrun simctl shutdown # only if you booted it +pnpm dev:mobile:simulator release # release every simulator you claimed +pnpm dev:mobile:android release # release every Android device you claimed ``` Also stop recorders, log followers, and emulator processes you created. Never use `tmux kill-server`, kill unrelated `kilo-dev-*` sessions, stop a simulator that was already booted, or use `pnpm dev:stop --force` while sibling worktrees are active. diff --git a/apps/mobile/e2e/flows/login-request-code.yaml b/apps/mobile/e2e/flows/login-request-code.yaml index 8d6f683a53..0d281fb4a6 100644 --- a/apps/mobile/e2e/flows/login-request-code.yaml +++ b/apps/mobile/e2e/flows/login-request-code.yaml @@ -1,7 +1,7 @@ # Login step 1/2 — request an email sign-in code. # -# Fresh-launches the app and, if signed out, submits EMAIL to reach the -# "Enter the code" screen. The 6-digit code is emailed to the local outbox +# Establishes a signed-out baseline, then submits EMAIL to reach the "Enter the +# code" screen. The 6-digit code is emailed to the local outbox # (dev/logs/emails/); read it there and pass it to login-verify-code.yaml. # # maestro --device test -e EMAIL=e2e-mobile@example.com \ @@ -14,53 +14,13 @@ # code for you. See e2e/AGENTS.md ("Login / logout helper flows"). appId: com.kilocode.kiloapp --- -# A clean iOS install can put system and Expo onboarding over the app. Clear -# both before returning to SpringBoard; otherwise the Kilo icon is not tappable. -- runFlow: - when: - visible: 'Ask App Not to Track' - commands: - - tapOn: 'Ask App Not to Track' -- runFlow: - when: - visible: 'This is the developer menu.*' - commands: - - tapOn: 'Continue' -- stopApp -- tapOn: - text: 'Kilo' -# Three possible launch states: Home (signed in), the consent gate (signed in, -# not yet consented), or the login page (signed out). +- runFlow: logout.yaml +# Tap the input via its placeholder — the "Email address" label shares the +# field's accessibility text and would match the (non-focusable) label. +- tapOn: 'you@example.com' +- inputText: ${EMAIL} +- tapOn: 'Send sign-in code' +# Wait for the code screen — sending is a network round-trip. - extendedWaitUntil: - visible: - text: 'HOME|Home, tab, 1 of 4|Welcome to Kilo Code|Accept and continue' - timeout: 30000 -# Grant the notification permission prompt if iOS shows one on first launch. -- runFlow: - when: - visible: 'Allow' - commands: - - tapOn: 'Allow' -# Booted into the consent gate (already signed in) -> accept it. -- runFlow: - when: - visible: 'Accept and continue' - commands: - - tapOn: 'Accept and continue' - - extendedWaitUntil: - visible: 'HOME|Home, tab, 1 of 4' - timeout: 15000 -# Signed out -> submit the email to trigger the code. -- runFlow: - when: - visible: 'Welcome to Kilo Code' - commands: - # Tap the input via its placeholder — the "Email address" label shares the - # field's accessibility text and would match the (non-focusable) label. - - tapOn: 'you@example.com' - - inputText: ${EMAIL} - - tapOn: 'Send sign-in code' - # Wait for the code screen — sending is a network round-trip. - - extendedWaitUntil: - visible: 'Verify code' - timeout: 15000 + visible: 'Verify code' + timeout: 15000 diff --git a/apps/mobile/e2e/flows/login-verify-code.yaml b/apps/mobile/e2e/flows/login-verify-code.yaml index 15c8cf43a1..db62612a9f 100644 --- a/apps/mobile/e2e/flows/login-verify-code.yaml +++ b/apps/mobile/e2e/flows/login-verify-code.yaml @@ -17,17 +17,19 @@ appId: com.kilocode.kiloapp # A brand-new account's first sign-in shows a consent screen before Home. - extendedWaitUntil: visible: - text: 'Accept and continue|HOME|Home, tab, 1 of 4' + text: 'Accept and continue|“Kilo” Would Like to Send You Notifications|HOME|Home, tab, 1 of 4' timeout: 20000 - runFlow: when: visible: 'Accept and continue' commands: - tapOn: 'Accept and continue' -# Grant the notification permission prompt if iOS shows one. +- extendedWaitUntil: + visible: '“Kilo” Would Like to Send You Notifications|HOME|Home, tab, 1 of 4' + timeout: 15000 - runFlow: when: - visible: 'Allow' + visible: '“Kilo” Would Like to Send You Notifications' commands: - tapOn: 'Allow' - extendedWaitUntil: diff --git a/apps/mobile/e2e/flows/logout.yaml b/apps/mobile/e2e/flows/logout.yaml index 80a317d47c..7ad6213627 100644 --- a/apps/mobile/e2e/flows/logout.yaml +++ b/apps/mobile/e2e/flows/logout.yaml @@ -4,20 +4,18 @@ # # maestro --device test e2e/flows/logout.yaml # -# Notes: -# - Launches the dev build by tapping its home-screen icon. `launchApp` cold- -# restarts the Expo dev client and bounces back to SpringBoard, so we stop the -# app and relaunch via the icon instead (reliable for dev builds). -# - See e2e/AGENTS.md ("Login / logout helper flows") for the full workflow. +# See e2e/AGENTS.md for the full workflow. appId: com.kilocode.kiloapp --- -- stopApp -- tapOn: - text: 'Kilo' -- extendedWaitUntil: - visible: - text: 'Home, tab, 1 of 4|Welcome to Kilo Code|Accept and continue' - timeout: 30000 +- runFlow: + when: + notVisible: 'Welcome to Kilo Code' + commands: + - runFlow: + when: + notVisible: 'HOME|Home, tab, 1 of 4' + commands: + - runFlow: settle-app.yaml # Booted into the consent gate (signed in, not consented) -> accept it to reach # Home, then sign out below. - runFlow: diff --git a/apps/mobile/e2e/flows/open-app.yaml b/apps/mobile/e2e/flows/open-app.yaml new file mode 100644 index 0000000000..aa34c935fd --- /dev/null +++ b/apps/mobile/e2e/flows/open-app.yaml @@ -0,0 +1,48 @@ +appId: com.kilocode.kiloapp +--- +- runFlow: + when: + visible: 'Ask App Not to Track' + commands: + - tapOn: 'Ask App Not to Track' +- runFlow: + when: + visible: 'This is the developer menu.*' + commands: + - tapOn: 'Continue' +- stopApp +- runFlow: + when: + visible: 'Kilo' + commands: + - tapOn: + text: 'Kilo' +- extendedWaitUntil: + visible: 'Allow “Kilo” to track your activity across other companies’ apps and websites\?|Ask App Not to Track|This is the developer menu.*|“Kilo” Would Like to Send You Notifications|HOME|Home, tab, 1 of 4|Welcome to Kilo Code|Accept and continue' + timeout: 30000 +- extendedWaitUntil: + visible: 'Ask App Not to Track|This is the developer menu.*|“Kilo” Would Like to Send You Notifications' + timeout: 3000 + optional: true +- runFlow: + when: + visible: 'Ask App Not to Track' + commands: + - tapOn: 'Ask App Not to Track' +- extendedWaitUntil: + visible: 'This is the developer menu.*' + timeout: 2000 + optional: true +- runFlow: + when: + visible: 'This is the developer menu.*' + commands: + - tapOn: 'Continue' +- runFlow: + when: + visible: '“Kilo” Would Like to Send You Notifications' + commands: + - tapOn: 'Allow' +- extendedWaitUntil: + visible: 'HOME|Home, tab, 1 of 4|Welcome to Kilo Code|Accept and continue' + timeout: 30000 diff --git a/apps/mobile/e2e/flows/settle-app.yaml b/apps/mobile/e2e/flows/settle-app.yaml new file mode 100644 index 0000000000..d03b043e72 --- /dev/null +++ b/apps/mobile/e2e/flows/settle-app.yaml @@ -0,0 +1,27 @@ +appId: com.kilocode.kiloapp +--- +- extendedWaitUntil: + visible: 'Allow “Kilo” to track your activity across other companies’ apps and websites\?|Ask App Not to Track|This is the developer menu.*|“Kilo” Would Like to Send You Notifications|HOME|Home, tab, 1 of 4|Welcome to Kilo Code|Accept and continue' + timeout: 15000 +- extendedWaitUntil: + visible: 'Ask App Not to Track|This is the developer menu.*|“Kilo” Would Like to Send You Notifications' + timeout: 3000 + optional: true +- runFlow: + when: + visible: 'Ask App Not to Track' + commands: + - tapOn: 'Ask App Not to Track' +- runFlow: + when: + visible: 'This is the developer menu.*' + commands: + - tapOn: 'Continue' +- runFlow: + when: + visible: '“Kilo” Would Like to Send You Notifications' + commands: + - tapOn: 'Allow' +- extendedWaitUntil: + visible: 'HOME|Home, tab, 1 of 4|Welcome to Kilo Code|Accept and continue' + timeout: 15000 diff --git a/apps/mobile/e2e/login.sh b/apps/mobile/e2e/login.sh index e51b12463a..b39804710b 100755 --- a/apps/mobile/e2e/login.sh +++ b/apps/mobile/e2e/login.sh @@ -3,7 +3,7 @@ # # Requests an email sign-in code, reads it from the local email outbox # (dev/logs/emails/, written by the Next.js dev server), and verifies it — -# leaving the app signed in on Home. No-op if already signed in. +# leaving the app signed in on Home. # # Usage: # e2e/login.sh [email] @@ -21,6 +21,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" OUTBOX="${OUTBOX:-$REPO_ROOT/dev/logs/emails}" +"$SCRIPT_DIR/preflight.sh" "$DEVICE" + # Newest sign-in-code email for EMAIL, or empty. latest_email() { [ -d "$OUTBOX" ] || return 0 @@ -38,25 +40,26 @@ latest_email() { before="$(latest_email)" -echo "==> requesting sign-in code for $EMAIL" -maestro --device "$DEVICE" test -e "EMAIL=$EMAIL" "$SCRIPT_DIR/flows/login-request-code.yaml" +echo "==> signing out and requesting sign-in code for $EMAIL" +if ! maestro --device "$DEVICE" test -e "EMAIL=$EMAIL" "$SCRIPT_DIR/flows/login-request-code.yaml"; then + echo "==> retrying launch and sign-in request once after a dev-client startup failure" + maestro --device "$DEVICE" test -e "EMAIL=$EMAIL" "$SCRIPT_DIR/flows/login-request-code.yaml" +fi # Wait for a newer outbox email than we had before (the send is async). code="" -for _ in $(seq 1 10); do +for _ in $(seq 1 120); do after="$(latest_email)" if [ -n "$after" ] && [ "$after" != "$before" ]; then code="$(perl -0777 -ne 'print $1 if /letter-spacing:\s*8px.*?>\s*(\d{6})\s* no new sign-in code; verifying existing session" - maestro --device "$DEVICE" test "$SCRIPT_DIR/flows/login-assert-home.yaml" - echo "==> already signed in" - exit + echo "==> no new sign-in code received" >&2 + exit 1 fi echo "==> verifying sign-in code" diff --git a/apps/mobile/e2e/logout.sh b/apps/mobile/e2e/logout.sh index 7627cfd191..b85a405bed 100755 --- a/apps/mobile/e2e/logout.sh +++ b/apps/mobile/e2e/logout.sh @@ -11,4 +11,5 @@ set -euo pipefail DEVICE="${1:?usage: logout.sh }" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"$SCRIPT_DIR/preflight.sh" "$DEVICE" maestro --device "$DEVICE" test "$SCRIPT_DIR/flows/logout.yaml" diff --git a/apps/mobile/e2e/preflight.sh b/apps/mobile/e2e/preflight.sh new file mode 100755 index 0000000000..2095c61d7f --- /dev/null +++ b/apps/mobile/e2e/preflight.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEVICE="${1:?usage: preflight.sh }" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" + +STATUS="$(cd "$REPO_ROOT" && pnpm -s dev:status --json)" +node - "$STATUS" "$REPO_ROOT" <<'NODE' +const [statusJson, expectedRoot] = process.argv.slice(2); +const status = JSON.parse(statusJson); +const required = ['mobile', 'nextjs', 'cloudflare-session-ingest']; +const failed = required.filter(name => status.services.find(service => service.name === name)?.status !== 'up'); +if (failed.length) throw new Error(`Required services are not up in ${expectedRoot}: ${failed.join(', ')}`); +NODE + +SESSION_INGEST_PORT="$(node - "$STATUS" <<'NODE' +const status = JSON.parse(process.argv[2]); +const port = status.services.find(service => service.name === 'cloudflare-session-ingest')?.port; +if (!port) throw new Error('session-ingest port missing'); +process.stdout.write(String(port)); +NODE +)" +SESSION_PROBE="$(mktemp)" +trap 'rm -f "$SESSION_PROBE"' EXIT +SESSION_STATUS="$(curl -sS -o "$SESSION_PROBE" -w '%{http_code}' \ + -H 'Authorization: Bearer invalid' "http://127.0.0.1:${SESSION_INGEST_PORT}/api/sessions")" +if [ "$SESSION_STATUS" != "401" ] || ! grep -Fq 'Invalid or expired token' "$SESSION_PROBE"; then + printf 'session-ingest secret readiness probe failed with HTTP %s\n' "$SESSION_STATUS" >&2 + cat "$SESSION_PROBE" >&2 + exit 1 +fi + +PLATFORM="ios" +if (cd "$REPO_ROOT" && pnpm -s dev:mobile:android adb devices | awk -v device="$DEVICE" '$1 == device && $2 == "device" { found=1 } END { exit !found }'); then + PLATFORM="android" + CLAIM="$(cd "$REPO_ROOT" && pnpm -s dev:mobile:android claim "$DEVICE")" +else + CLAIM="$(cd "$REPO_ROOT" && pnpm -s dev:mobile:simulator claim "$DEVICE")" +fi +node - "$CLAIM" "$REPO_ROOT" <<'NODE' +const [claimOutput, expectedRoot] = process.argv.slice(2); +const claim = JSON.parse(claimOutput.trim().split('\n').at(-1)); +if (claim.worktreeRoot !== expectedRoot) { + throw new Error(`Device belongs to ${claim.worktreeRoot}, expected ${expectedRoot}`); +} +NODE + +MOBILE_LOG="$(cd "$REPO_ROOT" && pnpm -s dev:capture mobile 300)" +if ! grep -Fq "Starting project at $REPO_ROOT/apps/mobile" <<<"$MOBILE_LOG"; then + printf 'Metro does not belong to this worktree: %s\n' "$REPO_ROOT" >&2 + exit 1 +fi +if ! grep -Eq 'iOS Bundled|Starting Metro Bundler' <<<"$MOBILE_LOG"; then + printf 'Metro has not reached a usable state for %s\n' "$REPO_ROOT" >&2 + exit 1 +fi + +EXPECTED_API_PORT="$(node - "$STATUS" <<'NODE' +const status = JSON.parse(process.argv[2]); +const port = status.services.find(service => service.name === 'nextjs')?.port; +if (!port) throw new Error('nextjs port missing'); +process.stdout.write(String(port)); +NODE +)" +if ! grep -Eq "^API_BASE_URL=http://[^:]+:${EXPECTED_API_PORT}$" "$REPO_ROOT/apps/mobile/.env.local"; then + printf 'apps/mobile/.env.local does not target this worktree nextjs port %s\n' "$EXPECTED_API_PORT" >&2 + exit 1 +fi + +MOBILE_HOST="$(perl -ne 'print $1 if /^API_BASE_URL=http:\/\/([^:]+):/' "$REPO_ROOT/apps/mobile/.env.local")" +METRO_PORT="$(node - "$STATUS" <<'NODE' +const status = JSON.parse(process.argv[2]); +const port = status.services.find(service => service.name === 'mobile')?.port; +if (!port) throw new Error('mobile port missing'); +process.stdout.write(String(port)); +NODE +)" +METRO_URL="http://${MOBILE_HOST}:${METRO_PORT}" +MANIFEST="$(mktemp)" +trap 'rm -f "$SESSION_PROBE" "$MANIFEST"' EXIT +curl -sS -H 'expo-platform: ios' -H 'expo-protocol-version: 1' \ + -H 'accept: application/expo+json,application/json' "$METRO_URL" >"$MANIFEST" +node - "$MANIFEST" "http://${MOBILE_HOST}:${EXPECTED_API_PORT}" "$REPO_ROOT" <<'NODE' +const fs = require('node:fs'); +const [manifestPath, expectedApiUrl, expectedRoot] = process.argv.slice(2); +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); +const expoClient = manifest.extra?.expoClient; +if (expoClient?.extra?.apiBaseUrl !== expectedApiUrl) { + throw new Error(`Metro manifest API URL is ${expoClient?.extra?.apiBaseUrl}, expected ${expectedApiUrl}`); +} +if (expoClient?._internal?.projectRoot !== `${expectedRoot}/apps/mobile`) { + throw new Error(`Metro manifest belongs to ${expoClient?._internal?.projectRoot}, expected ${expectedRoot}/apps/mobile`); +} +NODE +ENCODED_METRO_URL="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$METRO_URL")" +if [ "$PLATFORM" = "ios" ]; then + xcrun simctl openurl "$DEVICE" \ + "exp+kilo-app://expo-development-client/?url=${ENCODED_METRO_URL}" +else + cd "$REPO_ROOT" + pnpm -s dev:mobile:android adb -s "$DEVICE" reverse "tcp:${EXPECTED_API_PORT}" "tcp:${EXPECTED_API_PORT}" + pnpm -s dev:mobile:android adb -s "$DEVICE" reverse "tcp:${METRO_PORT}" "tcp:${METRO_PORT}" + pnpm -s dev:mobile:android adb -s "$DEVICE" shell am start -a android.intent.action.VIEW \ + -d "exp+kilo-app://expo-development-client/?url=${ENCODED_METRO_URL}" >/dev/null +fi + +echo "Mobile E2E preflight passed for $PLATFORM device $DEVICE in $REPO_ROOT ($METRO_URL)" diff --git a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx index d8fe7440da..af355d0557 100644 --- a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx @@ -79,6 +79,7 @@ export default function TabsLayout() { name="(0_home)" options={{ title: 'Home', + tabBarAccessibilityLabel: 'Home, tab, 1 of 4', tabBarLabel: ({ focused }) => , tabBarIcon: ({ color, focused }) => ( @@ -94,7 +95,7 @@ export default function TabsLayout() { name="(1_kiloclaw)" options={{ title: 'KiloClaw', - tabBarAccessibilityLabel: 'KiloClaw', + tabBarAccessibilityLabel: 'KiloClaw, tab, 2 of 4', tabBarLabel: ({ focused }) => ( TAB_LABEL_WRAP_FONT_SCALE ? 'Kilo\nClaw' : 'KiloClaw'} @@ -117,6 +118,7 @@ export default function TabsLayout() { name="(2_agents)" options={{ title: 'Agents', + tabBarAccessibilityLabel: 'Agents, tab, 3 of 4', tabBarLabel: ({ focused }) => , tabBarIcon: ({ color, focused }) => ( @@ -132,6 +134,7 @@ export default function TabsLayout() { name="(3_profile)" options={{ title: 'Profile', + tabBarAccessibilityLabel: 'Profile, tab, 4 of 4', tabBarLabel: ({ focused }) => , tabBarIcon: ({ color, focused }) => ( diff --git a/dev/local/cli.ts b/dev/local/cli.ts index ef372da5b2..7733537232 100644 --- a/dev/local/cli.ts +++ b/dev/local/cli.ts @@ -20,6 +20,7 @@ import { sessionExists, findOtherKiloDevSessions, createSession, + setSessionEnvironment, killSession, attachSession, sendKeys, @@ -34,7 +35,9 @@ import { isTmuxAvailable, findServicePane, isPaneRunningCommand, + captureServicePane, } from './tmux'; +import { detectLanIp, prepareMobileEnvironment } from './mobile-env'; import { findRepoRoot, startServiceInTmux, @@ -127,9 +130,50 @@ async function cmdUp(args: string[], repoRoot: string): Promise { console.log(`${DIM}Port offset: ${portOffset} (KILO_PORT_OFFSET)${RESET}`); } + // --- Resolve targets --- + // Always start core (always-on) groups; additional targets are merged in + const coreServices = resolveGroups(getAlwaysOnGroupIds()); + const extraServices = targets.length === 0 ? [] : resolveTargets(targets); + let serviceNames = topologicalSort([...new Set([...coreServices, ...extraServices])]); + + const mobileEnv: Record = {}; + if (serviceNames.includes('mobile')) { + const host = process.env.MOBILE_DEV_HOST || detectLanIp(); + if (!host) { + throw new Error('Could not detect LAN IP. Set MOBILE_DEV_HOST explicitly.'); + } + Object.assign(mobileEnv, prepareMobileEnvironment(repoRoot, host).sessionEnv); + } + + const envResult = await syncEnvVars({ repoRoot, yes: true, targets: serviceNames }); + if (!envResult.ok) { + throw new Error('Failed to prepare required local service environment'); + } + // --- Check for existing session --- const sessionName = getSessionName(); if (sessionExists(sessionName)) { + for (const name of serviceNames) { + const service = getService(name); + if (service.type !== 'worker' || !findServicePane(sessionName, name)) continue; + const outcome = await restartServiceInTmux(sessionName, name); + if (outcome === 'gave-up') throw new Error(`${name} did not restart with refreshed secrets`); + } + if (Object.keys(mobileEnv).length > 0) { + setSessionEnvironment(sessionName, mobileEnv); + const nextjsPane = findServicePane(sessionName, 'nextjs'); + if (nextjsPane) { + const outcome = await restartServiceInTmux(sessionName, 'nextjs'); + if (outcome === 'gave-up') + throw new Error('nextjs did not restart with refreshed mobile URLs'); + } + const mobilePane = findServicePane(sessionName, 'mobile'); + if (mobilePane) { + const outcome = await restartServiceInTmux(sessionName, 'mobile', mobileEnv); + if (outcome === 'gave-up') + throw new Error('mobile did not restart with refreshed mobile URLs'); + } + } console.log( noAttach ? `Session ${sessionName} already running.` @@ -139,11 +183,24 @@ async function cmdUp(args: string[], repoRoot: string): Promise { return; } - // --- Resolve targets --- - // Always start core (always-on) groups; additional targets are merged in - const coreServices = resolveGroups(getAlwaysOnGroupIds()); - const extraServices = targets.length === 0 ? [] : resolveTargets(targets); - let serviceNames = topologicalSort([...new Set([...coreServices, ...extraServices])]); + const conflictingPorts: string[] = []; + const reusedHostServices = new Set(); + for (const name of serviceNames) { + const service = getService(name); + if (service.type !== 'infra' && service.port > 0 && (await probePort(service.port))) { + if (name === 'kiloclaw-docker-tcp') { + reusedHostServices.add(name); + } else { + conflictingPorts.push(`${name}:${service.port}`); + } + } + } + if (conflictingPorts.length > 0) { + throw new Error( + `Refusing to share occupied worktree service ports: ${conflictingPorts.join(', ')}. ` + + 'Stop the owning worktree or set a distinct KILO_PORT_OFFSET.' + ); + } // --- Check for socat when kiloclaw-docker-tcp is requested --- if (serviceNames.includes('kiloclaw-docker-tcp')) { @@ -209,6 +266,7 @@ async function cmdUp(args: string[], repoRoot: string): Promise { ? `${pnpmHome}${path.delimiter}${processPath}` : processPath; const sessionEnv: Record = { + ...mobileEnv, KILO_PORT_OFFSET: String(portOffset), PATH: sessionPath, WRANGLER_REGISTRY_PATH: wranglerRegistryPath, @@ -413,6 +471,11 @@ async function cmdUp(args: string[], repoRoot: string): Promise { const skippedServices: string[] = []; for (const name of otherServices) { + if (reusedHostServices.has(name)) { + console.log(`Reusing host ${name} on ${getService(name).port}`); + startedServices.push(name); + continue; + } const dependsOnKiloclaw = getService(name).dependsOn.includes('kiloclaw'); if (!kiloclawTunnelCaptured && (name === 'kiloclaw' || dependsOnKiloclaw)) { skippedServices.push(name); @@ -623,7 +686,7 @@ async function cmdStatus(repoRoot: string, isJson = false): Promise { } } -async function cmdRestart(serviceName: string): Promise { +async function cmdRestart(serviceName: string, repoRoot: string): Promise { if (!services.has(serviceName)) { console.error(`Unknown service: ${serviceName}`); process.exit(1); @@ -647,8 +710,16 @@ async function cmdRestart(serviceName: string): Promise { process.exit(1); } + let restartEnv: Record | undefined; + if (serviceName === 'mobile') { + const host = process.env.MOBILE_DEV_HOST || detectLanIp(); + if (!host) throw new Error('Could not detect LAN IP. Set MOBILE_DEV_HOST explicitly.'); + restartEnv = prepareMobileEnvironment(repoRoot, host).sessionEnv; + setSessionEnvironment(sessionName, restartEnv); + } + console.log(`Restarting ${serviceName} (waiting for the old process to shut down)...`); - const outcome = await restartServiceInTmux(sessionName, serviceName); + const outcome = await restartServiceInTmux(sessionName, serviceName, restartEnv); if (outcome === 'gave-up') { console.error(`${serviceName} did not shut down in time; not relaunched`); process.exit(1); @@ -656,6 +727,15 @@ async function cmdRestart(serviceName: string): Promise { console.log(`Restarted ${serviceName}`); } +function cmdCapture(serviceName: string, linesArg: string | undefined): void { + if (!services.has(serviceName)) throw new Error(`Unknown service: ${serviceName}`); + const lines = linesArg === undefined ? 200 : Number(linesArg); + if (!Number.isInteger(lines) || lines < 1 || lines > 10_000) { + throw new Error(`Invalid line count: ${linesArg}`); + } + process.stdout.write(captureServicePane(getSessionName(), serviceName, lines)); +} + async function cmdStop(repoRoot: string, force: boolean): Promise { const sessionName = getSessionName(); @@ -717,6 +797,8 @@ Usage: other kilo-dev sessions are running; --force overrides) dev:status [--json] Show running services and their ports dev:restart Restart a running service + dev:capture [lines] + Capture a service pane wherever the dashboard moved it dev:env [targets...] Sync env vars (.dev.vars + .env.development.local) dev:env --check Validate env vars (CI mode) dev:env -y Sync without confirmation @@ -750,7 +832,13 @@ async function main() { console.error('Usage: dev:restart '); process.exit(1); } - await cmdRestart(serviceName); + await cmdRestart(serviceName, repoRoot); + break; + } + case 'capture': { + const serviceName = args[1]; + if (!serviceName) throw new Error('Usage: dev:capture [lines]'); + cmdCapture(serviceName, args[2]); break; } case 'env': diff --git a/dev/local/env-sync/index.ts b/dev/local/env-sync/index.ts index 1da6a6c409..d8997251c6 100644 --- a/dev/local/env-sync/index.ts +++ b/dev/local/env-sync/index.ts @@ -44,7 +44,7 @@ async function syncEnvVars(options: { }): Promise { const { repoRoot, check = false, yes = false, targets } = options; const serviceFilter = resolveServiceFilter(targets); - const plan = computePlan(repoRoot, serviceFilter); + const plan = computePlan(repoRoot, serviceFilter, !check); if (plan.missingEnvLocal) { displayPlan(plan); @@ -74,7 +74,7 @@ async function syncEnvVars(options: { if (shouldApply) { applyEnvLocalAutoCreates(plan.envLocalAutoCreates, repoRoot); const applyReadyPlan = - plan.envLocalAutoCreates.length > 0 ? computePlan(repoRoot, serviceFilter) : plan; + plan.envLocalAutoCreates.length > 0 ? computePlan(repoRoot, serviceFilter, true) : plan; applyPlan(applyReadyPlan, repoRoot); console.log(`\n${GREEN}✓ Applied${RESET}`); } else { @@ -99,7 +99,7 @@ async function checkEnvVars(repoRoot: string, targets?: string[]): Promise sum + c.missingValues.length, 0) + plan.secretStoreWarnings.reduce((sum, c) => sum + c.bindings.length, 0); diff --git a/dev/local/env-sync/output.ts b/dev/local/env-sync/output.ts index 0b586f61ee..7551a90ab9 100644 --- a/dev/local/env-sync/output.ts +++ b/dev/local/env-sync/output.ts @@ -267,7 +267,7 @@ function createSecretsStoreSecret( storeId: string, secretName: string, value: string -): boolean { +): void { const result = spawnSync( 'pnpm', [ @@ -284,11 +284,16 @@ function createSecretsStoreSecret( { cwd: path.join(repoRoot, workerDir), encoding: 'utf-8', - input: value, // Pass value via stdin for security + input: `${value}\n`, // Complete Wrangler's prompt without exposing the value in argv. stdio: ['pipe', 'pipe', 'pipe'], } ); - return result.status === 0; + if (result.status !== 0) { + const errorOutput = result.stderr.trim(); + throw new Error( + `Failed to create Secrets Store secret ${secretName}${errorOutput ? `: ${errorOutput}` : ''}` + ); + } } function applySecretsStoreAutoCreates(creates: SecretStoreAutoCreate[], repoRoot: string): void { @@ -296,18 +301,14 @@ function applySecretsStoreAutoCreates(creates: SecretStoreAutoCreate[], repoRoot console.log('\nCreating secrets store secrets...'); for (const create of creates) { - const success = createSecretsStoreSecret( + createSecretsStoreSecret( repoRoot, create.workerDir, create.binding.store_id, create.binding.secret_name, create.value ); - if (success) { - console.log(` ✓ ${create.binding.secret_name}`); - } else { - console.error(` ✗ ${create.binding.secret_name} (failed)`); - } + console.log(` ✓ ${create.binding.secret_name}`); } } diff --git a/dev/local/env-sync/plan.test.ts b/dev/local/env-sync/plan.test.ts index 08caef9bda..d01492dc54 100644 --- a/dev/local/env-sync/plan.test.ts +++ b/dev/local/env-sync/plan.test.ts @@ -4,6 +4,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import test from 'node:test'; import { computePlan } from './plan'; +import { getService } from '../services'; const workerDir = 'services/cloud-agent-next'; @@ -147,7 +148,7 @@ test('generates the auto routing worker URL for local Next.js', () => { { key: 'AUTO_ROUTING_WORKER_URL', oldValue: undefined, - newValue: 'http://localhost:8810', + newValue: `http://localhost:${getService('auto-routing').port}`, } ); } finally { @@ -544,7 +545,7 @@ test('rejects reserved generated-secret metadata in source Wrangler config', () } }); -test('preserves an existing annotated local Secrets Store secret', () => { +test('preserves an existing generated local Secrets Store secret', () => { const repo = createGitTokenServiceRepo({}); try { withFakePnpm('SCM_SESSION_CAPABILITY_ENCRYPTION_KEY_DEV\n', () => { @@ -557,6 +558,51 @@ test('preserves an existing annotated local Secrets Store secret', () => { } }); +test('refreshes an existing source-backed local Secrets Store secret', () => { + const repo = createRepo({ + '.env.local': 'NEXTAUTH_SECRET=local-nextauth-secret\n', + 'services/event-service/package.json': JSON.stringify({ scripts: { dev: 'wrangler dev' } }), + 'services/event-service/wrangler.jsonc': `{ + "secrets_store_secrets": [{ + "binding": "NEXTAUTH_SECRET", + "store_id": "store-id", + "secret_name": "NEXTAUTH_SECRET_PROD" + }] + }`, + }); + try { + withFakePnpm('NEXTAUTH_SECRET_PROD\n', () => { + const plan = computePlan(repo.root, new Set(['event-service'])); + assert.equal(plan.secretStoreAutoCreates.length, 1); + assert.equal(plan.secretStoreAutoCreates[0]?.value, 'local-nextauth-secret'); + }); + } finally { + repo.cleanup(); + } +}); + +test('check mode accepts an existing source-backed local Secrets Store secret', () => { + const repo = createRepo({ + '.env.local': 'NEXTAUTH_SECRET=local-nextauth-secret\n', + 'services/event-service/package.json': JSON.stringify({ scripts: { dev: 'wrangler dev' } }), + 'services/event-service/wrangler.jsonc': `{ + "secrets_store_secrets": [{ + "binding": "NEXTAUTH_SECRET", + "store_id": "store-id", + "secret_name": "NEXTAUTH_SECRET_PROD" + }] + }`, + }); + try { + withFakePnpm('NEXTAUTH_SECRET_PROD\n', () => { + const plan = computePlan(repo.root, new Set(['event-service']), false); + assert.deepEqual(plan.secretStoreAutoCreates, []); + }); + } finally { + repo.cleanup(); + } +}); + test('auto-creates event-service NEXTAUTH Secrets Store binding from .env.local', () => { const repo = createRepo({ '.env.local': 'NEXTAUTH_SECRET=local-nextauth-secret\n', @@ -618,7 +664,11 @@ test('auto-creates kilo-chat gateway Secrets Store binding from kiloclaw dev var const plan = computePlan(repo.root, new Set(['kilo-chat'])); assert.equal(plan.missingEnvLocal, false); assert.deepEqual(plan.secretStoreWarnings, []); - assert.deepEqual(plan.secretStoreAutoCreates, [ + assert.equal(plan.secretStoreAutoCreates.length, 2); + assert.deepEqual( + plan.secretStoreAutoCreates.find( + create => create.binding.secret_name === 'GATEWAY_TOKEN_SECRET' + ), { workerDir: 'services/kilo-chat', binding: { @@ -628,8 +678,8 @@ test('auto-creates kilo-chat gateway Secrets Store binding from kiloclaw dev var }, sourceKey: 'services/kiloclaw/.dev.vars.example:GATEWAY_TOKEN_SECRET', value: 'dev-gateway-secret-kiloclaw', - }, - ]); + } + ); }); } finally { repo.cleanup(); @@ -775,16 +825,20 @@ test('preserves host.docker.internal in @url defaults for useLanIp services', () }); try { const plan = computePlan(repo.root, new Set(['cloud-agent-next'])); + const nextjsPort = getService('nextjs').port; + const cloudAgentPort = getService('cloud-agent-next').port; assert.equal(plan.missingEnvLocal, false); assert.equal(plan.devVarsChanges.length, 1); const [change] = plan.devVarsChanges; assert.ok(change); assert.equal(change.isNew, true); const content = change.newFileContent ?? ''; - assert.ok(content.includes('KILOCODE_BACKEND_BASE_URL=http://host.docker.internal:3000')); - assert.ok(content.includes('WORKER_URL=http://host.docker.internal:8794')); + assert.ok( + content.includes(`KILOCODE_BACKEND_BASE_URL=http://host.docker.internal:${nextjsPort}`) + ); + assert.ok(content.includes(`WORKER_URL=http://host.docker.internal:${cloudAgentPort}`)); // ORIGINS keys still use localhost even when example default has host.docker.internal - assert.ok(content.includes('ALLOWED_ORIGINS=http://localhost:3000')); + assert.ok(content.includes(`ALLOWED_ORIGINS=http://localhost:${nextjsPort}`)); } finally { repo.cleanup(); } @@ -811,15 +865,17 @@ test('preserves localhost in worker-side @url defaults for useLanIp services', ( }); try { const plan = computePlan(repo.root, new Set(['cloud-agent-next'])); + const nextjsPort = getService('nextjs').port; + const cloudAgentPort = getService('cloud-agent-next').port; assert.equal(plan.missingEnvLocal, false); assert.equal(plan.devVarsChanges.length, 1); const [change] = plan.devVarsChanges; assert.ok(change); assert.equal(change.isNew, true); const content = change.newFileContent ?? ''; - assert.ok(content.includes('KILOCODE_BACKEND_BASE_URL=http://localhost:3000')); - assert.ok(content.includes('KILO_OPENROUTER_BASE=http://localhost:3000/api')); - assert.ok(content.includes('WORKER_URL=http://host.docker.internal:8794')); + assert.ok(content.includes(`KILOCODE_BACKEND_BASE_URL=http://localhost:${nextjsPort}`)); + assert.ok(content.includes(`KILO_OPENROUTER_BASE=http://localhost:${nextjsPort}/api`)); + assert.ok(content.includes(`WORKER_URL=http://host.docker.internal:${cloudAgentPort}`)); } finally { repo.cleanup(); } diff --git a/dev/local/env-sync/plan.ts b/dev/local/env-sync/plan.ts index aea3bfdc4d..b8e27afd9c 100644 --- a/dev/local/env-sync/plan.ts +++ b/dev/local/env-sync/plan.ts @@ -477,7 +477,11 @@ function collectLocalSecretSources( // Plan computation // --------------------------------------------------------------------------- -function computePlan(repoRoot: string, serviceFilter?: Set): EnvSyncPlan { +function computePlan( + repoRoot: string, + serviceFilter?: Set, + refreshSourceBackedSecrets = true +): EnvSyncPlan { const envLocalPath = path.join(repoRoot, '.env.local'); if (!fs.existsSync(envLocalPath)) { return { @@ -734,11 +738,8 @@ function computePlan(repoRoot: string, serviceFilter?: Set): EnvSyncPlan storeOutputCache.set(cacheKey, output); } - if (output.includes(b.secret_name)) { - continue; // Secret exists, nothing to do - } - if (b.devGeneratedBase64Bytes) { + if (output.includes(b.secret_name)) continue; secretStoreAutoCreates.push({ workerDir: svc.dir, binding: b, @@ -750,14 +751,15 @@ function computePlan(repoRoot: string, serviceFilter?: Set): EnvSyncPlan const source = resolveSecretStoreSource(b.secret_name, envLocal, localSecretSources); if (source) { - // Can auto-create from .env.local or another local worker's dev vars. + if (!refreshSourceBackedSecrets && output.includes(b.secret_name)) continue; + // Recreate source-backed secrets so metadata-only entries and stale values self-heal. secretStoreAutoCreates.push({ workerDir: svc.dir, binding: b, sourceKey: source.sourceKey, value: source.value, }); - } else { + } else if (!output.includes(b.secret_name)) { // Missing and no source value - warn missingBindings.push(b); } diff --git a/dev/local/mobile-android.ts b/dev/local/mobile-android.ts new file mode 100644 index 0000000000..0fe6851c9e --- /dev/null +++ b/dev/local/mobile-android.ts @@ -0,0 +1,236 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +type AndroidEnvironment = { + adb: string; + emulator: string; + javaHome: string; + path: string; + sdkRoot: string; + sdkmanager?: string; +}; + +type ResolveArgs = { + home: string; + path: string; + existingPaths?: ReadonlySet; + javaMajor?: (javaHome: string) => number | undefined; +}; + +type DeviceClaim = { serial: string; worktreeRoot: string; claimedAt: string }; + +function firstExisting( + candidates: string[], + exists: (candidate: string) => boolean +): string | undefined { + return candidates.find(exists); +} + +function resolveAndroidEnvironment(args: ResolveArgs): AndroidEnvironment { + const exists = args.existingPaths + ? (candidate: string) => args.existingPaths!.has(candidate) + : fs.existsSync; + const sdkRoots = [ + process.env.ANDROID_HOME, + process.env.ANDROID_SDK_ROOT, + path.join(args.home, 'Library/Android/sdk'), + '/opt/homebrew/share/android-commandlinetools', + '/usr/local/share/android-commandlinetools', + ].filter((value): value is string => Boolean(value)); + const sdkRoot = sdkRoots.find(root => + firstExisting( + [path.join(root, 'platform-tools/adb'), path.join(root, 'emulator/emulator')], + exists + ) + ); + if (!sdkRoot) { + throw new Error( + 'Android SDK not found in ANDROID_HOME, ~/Library/Android/sdk, or Homebrew android-commandlinetools. Run: brew install --cask android-commandlinetools' + ); + } + + const adb = firstExisting([path.join(sdkRoot, 'platform-tools/adb')], exists); + const emulator = firstExisting([path.join(sdkRoot, 'emulator/emulator')], exists); + const sdkmanager = firstExisting( + [ + path.join(sdkRoot, 'cmdline-tools/latest/bin/sdkmanager'), + '/opt/homebrew/bin/sdkmanager', + '/usr/local/bin/sdkmanager', + ], + exists + ); + const javaHomes = [ + '/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home', + '/opt/homebrew/opt/temurin@17/libexec/openjdk.jdk/Contents/Home', + process.env.JAVA_HOME, + ].filter((value): value is string => Boolean(value)); + const javaMajor = + args.javaMajor ?? + ((candidate: string) => { + const result = spawnSync(path.join(candidate, 'bin/java'), ['-version'], { + encoding: 'utf8', + }); + const match = `${result.stdout}${result.stderr}`.match(/version "(\d+)/); + return match ? Number(match[1]) : undefined; + }); + const javaHome = javaHomes.find( + candidate => exists(path.join(candidate, 'bin/java')) && javaMajor(candidate) === 17 + ); + + const missing = [!adb && 'adb', !emulator && 'emulator', !javaHome && 'JDK 17'].filter(Boolean); + if (missing.length > 0) { + throw new Error(`Android tooling incomplete: missing ${missing.join(', ')}`); + } + + const toolPaths = [ + path.join(sdkRoot, 'platform-tools'), + path.join(sdkRoot, 'emulator'), + sdkmanager && path.dirname(sdkmanager), + path.join(javaHome, 'bin'), + ].filter((value): value is string => Boolean(value)); + return { + adb, + emulator, + javaHome, + path: [...toolPaths, args.path].join(path.delimiter), + sdkRoot, + sdkmanager, + }; +} + +function environment(): AndroidEnvironment { + return resolveAndroidEnvironment({ home: os.homedir(), path: process.env.PATH ?? '' }); +} + +function run(command: string, args: string[], env: AndroidEnvironment, cwd?: string): void { + execFileSync(command, args, { + stdio: 'inherit', + cwd, + env: { + ...process.env, + ANDROID_HOME: env.sdkRoot, + ANDROID_SDK_ROOT: env.sdkRoot, + JAVA_HOME: env.javaHome, + PATH: env.path, + }, + }); +} + +function getAndroidSerials(env: AndroidEnvironment): string[] { + const output = execFileSync(env.adb, ['devices'], { + encoding: 'utf8', + env: { ...process.env, PATH: env.path }, + }); + return output + .split('\n') + .slice(1) + .map(line => line.trim().split(/\s+/)) + .filter(([, state]) => state === 'device') + .map(([serial]) => serial); +} + +function claimPath(serial: string): string { + return path.join( + os.tmpdir(), + 'kilo-mobile-android-claims', + `${serial.replaceAll('/', '_')}.json` + ); +} + +function claimAndroidDevice(serial: string, worktreeRoot: string): DeviceClaim { + const filePath = claimPath(serial); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + try { + const claim = JSON.parse(fs.readFileSync(filePath, 'utf8')) as DeviceClaim; + if (claim.worktreeRoot === worktreeRoot) return claim; + if (fs.existsSync(claim.worktreeRoot)) + throw new Error(`${serial} is claimed by ${claim.worktreeRoot}`); + fs.rmSync(filePath, { force: true }); + } catch (error) { + if (error instanceof SyntaxError) { + fs.rmSync(filePath, { force: true }); + } else if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + // Missing claims can be created atomically below. + } else if (error instanceof Error && error.message.includes(' is claimed by ')) { + throw error; + } + } + const claim = { serial, worktreeRoot, claimedAt: new Date().toISOString() }; + fs.writeFileSync(filePath, JSON.stringify(claim), { flag: 'wx' }); + return claim; +} + +function releaseAndroidDevice(serial: string, worktreeRoot: string): void { + const filePath = claimPath(serial); + const claim = JSON.parse(fs.readFileSync(filePath, 'utf8')) as DeviceClaim; + if (claim.worktreeRoot !== worktreeRoot) + throw new Error(`${serial} is claimed by ${claim.worktreeRoot}`); + fs.rmSync(filePath); +} + +function main(): void { + const [command, ...args] = process.argv.slice(2); + const env = environment(); + const worktreeRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', + }).trim(); + if (command === 'doctor' || command === undefined) { + const avds = execFileSync(env.emulator, ['-list-avds'], { + encoding: 'utf8', + env: { ...process.env, PATH: env.path, JAVA_HOME: env.javaHome }, + }) + .trim() + .split('\n') + .filter(Boolean); + console.log(JSON.stringify({ ...env, avds, worktree: path.basename(process.cwd()) }, null, 2)); + return; + } + if (command === 'build') { + return run( + 'npx', + ['expo', 'run:android', '--no-install', '--no-bundler', ...args], + env, + path.join(worktreeRoot, 'apps/mobile') + ); + } + if (command === 'claim') { + const requested = args[0]; + const serial = requested ?? getAndroidSerials(env)[0]; + if (!serial || !getAndroidSerials(env).includes(serial)) + throw new Error('No connected Android device is available'); + console.log(JSON.stringify(claimAndroidDevice(serial, worktreeRoot))); + return; + } + if (command === 'release') { + const serial = args[0]; + if (!serial) throw new Error('Usage: pnpm dev:mobile:android release '); + releaseAndroidDevice(serial, worktreeRoot); + console.log(`Released ${serial}`); + return; + } + if (command === 'adb') return run(env.adb, args, env); + if (command === 'emulator') return run(env.emulator, args, env); + if (command === 'sdkmanager') { + if (!env.sdkmanager) throw new Error('sdkmanager is not installed'); + return run(env.sdkmanager, args, env); + } + throw new Error( + 'Usage: pnpm dev:mobile:android [doctor|build|claim|release|adb|emulator|sdkmanager] [args...]' + ); +} + +const isMain = + process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename); +if (isMain) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } +} + +export { claimAndroidDevice, releaseAndroidDevice, resolveAndroidEnvironment }; +export type { AndroidEnvironment }; diff --git a/dev/local/mobile-env.test.ts b/dev/local/mobile-env.test.ts index 287039f73c..03a97086f4 100644 --- a/dev/local/mobile-env.test.ts +++ b/dev/local/mobile-env.test.ts @@ -1,17 +1,40 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; -import { applyEnvValues, buildMobileEnvValues, isUsableIpv4, upsertRootEnv } from './mobile-env'; +import { + applyEnvValues, + buildMobileEnvValues, + ensureRootEnv, + isUsableIpv4, + prepareMobileEnvironment, + upsertRootEnv, +} from './mobile-env'; +import { getService } from './services'; test('builds LAN URLs for every mobile-facing local service', () => { const values = buildMobileEnvValues('192.168.1.10'); - assert.equal(values.get('API_BASE_URL'), 'http://192.168.1.10:3000'); - assert.equal(values.get('WEB_BASE_URL'), 'http://192.168.1.10:3000'); - assert.equal(values.get('CLOUD_AGENT_WS_URL'), 'ws://192.168.1.10:8794'); - assert.equal(values.get('SESSION_INGEST_WS_URL'), 'ws://192.168.1.10:8800'); - assert.equal(values.get('KILO_CHAT_URL'), 'http://192.168.1.10:8808'); - assert.equal(values.get('EVENT_SERVICE_URL'), 'ws://192.168.1.10:8809'); - assert.equal(values.get('NOTIFICATIONS_URL'), 'http://192.168.1.10:8804'); + assert.equal(values.get('API_BASE_URL'), `http://192.168.1.10:${getService('nextjs').port}`); + assert.equal(values.get('WEB_BASE_URL'), `http://192.168.1.10:${getService('nextjs').port}`); + assert.equal( + values.get('CLOUD_AGENT_WS_URL'), + `ws://192.168.1.10:${getService('cloud-agent-next').port}` + ); + assert.equal( + values.get('SESSION_INGEST_WS_URL'), + `ws://192.168.1.10:${getService('cloudflare-session-ingest').port}` + ); + assert.equal(values.get('KILO_CHAT_URL'), `http://192.168.1.10:${getService('kilo-chat').port}`); + assert.equal( + values.get('EVENT_SERVICE_URL'), + `ws://192.168.1.10:${getService('event-service').port}` + ); + assert.equal( + values.get('NOTIFICATIONS_URL'), + `http://192.168.1.10:${getService('notifications').port}` + ); }); test('rewrites only requested env keys while preserving comments and other values', () => { @@ -70,3 +93,40 @@ test('validates IPv4-looking host values', () => { assert.equal(isUsableIpv4('localhost'), false); assert.equal(isUsableIpv4(undefined), false); }); + +test('copies the primary worktree root env when a secondary worktree is missing it', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-mobile-env-')); + const primary = path.join(root, 'primary'); + const secondary = path.join(root, 'secondary'); + fs.mkdirSync(primary); + fs.mkdirSync(secondary); + fs.writeFileSync(path.join(primary, '.env.local'), 'NEXTAUTH_SECRET=secret\n'); + + ensureRootEnv(secondary, [primary, secondary]); + + assert.equal( + fs.readFileSync(path.join(secondary, '.env.local'), 'utf8'), + 'NEXTAUTH_SECRET=secret\n' + ); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('prepares mobile URLs before returning values for the Metro tmux environment', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-mobile-env-')); + fs.mkdirSync(path.join(root, 'apps/mobile'), { recursive: true }); + fs.writeFileSync(path.join(root, '.env.local'), 'NEXTAUTH_URL="http://localhost:3000"\n'); + fs.writeFileSync( + path.join(root, 'apps/mobile/.env.local.example'), + 'API_BASE_URL=http://localhost:3000\nWEB_BASE_URL=http://localhost:3000\n' + ); + + const result = prepareMobileEnvironment(root, '192.168.1.10'); + + const appUrl = `http://192.168.1.10:${getService('nextjs').port}`; + assert.equal(result.appUrl, appUrl); + assert.equal(result.sessionEnv.API_BASE_URL, appUrl); + assert.equal(result.sessionEnv.WEB_BASE_URL, appUrl); + assert.equal(result.sessionEnv.NEXTAUTH_URL, undefined); + assert.equal(result.sessionEnv.APP_URL_OVERRIDE, undefined); + fs.rmSync(root, { recursive: true, force: true }); +}); diff --git a/dev/local/mobile-env.ts b/dev/local/mobile-env.ts index 333625a647..94dc6bf101 100644 --- a/dev/local/mobile-env.ts +++ b/dev/local/mobile-env.ts @@ -19,6 +19,10 @@ const URL_KEY_TO_SERVICE = new Map; +type PreparedMobileEnvironment = { + appUrl: string; + sessionEnv: Record; +}; function parseArgs(args: string[]): { host: string | undefined } { let host = process.env.MOBILE_DEV_HOST; @@ -153,6 +157,54 @@ function writeRootEnv(repoRoot: string, host: string): void { fs.writeFileSync(envPath, upsertRootEnv(content, values), 'utf-8'); } +function getWorktreePaths(repoRoot: string): string[] { + const output = execFileSync('git', ['worktree', 'list', '--porcelain'], { + cwd: repoRoot, + encoding: 'utf8', + }); + return output + .split('\n') + .filter(line => line.startsWith('worktree ')) + .map(line => line.slice('worktree '.length)); +} + +function ensureRootEnv(repoRoot: string, worktreePaths?: string[]): void { + const envPath = path.join(repoRoot, ROOT_ENV_REL_PATH); + if (fs.existsSync(envPath)) return; + + const primaryWorktree = (worktreePaths ?? getWorktreePaths(repoRoot))[0]; + const sourcePath = primaryWorktree && path.join(primaryWorktree, ROOT_ENV_REL_PATH); + if ( + sourcePath && + path.resolve(primaryWorktree) !== path.resolve(repoRoot) && + fs.existsSync(sourcePath) + ) { + fs.copyFileSync(sourcePath, envPath); + console.log(`Copied ${ROOT_ENV_REL_PATH} from primary worktree`); + return; + } + + throw new Error( + `Missing ${ROOT_ENV_REL_PATH} in this worktree and the primary worktree. Run pnpm dev:worktree:prepare.` + ); +} + +function prepareMobileEnvironment(repoRoot: string, host: string): PreparedMobileEnvironment { + if (!isUsableIpv4(host)) { + throw new Error(`Invalid mobile development host: ${host}`); + } + ensureRootEnv(repoRoot); + writeMobileEnv(repoRoot, host); + writeRootEnv(repoRoot, host); + + const mobileValues = buildMobileEnvValues(host); + const appUrl = serviceUrl(host, 'nextjs', 'http'); + return { + appUrl, + sessionEnv: Object.fromEntries(mobileValues), + }; +} + function findRepoRoot(): string { let dir = import.meta.dirname; for (let i = 0; i < 20; i++) { @@ -182,10 +234,7 @@ function main(): void { } const repoRoot = findRepoRoot(); - writeMobileEnv(repoRoot, host); - writeRootEnv(repoRoot, host); - - const appUrl = serviceUrl(host, 'nextjs', 'http'); + const { appUrl } = prepareMobileEnvironment(repoRoot, host); console.log(`Wrote ${MOBILE_ENV_REL_PATH}`); console.log(`Updated ${ROOT_ENV_REL_PATH} APP_URL_OVERRIDE and NEXTAUTH_URL`); console.log(`Mobile web/API base URL: ${appUrl}`); @@ -206,4 +255,13 @@ if (isMain) { } } -export { applyEnvValues, buildMobileEnvValues, detectLanIp, isUsableIpv4, upsertRootEnv }; +export { + applyEnvValues, + buildMobileEnvValues, + detectLanIp, + ensureRootEnv, + isUsableIpv4, + prepareMobileEnvironment, + upsertRootEnv, +}; +export type { PreparedMobileEnvironment }; diff --git a/dev/local/mobile-simulator.test.ts b/dev/local/mobile-simulator.test.ts new file mode 100644 index 0000000000..83fe4537e3 --- /dev/null +++ b/dev/local/mobile-simulator.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { claimSimulator, releaseSimulator, type SimulatorDevice } from './mobile-simulator'; + +const devices: SimulatorDevice[] = [ + { id: 'A', name: 'Kilo E2E-A', state: 'Booted' }, + { id: 'B', name: 'Kilo E2E-B', state: 'Shutdown' }, +]; + +test('claims an unowned simulator instead of sharing another worktree simulator', () => { + const lockRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-simulator-locks-')); + const one = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-worktree-one-')); + const two = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-worktree-two-')); + claimSimulator({ devices, lockRoot, worktreeRoot: one, requestedId: 'A' }); + + const claim = claimSimulator({ devices, lockRoot, worktreeRoot: two }); + + assert.equal(claim.device.id, 'B'); + fs.rmSync(lockRoot, { recursive: true, force: true }); + fs.rmSync(one, { recursive: true, force: true }); + fs.rmSync(two, { recursive: true, force: true }); +}); + +test('refuses to release a simulator claimed by another worktree', () => { + const lockRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-simulator-locks-')); + const one = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-worktree-one-')); + const two = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-worktree-two-')); + claimSimulator({ devices, lockRoot, worktreeRoot: one, requestedId: 'A' }); + + assert.throws( + () => releaseSimulator({ deviceId: 'A', lockRoot, worktreeRoot: two }), + new RegExp(`claimed by ${one}`) + ); + fs.rmSync(lockRoot, { recursive: true, force: true }); + fs.rmSync(one, { recursive: true, force: true }); + fs.rmSync(two, { recursive: true, force: true }); +}); + +test('uses exclusive claim creation to prevent concurrent simulator sharing', () => { + const source = fs.readFileSync(new URL('./mobile-simulator.ts', import.meta.url), 'utf8'); + assert.match(source, /flag: 'wx'/); +}); diff --git a/dev/local/mobile-simulator.ts b/dev/local/mobile-simulator.ts new file mode 100644 index 0000000000..e604a2d50d --- /dev/null +++ b/dev/local/mobile-simulator.ts @@ -0,0 +1,130 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +type SimulatorDevice = { id: string; name: string; state: string }; +type ClaimArgs = { + devices: SimulatorDevice[]; + lockRoot: string; + worktreeRoot: string; + requestedId?: string; +}; + +function lockPath(lockRoot: string, deviceId: string): string { + return path.join(lockRoot, `${deviceId}.json`); +} + +function readOwner(lockRoot: string, deviceId: string): string | undefined { + try { + const claim = JSON.parse(fs.readFileSync(lockPath(lockRoot, deviceId), 'utf8')) as { + worktreeRoot?: string; + }; + if (claim.worktreeRoot && fs.existsSync(claim.worktreeRoot)) return claim.worktreeRoot; + fs.rmSync(lockPath(lockRoot, deviceId), { force: true }); + } catch { + fs.rmSync(lockPath(lockRoot, deviceId), { force: true }); + // Missing or invalid claims are unowned. + } + return undefined; +} + +function claimSimulator(args: ClaimArgs): { device: SimulatorDevice; alreadyOwned: boolean } { + const { devices, lockRoot, worktreeRoot, requestedId } = args; + fs.mkdirSync(lockRoot, { recursive: true }); + const candidates = requestedId + ? devices.filter(device => device.id === requestedId) + : [...devices].sort((a, b) => Number(a.state === 'Booted') - Number(b.state === 'Booted')); + if (candidates.length === 0) + throw new Error(`Simulator ${requestedId ?? ''} is not available`.trim()); + + for (const device of candidates) { + const owner = readOwner(lockRoot, device.id); + if (owner === worktreeRoot) return { device, alreadyOwned: true }; + if (owner) { + if (requestedId) throw new Error(`Simulator ${device.id} is claimed by ${owner}`); + continue; + } + try { + fs.writeFileSync( + lockPath(lockRoot, device.id), + JSON.stringify({ deviceId: device.id, worktreeRoot, claimedAt: new Date().toISOString() }), + { flag: 'wx' } + ); + return { device, alreadyOwned: false }; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'EEXIST') { + if (requestedId) throw new Error(`Simulator ${device.id} was claimed concurrently`); + continue; + } + throw error; + } + } + throw new Error('No unclaimed iOS simulator is available'); +} + +function releaseSimulator(args: { + deviceId: string; + lockRoot: string; + worktreeRoot: string; +}): void { + const owner = readOwner(args.lockRoot, args.deviceId); + if (owner && owner !== args.worktreeRoot) { + throw new Error(`Simulator ${args.deviceId} is claimed by ${owner}`); + } + fs.rmSync(lockPath(args.lockRoot, args.deviceId), { force: true }); +} + +function listIosDevices(): SimulatorDevice[] { + const raw = JSON.parse( + execFileSync('xcrun', ['simctl', 'list', 'devices', 'available', '--json'], { + encoding: 'utf8', + }) + ) as { devices: Record> }; + return Object.entries(raw.devices) + .filter(([runtime]) => runtime.includes('.iOS-')) + .flatMap(([, devices]) => devices) + .map(device => ({ id: device.udid, name: device.name, state: device.state })); +} + +function main(): void { + const [command, requestedId] = process.argv.slice(2); + const worktreeRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', + }).trim(); + const lockRoot = path.join(os.tmpdir(), 'kilo-mobile-simulator-claims'); + if (command === 'claim') { + const claim = claimSimulator({ + devices: listIosDevices(), + lockRoot, + worktreeRoot, + requestedId, + }); + if (claim.device.state !== 'Booted') { + execFileSync('xcrun', ['simctl', 'boot', claim.device.id], { stdio: 'ignore' }); + execFileSync('xcrun', ['simctl', 'bootstatus', claim.device.id, '-b'], { stdio: 'inherit' }); + } + console.log(JSON.stringify({ ...claim, worktreeRoot })); + return; + } + if (command === 'release' && requestedId) { + releaseSimulator({ deviceId: requestedId, lockRoot, worktreeRoot }); + console.log(`Released ${requestedId}`); + return; + } + throw new Error('Usage: pnpm dev:mobile:simulator >'); +} + +const isMain = + process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename); +if (isMain) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } +} + +export { claimSimulator, releaseSimulator }; +export type { SimulatorDevice }; diff --git a/dev/local/mobile-workflow.test.ts b/dev/local/mobile-workflow.test.ts new file mode 100644 index 0000000000..7c9bfd1c35 --- /dev/null +++ b/dev/local/mobile-workflow.test.ts @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +test('login waits for the delayed Expo developer menu after launching Kilo', () => { + const flow = fs.readFileSync('apps/mobile/e2e/flows/open-app.yaml', 'utf8'); + const launchIndex = flow.indexOf("text: 'Kilo'"); + const developerMenuWaitIndex = flow.indexOf( + "visible: 'This is the developer menu.*'", + launchIndex + ); + const optionalWaitIndex = flow.lastIndexOf('- extendedWaitUntil:', developerMenuWaitIndex); + const continueIndex = flow.indexOf("tapOn: 'Continue'", developerMenuWaitIndex); + + assert.ok(launchIndex >= 0); + assert.ok(developerMenuWaitIndex > launchIndex); + assert.ok(continueIndex > developerMenuWaitIndex); + assert.match(flow.slice(optionalWaitIndex, continueIndex), /timeout: 2000/); + assert.match(flow.slice(optionalWaitIndex, continueIndex), /optional: true/); +}); + +test('login flows never use an unidentified generic Allow selector', () => { + const request = fs.readFileSync('apps/mobile/e2e/flows/login-request-code.yaml', 'utf8'); + const openApp = fs.readFileSync('apps/mobile/e2e/flows/open-app.yaml', 'utf8'); + + assert.doesNotMatch(request, /visible: 'Allow'/); + assert.match(openApp, /“Kilo” Would Like to Send You Notifications/); +}); + +test('login verification does not pay a fixed optional notification wait', () => { + const verify = fs.readFileSync('apps/mobile/e2e/flows/login-verify-code.yaml', 'utf8'); + + assert.doesNotMatch(verify, /optional: true/); + assert.match(verify, /“Kilo” Would Like to Send You Notifications\|HOME/); +}); + +test('login request establishes a signed-out baseline before requesting a fresh OTP', () => { + const request = fs.readFileSync('apps/mobile/e2e/flows/login-request-code.yaml', 'utf8'); + assert.ok(request.indexOf('logout.yaml') < request.indexOf("tapOn: 'Send sign-in code'")); +}); + +test('login reuses the app state established by logout instead of relaunching each step', () => { + const request = fs.readFileSync('apps/mobile/e2e/flows/login-request-code.yaml', 'utf8'); + const verify = fs.readFileSync('apps/mobile/e2e/flows/login-verify-code.yaml', 'utf8'); + const login = fs.readFileSync('apps/mobile/e2e/login.sh', 'utf8'); + + assert.doesNotMatch(request, /open-app\.yaml/); + assert.doesNotMatch(verify, /open-app\.yaml/); + assert.doesNotMatch(login, /maestro .*logout\.yaml/); + assert.doesNotMatch(login, /login-assert-home\.yaml/); +}); + +test('login polls the local outbox without one-second latency', () => { + const login = fs.readFileSync('apps/mobile/e2e/login.sh', 'utf8'); + assert.match(login, /sleep 0\.25/); +}); + +test('shared launch prompt grace periods total at most five seconds', () => { + const flow = fs.readFileSync('apps/mobile/e2e/flows/open-app.yaml', 'utf8'); + const optionalWaits = [...flow.matchAll(/timeout: (\d+)\n\s+optional: true/g)].map(match => + Number(match[1]) + ); + + assert.equal( + optionalWaits.reduce((total, timeout) => total + timeout, 0), + 5000 + ); +}); + +test('helper-driven logout settles the app already launched by preflight', () => { + const logout = fs.readFileSync('apps/mobile/e2e/flows/logout.yaml', 'utf8'); + const settle = fs.readFileSync('apps/mobile/e2e/flows/settle-app.yaml', 'utf8'); + + assert.match(logout, /settle-app\.yaml/); + assert.doesNotMatch(logout, /open-app\.yaml/); + assert.doesNotMatch(settle, /stopApp|text: 'Kilo'/); + assert.match(settle, /timeout: 3000/); +}); + +test('logout skips prompt settling for stable signed-in and signed-out states', () => { + const logout = fs.readFileSync('apps/mobile/e2e/flows/logout.yaml', 'utf8'); + const settleIndex = logout.indexOf('settle-app.yaml'); + + assert.ok(logout.indexOf("notVisible: 'Welcome to Kilo Code'") < settleIndex); + assert.ok(logout.indexOf("notVisible: 'HOME|Home, tab, 1 of 4'") < settleIndex); +}); + +test('shared launch clears an already-visible tracking prompt before tapping the app icon', () => { + const flow = fs.readFileSync('apps/mobile/e2e/flows/open-app.yaml', 'utf8'); + assert.ok(flow.indexOf("visible: 'Ask App Not to Track'") < flow.indexOf("visible: 'Kilo'")); +}); + +test('mobile workflow documents hierarchy-derived tab selectors', () => { + const runbook = fs.readFileSync('apps/mobile/e2e/AGENTS.md', 'utf8'); + + assert.match(runbook, /Agents, tab, 3 of 4/); + assert.match(runbook, /Never guess a selector from the visible label/); + assert.match(runbook, /pnpm dev:capture mobile/); + assert.match(runbook, /dev:mobile:simulator claim/); +}); + +test('tab layout exposes the exact documented accessibility labels', () => { + const layout = fs.readFileSync('apps/mobile/src/app/(app)/(tabs)/_layout.tsx', 'utf8'); + + for (const label of [ + 'Home, tab, 1 of 4', + 'KiloClaw, tab, 2 of 4', + 'Agents, tab, 3 of 4', + 'Profile, tab, 4 of 4', + ]) { + assert.match(layout, new RegExp(`tabBarAccessibilityLabel: '${label}'`)); + } +}); + +test('login preflight reconnects the claimed iOS device to this worktree Metro URL', () => { + const preflight = fs.readFileSync('apps/mobile/e2e/preflight.sh', 'utf8'); + + assert.match(preflight, /pnpm -s dev:mobile:simulator claim/); + assert.match(preflight, /pnpm -s dev:capture mobile/); + assert.match(preflight, /exp\+kilo-app:\/\/expo-development-client\/\?url=/); + assert.match(preflight, /session-ingest secret readiness probe failed/); + assert.match(preflight, /Metro manifest API URL is/); + assert.match(preflight, /dev:mobile:android claim/); + assert.match(preflight, /adb -s "\$DEVICE" reverse/); +}); + +test('Android tooling is resolved independently of the agent PATH', async () => { + const { resolveAndroidEnvironment } = await import('./mobile-android'); + const env = resolveAndroidEnvironment({ + home: '/Users/test', + path: '/usr/bin:/bin', + existingPaths: new Set([ + '/opt/homebrew/share/android-commandlinetools/platform-tools/adb', + '/opt/homebrew/share/android-commandlinetools/emulator/emulator', + '/opt/homebrew/share/android-commandlinetools/cmdline-tools/latest/bin/sdkmanager', + '/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home/bin/java', + ]), + javaMajor: () => 17, + }); + + assert.equal(env.adb, '/opt/homebrew/share/android-commandlinetools/platform-tools/adb'); + assert.equal(env.emulator, '/opt/homebrew/share/android-commandlinetools/emulator/emulator'); + assert.equal(env.javaHome, '/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home'); + assert.match(env.path, /android-commandlinetools\/platform-tools/); +}); + +test('Android workflow uses Maestro first and applies resolved tooling to Expo builds', () => { + const android = fs.readFileSync('dev/local/mobile-android.ts', 'utf8'); + const runbook = fs.readFileSync('apps/mobile/e2e/AGENTS.md', 'utf8'); + + assert.match(android, /'build'/); + assert.match(android, /'run:android'/); + assert.match(android, /path\.join\(worktreeRoot, 'apps\/mobile'\)/); + assert.match(runbook, /Use Maestro as the primary Android automation driver/); + assert.match(runbook, /Fall back to repository-wrapped ADB/); +}); + +test('iOS workflow uses Maestro first with simctl as the low-level fallback', () => { + const runbook = fs.readFileSync('apps/mobile/e2e/AGENTS.md', 'utf8'); + const verifier = fs.readFileSync('apps/mobile/.kilo/agent/mobile-e2e-verifier.md', 'utf8'); + + assert.match(runbook, /Use Maestro as the primary iOS automation driver/); + assert.match(runbook, /Fall back to `xcrun simctl`/); + assert.match(verifier, /Fall back to `xcrun simctl` on iOS/); +}); + +test('Android tooling rejects a non-Java-17 JAVA_HOME', async () => { + const { resolveAndroidEnvironment } = await import('./mobile-android'); + assert.throws( + () => + resolveAndroidEnvironment({ + home: '/Users/test', + path: '/usr/bin:/bin', + existingPaths: new Set([ + '/opt/homebrew/share/android-commandlinetools/platform-tools/adb', + '/opt/homebrew/share/android-commandlinetools/emulator/emulator', + '/java/bin/java', + ]), + javaMajor: () => 21, + }), + /missing JDK 17/ + ); +}); + +test('Android device ownership uses exclusive worktree claims', () => { + const android = fs.readFileSync('dev/local/mobile-android.ts', 'utf8'); + assert.match(android, /flag: 'wx'/); + assert.match(android, /claimAndroidDevice/); + assert.match(android, /releaseAndroidDevice/); +}); + +test('env sync refreshes source-backed Wrangler secrets through completed stdin prompts', () => { + const plan = fs.readFileSync('dev/local/env-sync/plan.ts', 'utf8'); + const envOutput = fs.readFileSync('dev/local/env-sync/output.ts', 'utf8'); + + assert.match(plan, /Recreate source-backed secrets/); + assert.match(envOutput, /input: `\$\{value\}\\n`/); + assert.match(envOutput, /Failed to create Secrets Store secret/); +}); + +test('workflow documents the shared Docker proxy exception without weakening backend isolation', () => { + const runbook = fs.readFileSync('apps/mobile/e2e/AGENTS.md', 'utf8'); + const cli = fs.readFileSync('dev/local/cli.ts', 'utf8'); + + assert.match(runbook, /sole intentional host-wide exception/); + assert.match(runbook, /Never kill a `socat` process owned by another worktree/); + assert.match(cli, /name === 'kiloclaw-docker-tcp'/); + assert.match(cli, /Refusing to share occupied worktree service ports/); +}); diff --git a/dev/local/runner.ts b/dev/local/runner.ts index 8ff7e73106..b37fa7a3cd 100644 --- a/dev/local/runner.ts +++ b/dev/local/runner.ts @@ -17,6 +17,7 @@ import { paneHasRunningChild, selectPane, setPaneTitle, + setPaneServiceIdentity, setMainLeftLayout, pipePane, } from './tmux'; @@ -116,6 +117,7 @@ export function startServiceInTmux( const startupCommand = svc.type === 'infra' ? buildInfraLogCommand(serviceName) : buildStartCommand(serviceName); const winIndex = createWindow(sessionName, serviceName, env, startupCommand); + setPaneServiceIdentity(sessionName, winIndex, 0, serviceName); const logPath = path.join(findRepoRoot(), 'dev', 'logs', `${serviceName}.log`); pipePane(sessionName, winIndex, 0, buildLogPipeCommand(logPath)); } @@ -298,7 +300,8 @@ const inFlightRestarts = new Map void>(); export function restartServiceInTmux( sessionName: string, - serviceName: string + serviceName: string, + env?: Record ): Promise { const svc = getService(serviceName); if (svc.type === 'infra') return Promise.resolve('not-running'); @@ -339,7 +342,7 @@ export function restartServiceInTmux( // window instead of leaving the service stopped after reporting // "Restarted"; the new window inherits the tmux session environment. try { - startServiceInTmux(sessionName, serviceName); + startServiceInTmux(sessionName, serviceName, env); settle('recreated'); } catch { // Same policy as below: keep the TUI alive; the next explicit @@ -366,7 +369,15 @@ export function restartServiceInTmux( return; } try { - sendKeys(sessionName, currentPane.windowIndex, cmd, currentPane.paneIndex); + const envPrefix = Object.entries(env ?? {}) + .map(([key, value]) => `${key}=${shellQuote(value)}`) + .join(' '); + sendKeys( + sessionName, + currentPane.windowIndex, + envPrefix ? `${envPrefix} ${cmd}` : cmd, + currentPane.paneIndex + ); settle('relaunched'); } catch { // The dashboard may have moved or closed the pane after we resolved it. diff --git a/dev/local/services.test.ts b/dev/local/services.test.ts index bd0e3f93c3..c473bd780a 100644 --- a/dev/local/services.test.ts +++ b/dev/local/services.test.ts @@ -3,12 +3,32 @@ import fs from 'node:fs'; import test from 'node:test'; import { + computePortOffset, getAlwaysOnGroupIds, getService, resolveGroups, resolveSessionNextAuthUrl, } from './services'; +test('uses an automatic port offset for secondary worktrees by default', () => { + assert.equal( + computePortOffset({ explicit: undefined, isPrimary: false, slug: 'mobile-context-info' }), + 1100 + ); +}); + +test('never assigns default ports to a secondary worktree', () => { + assert.equal(computePortOffset({ explicit: 'auto', isPrimary: false, slug: 'd' }), 5000); +}); + +test('keeps the primary worktree on the default ports', () => { + assert.equal(computePortOffset({ explicit: undefined, isPrimary: true, slug: 'cloud' }), 0); +}); + +test('honors an explicit port offset', () => { + assert.equal(computePortOffset({ explicit: '1200', isPrimary: false, slug: 'anything' }), 1200); +}); + test('points NEXTAUTH_URL at the offset port when the web app runs without a tunnel', () => { const url = resolveSessionNextAuthUrl({ portOffset: 2900, @@ -51,14 +71,22 @@ test('keeps auto routing workers in their own opt-in group', () => { assert.equal(service.group, 'auto-routing'); assert.equal(service.type, 'worker'); assert.equal(service.dir, 'services/auto-routing'); - assert.equal(service.port, 8810); + assert.equal( + service.port, + 8810 + + computePortOffset({ + explicit: process.env.KILO_PORT_OFFSET, + isPrimary: false, + slug: 'harden-mobile-agent-workflow', + }) + ); assert.match(service.command.join(' '), /pnpm run dev/); const benchmark = getService('auto-routing-benchmark'); assert.equal(benchmark.group, 'auto-routing'); assert.equal(benchmark.type, 'worker'); assert.equal(benchmark.dir, 'services/auto-routing-benchmark'); - assert.equal(benchmark.port, 8814); + assert.equal(benchmark.port - service.port, 4); const alwaysOn = resolveGroups(getAlwaysOnGroupIds()); assert.ok(!alwaysOn.includes('auto-routing')); @@ -82,7 +110,7 @@ test('keeps auto routing package dev script compatible with local launcher flags test('starts Storybook with Storybook v10 port flags', () => { const service = getService('storybook'); - assert.deepEqual(service.command, ['pnpm', 'run', 'storybook', '-p', '6006']); + assert.deepEqual(service.command, ['pnpm', 'run', 'storybook', '-p', String(service.port)]); }); test('preserves auto routing backend auth secret name', () => { diff --git a/dev/local/services.ts b/dev/local/services.ts index 2e4aed0e88..e88d777aec 100644 --- a/dev/local/services.ts +++ b/dev/local/services.ts @@ -266,24 +266,35 @@ function isPrimaryWorktree(): boolean { return path.resolve(gitDir) === path.resolve(gitCommonDir); } -function computeAutoOffset(): number { - if (isPrimaryWorktree()) return 0; - - const root = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim(); - const slug = path.basename(root); - +export function computePortOffset(args: { + explicit: string | undefined; + isPrimary: boolean; + slug: string; +}): number { + const { explicit, isPrimary, slug } = args; + if (explicit !== undefined && explicit !== 'auto') { + const value = Number(explicit); + if (!Number.isInteger(value) || value < 0) { + throw new Error(`Invalid KILO_PORT_OFFSET: ${explicit}`); + } + return value; + } + if (isPrimary) return 0; let hash = 0; for (let i = 0; i < slug.length; i++) { hash = ((hash << 5) - hash + slug.charCodeAt(i)) | 0; } - return (((hash % 50) + 50) % 50) * 100; + const bucket = ((hash % 50) + 50) % 50; + return (bucket === 0 ? 50 : bucket) * 100; } function getPortOffset(): number { - const explicit = process.env.KILO_PORT_OFFSET; - if (explicit === undefined) return 0; // disabled by default - if (explicit === 'auto') return computeAutoOffset(); - return Number(explicit); + const root = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim(); + return computePortOffset({ + explicit: process.env.KILO_PORT_OFFSET, + isPrimary: isPrimaryWorktree(), + slug: path.basename(root), + }); } export const portOffset = getPortOffset(); diff --git a/dev/local/tmux.test.ts b/dev/local/tmux.test.ts index bc82e15137..3b6648d56b 100644 --- a/dev/local/tmux.test.ts +++ b/dev/local/tmux.test.ts @@ -5,7 +5,13 @@ import * as os from 'node:os'; import * as path from 'node:path'; import test from 'node:test'; -import { breakPane, buildInteractiveShellCommand, listWindows } from './tmux'; +import { + breakPane, + buildInteractiveShellCommand, + captureServicePane, + listWindows, + setPaneServiceIdentity, +} from './tmux'; import { buildStartCommand, restartServiceInTmux } from './runner'; test('buildInteractiveShellCommand wraps quoted startup commands in parseable shell syntax', () => { @@ -33,6 +39,49 @@ function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } +test( + 'captureServicePane follows a service after the dashboard moves its pane', + { skip: !hasTmux }, + () => { + const sessionName = `kilo-tmux-test-${process.pid}-${Date.now()}`; + const serviceName = 'mobile'; + const tmux = (...args: string[]) => execFileSync('tmux', args, { stdio: 'ignore' }); + + try { + tmux('new-session', '-d', '-s', sessionName, '-n', 'dashboard', 'sleep 120'); + tmux( + 'new-window', + '-d', + '-t', + sessionName, + '-n', + serviceName, + '/bin/sh', + '-c', + 'printf "worktree-mobile-pane\\n"; sleep 120' + ); + const serviceWindow = listWindows(sessionName).find(window => window.name === serviceName); + assert.ok(serviceWindow); + setPaneServiceIdentity(sessionName, serviceWindow.index, 0, serviceName); + tmux( + 'join-pane', + '-h', + '-s', + `${sessionName}:${serviceWindow.index}.0`, + '-t', + `${sessionName}:0.0` + ); + assert.match(captureServicePane(sessionName, serviceName, 20), /worktree-mobile-pane/); + } finally { + try { + tmux('kill-session', '-t', sessionName); + } catch { + // Session may already be gone if tmux fails during setup. + } + } + } +); + test( 'breakPane keeps the requested service window name after tmux automatic rename', { skip: !hasTmux }, diff --git a/dev/local/tmux.ts b/dev/local/tmux.ts index f230c8b150..e776e96309 100644 --- a/dev/local/tmux.ts +++ b/dev/local/tmux.ts @@ -108,6 +108,12 @@ function createSession(sessionName: string, env?: Record): void }); } +function setSessionEnvironment(sessionName: string, env: Record): void { + for (const [key, value] of Object.entries(env)) { + execFileSync('tmux', ['set-environment', '-t', sessionName, key, value], { stdio: 'ignore' }); + } +} + function killSession(sessionName: string): void { try { execSync(`tmux kill-session -t ${sessionName}`, { stdio: 'ignore' }); @@ -152,6 +158,26 @@ function createWindow( return parseInt(output, 10); } +function setPaneServiceIdentity( + sessionName: string, + windowTarget: string | number, + pane: number, + serviceName: string +): void { + execFileSync( + 'tmux', + [ + 'set-option', + '-p', + '-t', + `${sessionName}:${windowTarget}.${pane}`, + '@kilo_service', + serviceName, + ], + { stdio: 'ignore' } + ); +} + function buildInteractiveShellCommand( startupCommand: string, shell = process.env.SHELL || '/bin/sh' @@ -370,6 +396,24 @@ type PaneInfo = { windowIndex: number; paneIndex: number }; */ function findServicePane(sessionName: string, serviceName: string): PaneInfo | undefined { try { + const panes = execFileSync( + 'tmux', + [ + 'list-panes', + '-s', + '-t', + sessionName, + '-F', + '#{window_index}:#{pane_index}:#{@kilo_service}', + ], + { encoding: 'utf8' } + ).trim(); + for (const line of panes.split('\n')) { + const [windowIndex, paneIndex, identity] = line.split(':'); + if (identity === serviceName) { + return { windowIndex: Number(windowIndex), paneIndex: Number(paneIndex) }; + } + } // Check named windows first (service in its own window) const windows = listWindows(sessionName); const win = windows.find(w => w.name === serviceName); @@ -391,6 +435,26 @@ function findServicePane(sessionName: string, serviceName: string): PaneInfo | u } } +function captureServicePane(sessionName: string, serviceName: string, historyLines = 200): string { + const pane = findServicePane(sessionName, serviceName); + if (!pane) { + throw new Error(`Service ${serviceName} is not running in ${sessionName}`); + } + return execFileSync( + 'tmux', + [ + 'capture-pane', + '-p', + '-J', + '-t', + `${sessionName}:${pane.windowIndex}.${pane.paneIndex}`, + '-S', + `-${historyLines}`, + ], + { encoding: 'utf8' } + ); +} + function isPaneRunningCommand(sessionName: string, pane: PaneInfo): boolean { try { const command = execSync( @@ -503,9 +567,11 @@ export { sessionExists, findOtherKiloDevSessions, createSession, + setSessionEnvironment, killSession, attachSession, createWindow, + setPaneServiceIdentity, buildInteractiveShellCommand, sendKeys, sendInterrupt, @@ -523,6 +589,7 @@ export { breakPane, countPanes, findServicePane, + captureServicePane, isPaneRunningCommand, paneHasRunningChild, selectPane, diff --git a/package.json b/package.json index d4a653ce26..dff940f025 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,11 @@ "dev:stop": "tsx dev/local/cli.ts stop", "dev:status": "tsx dev/local/cli.ts status", "dev:restart": "tsx dev/local/cli.ts restart", + "dev:capture": "tsx dev/local/cli.ts capture", "dev:env": "tsx dev/local/cli.ts env", "dev:env:mobile": "tsx dev/local/mobile-env.ts", + "dev:mobile:simulator": "tsx dev/local/mobile-simulator.ts", + "dev:mobile:android": "tsx dev/local/mobile-android.ts", "dev:setup-env": "tsx dev/local/setup-env.ts", "dev:seed": "tsx dev/seed/index.ts", "dev:discord-gateway-cron": "tsx dev/discord-gateway-cron.ts", diff --git a/scripts/worktree-prepare.sh b/scripts/worktree-prepare.sh index 27704be2cc..03283cb91c 100755 --- a/scripts/worktree-prepare.sh +++ b/scripts/worktree-prepare.sh @@ -12,6 +12,11 @@ WEB_ENV_FILE="apps/web/.env.development.local" TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT +if command -v direnv >/dev/null 2>&1 && [ -f .envrc ]; then + echo "==> Authorizing this worktree's root direnv config…" + direnv allow . +fi + upsert_env_line() { local file="$1" local line="$2" @@ -92,6 +97,17 @@ elif [ -f "$MAIN_WORKTREE/$WEB_ENV_FILE" ]; then cp "$MAIN_WORKTREE/$WEB_ENV_FILE" "./$WEB_ENV_FILE" fi +if [ "$MAIN_WORKTREE_REALPATH" != "$CURRENT_WORKTREE_REALPATH" ] && + [ -f "$MAIN_WORKTREE/apps/mobile/.env.local" ]; then + echo "==> Copying apps/mobile/.env.local from main worktree…" + cp "$MAIN_WORKTREE/apps/mobile/.env.local" ./apps/mobile/.env.local +fi + +if command -v direnv >/dev/null 2>&1 && [ -f apps/mobile/.envrc ]; then + echo "==> Authorizing this worktree's mobile direnv config…" + direnv allow ./apps/mobile +fi + echo "==> Syncing Next.js development env…" pnpm dev:env -y nextjs