diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index bc4db54..83c7c26 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "iterable-sdk", - "version": "26.9.14-beta", + "version": "26.10.0-beta", "description": "Takes an Android app — native or React Native — from nothing to a push notification proven on a real device: Firebase provisioning, version-pinned SDK integration, silent-failure pitfalls, and device-side proof that a push actually arrived. Integration guidance covers React Native and Expo on both platforms; provisioning and device-side proof are Android-first.", "author": { "name": "Iterable" }, "repository": "https://github.com/Iterable/iterable-sdk-skill", diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 1535ad5..70bec9b 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -3,7 +3,7 @@ "owner": { "name": "Iterable" }, "metadata": { "description": "Iterable's official skills for AI coding assistants — mobile SDK integration guidance.", - "version": "26.9.14-beta" + "version": "26.10.0-beta" }, "plugins": [ { diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index e30f191..ae8a965 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "iterable-sdk", - "version": "26.9.14-beta", + "version": "26.10.0-beta", "description": "Takes an Android app — native or React Native — from nothing to a push notification proven on a real device: Firebase provisioning, version-pinned SDK integration, silent-failure pitfalls, and device-side proof that a push actually arrived. Integration guidance covers React Native and Expo on both platforms; provisioning and device-side proof are Android-first.", "author": { "name": "Iterable" }, "repository": "https://github.com/Iterable/iterable-sdk-skill", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1035327 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,152 @@ +# Contributing to the Iterable SDK skills + +This guide is for Iterable engineers and Docs team members reviewing the +plugin's developer-facing and model-facing content. + +The commands in this file are repository-maintenance tools. Developers who +install the plugin do not run them. + +## Copy-review quick start + +From the repository root: + +```bash +make screens-help +make screens +``` + +`make screens-help` lists the available scenarios and their source files. +`make screens` renders every shared onboarding question with sample Firebase +projects and Android devices. + +The preview requires Node.js. It does not require credentials, network access, +an Iterable or Firebase project, or a connected device. + +For a more comfortable full review: + +```bash +make screens | less -R +``` + +To review one scenario: + +```bash +make screens KIND=opening +make screens KIND=pick_device +make screens KIND="iterable_step 3" +``` + +The heading above each rendered question identifies the source responsible for +its words and the shell function responsible for its behavior. + +## Where screen copy lives + +Screen copy is being moved from shell into writer-facing Markdown one screen at +a time: + +- `copy/screens/*.md` contains the words for migrated screens. +- `bin/questions.sh` contains behavior for every screen and the words for + screens that have not yet moved. +- Stable option IDs connect a Markdown label and description to the relevant + shell behavior. + +Only `copy/screens/opening.md` has moved so far. See +[`copy/screens/README.md`](copy/screens/README.md) for its format, supported +sections, and placeholders. + +### Editing a migrated screen + +1. Run `make screens KIND=`. +2. Edit the `copy/screens/*.md` file listed above the preview. +3. Do not rename or remove an `Option:` ID. +4. Render the scenario again. +5. Run `make test`. + +Labels, descriptions, paragraph structure, and option order are copy. Option +IDs, commands, consent records, state changes, and the effect of selecting an +option are behavior. + +### Editing an unmigrated screen + +Unmigrated copy still lives inside `bin/questions.sh`. Docs reviewers can +propose wording there, but should pair with an engineer before changing the +shell source. The engineer should confirm that the edit does not change option +effects, commands, consent boundaries, or state transitions. + +When practical, move the screen into `copy/screens/` as a separate engineering +change instead of expanding the mixture of prose and behavior in shell. + +## Other developer-facing text + +`make screens` covers the shared questions and options supplied to the terminal +wizard and coding-agent conversation. It does not render every user-facing +string in the onboarding program. + +| Surface | Source | +|---|---| +| Shared onboarding questions | `copy/screens/*.md` and `bin/questions.sh` | +| Gate names and verdicts | `bin/gates`, `bin/gates-device.sh`, `bin/gates-iterable.sh` | +| Banners, summaries, endings, and ladder headings | `bin/config.sh` | +| Iterable dashboard instructions | `bin/iterable-keys` | +| Terminal-only interaction text | `bin/wizard` | +| Coding-agent protocol and refusals | `bin/agent` | +| Installed-user documentation | `README.md` | +| Model-facing skill instructions | each skill's `SKILL.md`, `PITFALLS.md`, and `reference/` files | + +The engineering map for these surfaces is in +[`bin/README.md`](bin/README.md). + +## Model-facing instructions + +Human-facing copy and model-facing instructions are separate review surfaces. +`make screens` previews what a developer reads; it does not preview the complete +context supplied to a coding agent. + +When changing a `SKILL.md`, agent definition, or other model-facing instruction, +follow Iterable's +[System Prompt Design Guide](https://github.com/Iterable/Iterable/blob/master/iterable-agents/src/mastra/prompts/PROMPT_GUIDE.md). +Keep the guide authoritative instead of reproducing its rules here. + +Local instructions should explain Iterable-specific routing, policy, and +boundaries. Executable mechanics belong in scripts and tool descriptions. +Update or add a test when an instruction addresses a concrete failure mode. + +## Validation + +For a copy-only screen change: + +```bash +make screens KIND= +make test +``` + +For onboarding behavior or shell changes: + +```bash +make test +``` + +Use `make test-all` only when the network-backed published-spec check is +intentionally in scope. + +For generated SDK reference or pipeline changes, follow [`REVIEW.md`](REVIEW.md) +and run: + +```bash +cd pipeline +pnpm check:all +``` + +## Pull-request handoff + +In the pull request, state: + +- which developer-facing or model-facing surface changed; +- which scenario, reader problem, or failure mode motivated the change; +- whether the change affects words, behavior, or both; +- which command you used to preview it; +- which tests you ran. + +For a Docs review, include the relevant `make screens KIND=...` commands so the +reviewer can reproduce the rendered questions without setting up Firebase, +Iterable, or an Android device. diff --git a/Makefile b/Makefile index 9926e37..3677e30 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: link-agents unlink-agents test test-all +.PHONY: link-agents unlink-agents test test-all screens screens-help AGENT_DIR := $(HOME)/.claude/agents @@ -27,3 +27,12 @@ test: # Plus the ones that reach Iterable's published spec. test-all: @tests/run-all.sh --all + +# Every screen a developer can be shown, with the line its words live on. For reading and +# changing the wording: `make screens | less -R`, or `make screens KIND=pick_device` for one. +screens: + @tools/screens $(KIND) + +# Usage and the complete list of scenario names accepted by `make screens KIND=...`. +screens-help: + @tools/screens --help diff --git a/README.md b/README.md index a7af202..5617d7a 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,124 @@ -# iterable-sdk-skill - -> **Beta.** Iterable's mobile SDK skills are currently in private beta. By signing -> up to use them during this period, you agree to our -> [Beta Terms](https://iterable.com/legal/beta-terms/). -> -> This document describes how to use the beta version of the plugin. Iterable -> reserves the right to change, update, and add or remove content. This -> documentation may contain errors and/or inaccuracies and is provided "as is," -> without warranties of any kind. -> -> This documentation is confidential and may not be shared outside of your -> organization. -> -> Current functionality is subject to change. - -Point your AI coding assistant at your Android or React Native app and this -plugin takes it from nothing to **a push notification proven to have arrived on a -real device** — creating the Firebase prerequisites, writing the integration -against version-pinned docs, and then reading the device's own notification -service to confirm delivery. - -The plugin ships **skills, agents, and shell scripts only**. Everything runs on -your machine, under your assistant, with your approval. No source code, no -credential, and no key ever leaves your machine. - ---- - -## What's in the box: four skills - -The skills form a funnel — each stage hands off to the next — and any one can be -used on its own. - -| # | Skill | What it does | -|---|-------|--------------| -| 1 | **iterable-provision** | Produces the prerequisites nobody can invent: a Firebase Android app and its real `google-services.json`, a service account scoped to sending push plus its JSON key, and the Iterable dashboard steps (two API keys, the mobile app, the Firebase push integration, the test identity). Then it **proves each one worked**. | -| 2 | **iterable-android** / **iterable-react-native** | Writes and debugs the integration itself, against Iterable's documentation bundled inside the skill. React Native covers both bare workflow and Expo (`@iterable/expo-plugin`). | -| 3 | **iterable-verify** | Sends a real push and reads the device's notification service to confirm it arrived. Read-only: it never provisions, never edits your app, and never re-sends to manufacture a pass. | - -**The path:** `iterable-provision → iterable-android` (or `iterable-react-native`) -`→ iterable-verify`. The integration skills used to stop when Preflight reached an -input nobody had — a `google-services.json`, a mobile API key, a configured push -integration. Now they route to `iterable-provision` instead of stopping, and hand -to `iterable-verify` once the code is written. - -> Skills are **model-invoked**. You don't type a command — describe what you want -> ("set up Iterable push in this app", "why isn't my push arriving?", "prove this -> works") and your assistant picks the right skill. - ---- - -## Prerequisites +# Iterable SDK skills + +> **Private beta.** Use of these skills is subject to Iterable's +> [Beta Terms](https://iterable.com/legal/beta-terms/). This documentation is +> confidential, may contain inaccuracies, and may change during the beta. + +Use these skills with Claude Code, Cursor, or Codex to integrate Iterable's +Android or React Native SDK. For Android push integrations, the workflow can +also create the required Firebase resources and verify that a real push +notification reaches your device. + +The plugin runs under your coding assistant. The provisioning workflow asks +before reading or changing scoped cloud resources. + +## Included skills + +| Skill | Purpose | +|---|---| +| `iterable-provision` | Creates and verifies Firebase prerequisites, then guides and checks the required Iterable setup. | +| `iterable-android` | Integrates and troubleshoots Iterable's native Android SDK using version-pinned documentation. | +| `iterable-react-native` | Integrates and troubleshoots `@iterable/react-native-sdk`, including Expo projects. | +| `iterable-verify` | Verifies an Android push integration by confirming that a real notification reached a selected device. | + +The skills can work independently. For a new Android push integration, the +usual sequence is: + +`iterable-provision` → `iterable-android` or `iterable-react-native` → `iterable-verify` + +Skills are model-invoked. Describe the outcome you want rather than running a +skill by name. For example: + +- "Set up Iterable push notifications in this app." +- "Integrate Iterable's React Native SDK." +- "Why isn't my push notification arriving?" +- "Verify that this integration works on my emulator." + +## Supported workflows + +- Native Android SDK integration. +- React Native integration for Android and iOS, including Expo. +- Firebase provisioning and device-side push verification for Android. +- Push notifications, in-app messages, mobile inbox, embedded messaging, deep + links, JWT authentication, event tracking, and user profiles. +- Unknown user activation for native Android. + +Native iOS and web skills are not included. Provisioning and device-side proof +are Android-first; the React Native skill can integrate the iOS SDK, but it does +not provision or verify APNs. + +## Requirements ### Accounts -- An **Iterable project**, and an [API key](https://support.iterable.com/hc/en-us/articles/360043464871) - — or let `iterable-provision` walk you through creating the keys. -- A **Google account** that can create or access a Firebase project. +- An Iterable project. The provisioning skill can guide you through creating + the required Iterable API keys and mobile configuration. +- A Google account with access to a Firebase project, or permission to create + one, when setting up Android push. -You sign in to both yourself. The plugin never asks for, receives, or handles -your passwords — see [Security](#security). +You authenticate with Google and Iterable yourself. The plugin does not ask for +or handle your account passwords. ### Command-line tools -`iterable-provision` and `iterable-verify` drive real tools on your machine. Check -what you have: +The provisioning and verification workflows require these non-standard tools: ```bash -node --version && gcloud --version && adb --version +node --version +gcloud --version +adb --version ``` -| Tool | Needed for | Install | -|------|-----------|---------| -| **`gcloud`** | the entire Google half — project, Firebase, service account, IAM, key | [Google Cloud CLI](https://cloud.google.com/sdk/docs/install), or `brew install --cask gcloud-cli` | -| **`adb`** | the device half — reading the installed app, its FCM token, and the arriving push | Android Studio's SDK Manager (platform-tools), or `brew install --cask android-platform-tools` | -| **`node`** | parsing API responses | [nodejs.org](https://nodejs.org), or `brew install node` | - -Those three and nothing else: you build and install your own app, so your JDK and -Gradle are yours and the plugin never invokes them. +| Tool | Used for | Installation | +|---|---|---| +| `gcloud` | Google authentication, Firebase projects, service accounts, IAM, and key creation | [Google Cloud CLI](https://cloud.google.com/sdk/docs/install) or `brew install --cask gcloud-cli` | +| `adb` | Selecting an Android device and reading the installed app, SDK registration, and arriving notification | Android Studio's SDK Manager or `brew install --cask android-platform-tools` | +| Node.js | Parsing command and API output | [nodejs.org](https://nodejs.org) or `brew install node` | -**If one is missing, nothing is guessed.** Gate `G0 Tooling present` goes red, every -gate below it reports `waiting on Tooling present` rather than failing, and the run -stops with exit `10` — *blocked on you*, not *broken*. You are told exactly which -binaries are absent and what each one unlocks. The plugin will not install them for -you: changing your toolchain is your decision, not your assistant's. +The scripts also use Bash, `curl`, and standard Unix utilities. They do not +install or modify your JDK, Gradle, Android SDK, or Node.js toolchain. During +integration, your coding assistant may run your project's existing build and +install commands with your approval. -### A device +If a required tool is missing, the workflow reports which tool is needed and +stops without attempting to install it. -`iterable-verify` needs a real Android device or a running emulator **with Google -Play services** — FCM cannot deliver to an emulator image without them. A push -proven on one is the only evidence this plugin accepts that the integration works. +### Android device ---- +Device-side verification requires a connected Android device or a running +emulator with Google Play services. You select the target device, even when +only one is connected, so the proof is performed on the device you intend to +test. -## Supported agents - -Cursor, Claude Code, and Codex. Each has an install path below. - -## Install +## Installation ### Claude Code -``` +```text /plugin marketplace add Iterable/iterable-sdk-skill /plugin install iterable-sdk@iterable ``` -This installs all four skills in one step. Start a new Claude Code session — -skills load at session start. +Start a new Claude Code session after installation. Skills are loaded when a +session starts. -Then turn on auto-update, so corpus refreshes reach you without you asking: -`/plugin` → **Marketplaces** → **iterable** → **Enable auto-update**. Claude -Code disables auto-update by default for third-party marketplaces, so without -this you stay on the version you first installed until you update by hand. See -[Staying current](#staying-current). +To receive updates automatically, open `/plugin`, select **Marketplaces**, +select **iterable**, and enable auto-update. ### Cursor -Requires [Cursor 3.9+](https://cursor.com). Clone this repo, then symlink the -skill directories into `~/.cursor/skills/` (not `~/.cursor/plugins/local/` — -that path alone does not load the skill): +Cursor requires version 3.9 or later. Clone the repository and link the skill +directories: ```bash git clone --depth 1 https://github.com/Iterable/iterable-sdk-skill.git ~/iterable-skills mkdir -p ~/.cursor/skills -for s in iterable-provision iterable-android iterable-react-native iterable-verify; do - ln -sfn ~/iterable-skills/$s ~/.cursor/skills/$s +for skill in iterable-provision iterable-android iterable-react-native iterable-verify; do + ln -sfn ~/iterable-skills/$skill ~/.cursor/skills/$skill done ``` -Reload Cursor (`Cmd+Shift+P` → **Developer: Reload Window**), then start a -**new Agent chat**. Skills load at session start — an existing chat won't pick -this up. +Run **Developer: Reload Window**, then start a new Agent chat. ### Codex @@ -139,220 +127,100 @@ codex plugin marketplace add Iterable/iterable-sdk-skill codex plugin add iterable-sdk@iterable ``` -Then start a new Codex session. To verify: `codex plugin list`. +Start a new Codex session, then use `codex plugin list` to confirm that the +plugin is installed. -For local development on this repo, add the checkout as the marketplace source -instead of GitHub: +For local development, use the repository checkout as the marketplace source: ```bash codex plugin marketplace add . codex plugin add iterable-sdk@iterable ``` -If you only need the raw skill folders and do not want the plugin marketplace -flow, symlink them directly: - -```bash -mkdir -p ~/.codex/skills -for s in iterable-provision iterable-android iterable-react-native iterable-verify; do - ln -sfn ~/iterable-skills/$s ~/.codex/skills/$s -done +## What happens during Android push setup + +1. The integration skill inspects the application and adds the required SDK + configuration. +2. If Firebase or Iterable prerequisites are missing, the provisioning skill + explains what it needs to read and asks for permission before continuing. +3. The workflow creates or selects the Firebase resources and service account. +4. It guides you through the Iterable dashboard actions that do not have a + public API: API keys, the mobile app, and the Firebase push integration. +5. Your coding assistant builds and installs the application using the + project's existing toolchain. +6. The workflow opens the selected application's launcher activity. You sign in + as the test user and respond to any Android permission prompts. +7. The verification skill sends a proof notification and checks Android's + notification service to confirm that it arrived. + +The component that performs an action does not verify its own result. For +example, the sender records which proof notification it sent, while a separate +read-only verifier checks the device for that notification. + +## Security and data handling + +- The repository's scripts run locally and make authenticated HTTPS requests + to Google, Firebase, and Iterable. +- You authenticate in your own browser or `gcloud` session. The scripts do not + collect account passwords or attempt to bypass sign-in, CAPTCHA, or + terms-of-service screens. +- Secrets are designed to move through protected files, environment variables, + or the local clipboard workflow rather than through chat, logs, or command + arguments. +- The generated `.iterable/` workspace is kept inside your project and + gitignores itself. It can contain credentials and should be treated as + sensitive. +- The verification gates only read application and notification state. A + separate actor can open the selected app's launcher activity, but it does not + tap, type, sign in, or answer permission prompts. + +Your coding assistant runs with your local privileges. Its handling of source +code and conversation data is governed by the assistant provider and your +organization's configuration. Install plugins only from sources you trust. + +## Current limitations + +- Firebase provisioning and device-side proof support Android only. +- Creating Iterable API keys, mobile apps, and push integrations requires + dashboard interaction because these actions do not have public APIs. +- JWT-enabled mobile API keys are recognized but are not currently supported by + the verification workflow. +- Some bundled source articles cover multiple mobile platforms and may include + snippets that are not relevant to the current application. +- These skills focus on SDK integration. To work with campaign or user data, + use Nova Agent in Iterable or + [Iterable's MCP Server](https://support.iterable.com/hc/articles/42936800222612). + +## Updating + +For Claude Code, update the marketplace before updating the plugin: + +```text +/plugin marketplace update iterable +/plugin update iterable-sdk@iterable ``` -Start a new Codex session after the symlink; skills are loaded at session start. - ---- +Start a new session after updating. With auto-update enabled, Claude Code may +ask you to reload plugins after a newer version is downloaded. -## How it works - -### The reference half - -`iterable-android` and `iterable-react-native` each carry a copy of Iterable's -documentation inside them (`iterable-android/reference/`, -`iterable-react-native/reference/`), so your assistant has the docs on hand even -offline. That bundled copy is the authoritative source — there is nothing to fetch -at runtime. The skill routes each task to the right doc slug, pitfalls file, and -integration checklist in -[`iterable-android/SKILL.md`](iterable-android/SKILL.md) or -[`iterable-react-native/SKILL.md`](iterable-react-native/SKILL.md). - -When Iterable's source documentation changes, an automated workflow refreshes the -corpus — see [Staying current](#staying-current). - -### The provisioning and proof half - -`iterable-provision` and `iterable-verify` drive a program in [`bin/`](bin/README.md) -built on one rule: - -> **The actor never grades its own work.** - -`bin/provision` and `bin/proof-push` *do* things. `bin/gates` *judges* them and can -do nothing at all — it is a read-only verifier over eighteen gates, `G0`–`G17`, -covering tooling, Google auth, the Firebase project and app, `google-services.json`, -the service account and its key, the Iterable keys and dashboard steps, the installed -APK, the FCM token, and the push itself. - -Two of those gates cannot be faked: - -- **`G9 Key actually works`** mints a real OAuth token from the service-account key - and makes FCM accept it. A key file that merely parses does not pass. -- **`G16 Push arrives on device`** finds the push in the device's own - `dumpsys notification` output, matched by the marker the send wrote. Not a log - line claiming success — the notification itself. - -**It says "unproven" rather than guessing.** Three Iterable dashboard steps have no -public API to read back — checked path by path against the published spec, not -assumed. Those gates print `~ unverifiable` instead of a tick, and exit code `40` -exists so that *"nothing is broken and nothing is proven"* is a thing the tool can -actually say. A gate that reads a local file to decide a remote step happened is -precisely the false pass this design exists to prevent. - -Exit codes: `0` all green · `10` blocked on you · `20` a gate failed · `30` tool -error · `40` nothing broken, nothing proven yet. - -[`INTENDED-FLOW.md`](INTENDED-FLOW.md) is the contract for the path a run takes, and -it changes before the code does. - -### The agents - -Four subagents carry the parts of the work that need their own narrow permissions: - -| Agent | Role | -|-------|------| -| `gcp-provisioner` | the Google side — Firebase, service accounts, IAM, keys (`G1`–`G9`) | -| `iterable-api-client` | Iterable's REST API and the verification loop (`G10`, `G14`–`G17`) | -| `web-operator` | the **only** agent with browser access, origin-confined to the Firebase, Google Cloud, and Iterable consoles | -| `gate-auditor` | adversarial re-derivation of whether a gate is truly green — read-only, and never audits its own gate | - ---- - -## What it covers - -Push notifications, in-app messages, mobile inbox, embedded messaging, deep -linking, JWT authentication, event tracking, and user profiles (plus unknown-user -activation on Android). Snippets are version-pinned to the SDK release each was -validated against. Android and React Native today; iOS and Web are coming. - -Provisioning and device-side proof are **Android-first**. React Native apps get the -same provisioning and, for their Android target, the same proof; iOS/APNs is not -covered yet. - ---- - -## Security - -- **The plugin never handles your passwords.** You authenticate in your own browser - and your own `gcloud`; the assistant attaches to the session you already have. It - will never defeat a CAPTCHA, a bot check, or a terms-of-service acceptance on your - behalf. -- **Secrets are never pasted into chat.** Keys are referenced by file path and - environment variable. The service-account key and API keys are written mode `0600` - into a workspace that gitignores itself, never echoed to a log, a report, or a - trace, and never passed as a command-line argument — argv is readable by any other - process. Deletion of the key is offered once it has been uploaded. -- **Nothing about your app is invented.** No placeholder `google-services.json`, no - fabricated API key, no commenting out a plugin to make a build pass. A missing - prerequisite is reported as missing. -- **The verifier can only read.** `bin/gates` performs no provisioning, and - `iterable-verify` never edits your app and never re-sends to turn a red gate green. -- **Least privilege.** The service account it creates is scoped to sending push. - `roles/firebase.admin` is never used. -- **Browser access is confined.** `web-operator` is the only agent that can drive a - browser, and only against `console.firebase.google.com`, - `console.cloud.google.com`, and `app.iterable.com`. -- **No telemetry.** Nothing is reported anywhere. The workspace stays in your project - and is gitignored, because it holds live session cookies and a downloaded key. - -Note that plugins and marketplaces run with your privileges — install only from -sources you trust. This is a general caution, not specific to Iterable. - ---- - -## Known limitations - -- **Mobile SDK integration only.** To query campaigns or user data in your - Iterable project, use the Nova Agent in the Iterable app. -- **Separate from [Iterable's MCP Server](https://support.iterable.com/hc/articles/42936800222612).** - The MCP Server connects your assistant to Iterable's APIs for campaign and - user data tasks. Use these skills when you're writing, provisioning, or - debugging mobile app code; use the MCP Server when you need to query or act on - data in your Iterable project. -- **Three Iterable dashboard steps are yours to click.** They have no public API. - The plugin tells you exactly what to click and then proves the result. -- **JWT-enabled mobile keys are recognised, not supported.** The tool names the - failure rather than guessing. Use a non-JWT key to get push working, then switch. -- **Some docs carry foreign snippets.** A few articles come from shared - "Mobile SDKs" pages and still contain iOS/JS code an Android agent must ignore. - ---- - -## Staying current - -When Iterable's docs change, a workflow rebuilds **every** configured platform's -corpus in one pass (`pnpm refresh:docs`), validates it, and commits to `main` -naming the platforms that actually changed — so the corpus tracks the docs without -waiting on a review. A maintainer audits refreshes after the fact; see -[`REVIEW.md`](REVIEW.md). - -### How a refresh reaches you - -Claude Code and Codex decide whether to update by comparing the version in -`.claude-plugin/plugin.json` against the version you have installed, and skip -the plugin when they match. So every refresh that changes the corpus also bumps -that version — otherwise new docs would sit on `main` and never reach a single -installed plugin. - -Versions are calendar-based, `YY.M.PATCH`: `26.9.0-beta` is the first release of -September 2026, `26.9.1-beta` the next, and the patch resets when the month rolls -over. The `-beta` suffix stays on every release while the plugin is in private -beta. The number says *when*, not how much changed — for that, read the commit -the version came from: +For Cursor's clone-and-link installation: ```bash -git log --oneline main --grep '^docs refresh:' -``` - -With auto-update enabled, Claude Code refreshes shortly after a session starts -(after a random delay of up to ten minutes) and prompts you to run -`/reload-plugins`; the session you are in keeps what it loaded at launch. - -To update by hand, refresh the marketplace first, then the plugin: - +cd ~/iterable-skills +git pull ``` -/plugin marketplace update iterable -/plugin update iterable-sdk@iterable -``` - -`/plugin update` reads your local copy of the marketplace catalogue and does not -refresh it, so on its own it reports `already at the latest version` however far -behind you are. -Cursor's install above is a clone and symlink rather than a plugin, so there it -is `git pull` in your clone and a **Developer: Reload Window**. +Then reload the Cursor window and start a new Agent chat. -Maintainers: `cd pipeline && pnpm refresh:docs` refreshes Android and React -Native together. Pass a platform name (`pnpm refresh:docs -- android`) to -limit the run. Refresh needs `gh` authenticated against private -`Iterable/iterable-docs` (`DOCS_READ_TOKEN` / `GH_TOKEN`). +## Contributing and architecture ---- +Repository-maintenance commands are documented in +[`CONTRIBUTING.md`](CONTRIBUTING.md). In particular, Docs team members can use +the copy-review workflow there to render onboarding questions without +credentials, network access, or a connected device. -## Repo layout +Engineering references: -``` -iterable-provision/ Provisioning skill — SKILL.md -iterable-android/ Android skill — SKILL.md + PITFALLS.md + reference/ -iterable-react-native/ React Native skill — same shape -iterable-verify/ Proof skill — SKILL.md -bin/ the program the two new skills drive — see bin/README.md -agents/ the four subagents the skills delegate to -INTENDED-FLOW.md the contract for the path a run takes -tests/ offline suites over bin/ — `make test` -pipeline/ refresh tooling + validation gates, CI-run -eval/ scenario definitions for scoring skill vs. no-skill answers -docs/onboard/ templates: inputs.yml, env.example -.claude-plugin/ Claude Code + Codex plugin + marketplace manifests -.cursor-plugin/ Cursor plugin + marketplace manifests -context7.json Context7 indexing manifest -mcp.json / .mcp.json Context7 MCP server config (Cursor / Claude Code discovery) -``` +- [`bin/README.md`](bin/README.md) — onboarding program architecture. +- [`INTENDED-FLOW.md`](INTENDED-FLOW.md) — authoritative onboarding sequence. +- [`REVIEW.md`](REVIEW.md) — generated SDK documentation review. diff --git a/bin/README.md b/bin/README.md index 5caab40..805da4c 100644 --- a/bin/README.md +++ b/bin/README.md @@ -1,300 +1,294 @@ -# `bin/` — the program behind `iterable-provision` and `iterable-verify` +# Onboarding program architecture -Maintainer reference for the shell program the two skills drive. For the product view — what a -client installs and why — read the [repo README](../README.md). For the path a run is *supposed* -to take, [`INTENDED-FLOW.md`](../INTENDED-FLOW.md) is the contract, and it changes before the code -does. +This document is for engineers maintaining the onboarding program under +`bin/`. For installation and product usage, see the +[repository README](../README.md). For copy review, see +[`CONTRIBUTING.md`](../CONTRIBUTING.md). -**Paths and commands in this file are relative to the repo root**, where `bin/gates` resolves. A -developer driving the tool from their own project never types these: they get one-line stubs in -`.iterable/`, for the reason in [Why the stubs exist](#why-the-stubs-exist). +[`INTENDED-FLOW.md`](../INTENDED-FLOW.md) is the authoritative description of +the expected onboarding sequence. Change that contract and its tests before +changing the sequence implemented here. -The whole design rests on one property: +Paths and commands in this document are relative to the repo root. +Installed users normally interact with generated `.iterable/` entry points +rather than these `bin/` paths. -> **The actor never grades its own work.** +## Core invariants -`bin/provision` and `bin/proof-push` do things. `bin/gates` judges them and can do nothing at all. +The central verification rule is: -## Three tiers, in strict preference order +> The component that performs an action does not verify its own result. -Nothing here drives a browser. +`bin/provision`, `bin/proof-push`, and `bin/launch-app` perform actions. +`bin/gates` independently reads the resulting state and does not provision +resources, edit the application, launch it, or send a notification. -1. **Deterministic** — `gcloud`, REST, and Iterable's public API. Everything Google-side, and the - entire verification loop. -2. **Human, by design** — three Iterable dashboard steps have no API at all, so the developer does - them and the tool proves the result. `bin/iterable-keys` says exactly what to click, takes the - two keys off the clipboard (or off a prompt with echo off), and hands straight to the verifier. -3. **Human, because it must be** — Google Cloud ToS, Google sign-in, Iterable sign-in, CAPTCHAs, - org-policy blocks. The tool writes what it needs to `.iterable/ASK.md` and exits `10`. +Other invariants: -Moving a step to a later tier requires evidence the earlier tier cannot do it, not a hunch. A -recorded-Playwright tier for tier 2 is designed and deferred: it would be a convenience layer over -a path that already works, and Iterable's key-creation screen is mid-rewrite. +- Missing prerequisites are reported rather than fabricated. +- A developer's answer is required before reading or changing scoped cloud + resources. +- Secrets are passed by protected file, environment variable, or the local + clipboard workflow, not printed into chat or command arguments. +- Non-interactive entry points never wait on a prompt. +- The terminal wizard and coding-agent path use the same question objects and + option behavior. +- Device verification gates only read the selected Android device. +- The launch actor can start the selected package's launcher activity, but + cannot tap, type, sign in, or grant permissions. -**The tool never handles anybody's password.** The developer signs in themselves, in their own -browser or their own `gcloud`; the agent attaches to that session. +## Automation tiers -## Three front ends, one verdict +The program prefers the most deterministic available interface: -`bin/onboard` dispatches the first two on whether stdin is a TTY. `bin/agent` is asked for by name. +1. **Command or API:** `gcloud`, REST APIs, and Iterable's public API. +2. **Developer dashboard action:** used when Iterable has no public API for the + operation. +3. **Developer authentication or policy action:** Google and Iterable sign-in, + terms acceptance, CAPTCHA, or organization-policy resolution. -| Caller | Front end | Why | +The shell program does not drive a browser. Agent-level browser access, where +available, is separately restricted by the `web-operator` definition. + +## Entry points + +`bin/onboard` selects the terminal or non-interactive path based on whether +stdin is a TTY. `bin/agent` is invoked explicitly by a coding agent. + +| Caller | Entry point | Behavior | |---|---|---| -| A human at a terminal | `bin/wizard` — menus, inline `gcloud auth login`, one confirm before any change | It can host interactive sub-processes, so nobody leaves and comes back | -| A script or CI job | `bin/onboard` report-and-exit: prints the ladder, names the single next action, exits `0`/`10`/`20`/`40` | No prompt can ever block it; safe to drive from a loop | -| An agent | `bin/agent` — one JSON object on stdout, the ladder on stderr, the same exit codes | A model branches on `next.kind` instead of parsing a table it might read wrong | - -All three share one verdict and one set of actions. What differs is the conversation and only that: -the wizard asks with menus, `bin/agent` hands the same questions to the host's own question UI. -Neither invents a gate, and neither decides anything the other wouldn't. - -## The `next` protocol - -`next` is the whole contract: `{owner, kind, gate, step, command, summary}`. - -- `owner` — `human`, `tool`, `agent` or `none`. -- `kind` — the branch to take (`authenticate`, `choose_target`, `approve_firebase`, `provision`, - `iterable_keys`, `install_app`, `run_app`, `send_proof`, `install_tools`, `done`, …). -- `step` — the same step in the developer's words, because `G13` is our vocabulary and not theirs. -- `command` — what to run, when there is something to run. - -It is derived from `state.tsv`, never from the exit code: rc `40` says something is pending and -never which thing, rc `10` says a human is needed and never which step. - -Two rules the JSON keeps: **paths, never values** — a credential in a report is a credential in a -log, so `artifacts` carries `path` and `present` and nothing else — and **the reporter never acts**, -so `bin/agent` provisions nothing and sends nothing. It names the script; the caller runs it. - -Every screen the wizard shows has a twin in `next.question`, including registering a missing -Android app and picking the device by name, so the chat is the whole walk rather than a router to -the terminal. `run_app` keeps its question even after the device is settled: `bin/launch-app` opens -the selected package, then signing in and answering Android's notification dialog happen inside -the developer's app, so the tool asks and waits. - -The gates still only read `dumpsys` and `logcat`. The one device actor, `bin/launch-app`, starts the -selected package's launcher activity and stops there: it never taps, types, signs in, or grants a -permission. A permission the tool granted itself would not be evidence of anything. - -### Who drives the Google setup stays open until the developer answers - -`next.kind` comes back as `choose_driver` — the fork itself — rather than either of its answers. -`bin/agent set DRIVER=agent` or `DRIVER=developer` is the developer answering; nothing else may set -either. `bin/handoff` sends them to their own terminal, which is the one place that can host the -Google sign-in in the same session. - -Once `DRIVER=agent` is recorded, `setup.handoff_command` and `setup.drive_it_yourself` both come -back `null`. A field an agent can see is a field an agent can reach for, and a terminal block left -on offer at every read is how a developer who asked for the chat still ended up holding a command -to type. - -That routing is advice to whoever reads it, and twice an agent ran the setup anyway — so the actor -asks the same question and answers it from disk. `bin/provision` and `bin/onboard --apply` exit -`10` with the handoff block unless a terminal is attached, or `DRIVER=agent` is *recorded* in -`resolved.env`. Recorded, not exported: `DRIVER=agent bin/onboard --apply` is a caller authorising -itself, so the environment loses here on purpose. A tty is the one thing a chat cannot fake, which -is what lets the wizard through the same gate without an exception written for it. - -### Consent is a mechanism, not a paragraph - -Nothing reaches somebody's Google project until a yes is recorded against *that project*: `next` -routes to `approve_firebase` ahead of any mutation, and `bin/provision` exits `10` without making a -single call if the record is missing — so an agent that skips the question is stopped by the program -rather than by prose. `approval.plan` is generated from the ladder, so the banner, the dry run and -the JSON cannot describe different work. `APPROVED=1` is the same answer for a job with no human in -it. - -**The read is gated the same way**, because it was the one that kept happening: three transcripts -show an agent finding `gcloud` already signed in and listing every Firebase project the account -could see, on the strength of prose asking it not to. Being able to look is not being allowed to -look, so `bin/discover` also exits `10` having read nothing until a yes is recorded — scoped to the -Google account rather than to a project, since no project is chosen yet. Three yeses, three sizes: -`list` to look, `firebase` to change, `create-app` to add. - -The `list` yes goes further, because the refusal above hands an agent a motive to record it itself: -it has to have come off a screen. Rendering `choose_target` mints a one-time token into -`.iterable/asked/` and stamps the moment the screen finished printing. `agent approve list` takes -`--asked ` and refuses at exit `10` if there is no screen, if the token is not the one the -screen carried, or if the yes arrives sooner than `ASK_DWELL_MS` (200ms) after it — nobody reads a -consent screen in a fifth of a second. The token is spent on use, so one screen buys one yes. - -What this cannot catch is a caller that renders the screen, holds the token and waits: that is -forging consent rather than drifting past prose, and no check on this side of the conversation can -tell it from a developer who agreed. A tty skips the whole thing — the wizard asks in person. - -## The pieces - -The split is structural rather than conventional. - -| Command | Role | -|---|---| -| `bin/gates` | **Verifier.** Read-only. Never provisions anything | -| `bin/agent` | **Reporter.** Runs the verifier and serialises its state; provisions nothing, sends nothing | -| `bin/provision` | **Actor.** Idempotent; ends by handing off to `bin/gates` to be judged | -| `bin/proof-push` | **Actor.** Sends the one push the ladder exists to prove, and records the marker it sent. Never checks whether it arrived | -| `bin/launch-app` | **Actor.** Opens the selected package's launcher activity on the selected device. Never taps, types, signs in, or grants permission | -| `bin/discover` | Read-only list of the developer's Firebase projects and their Android apps | -| `bin/iterable-keys` | Walks the four Iterable dashboard steps that have no API, captures the keys, then hands off to `bin/gates`. Offers the identity the app already registered, read from the device | -| `bin/handoff` | Prints the block that sends a developer to their own terminal, for an agent to relay verbatim. Changes nothing | -| `bin/teardown` | Removes only what the tool added | - -Without a terminal, `bin/iterable-keys` prints the same four dashboard steps and writes -`.iterable/.env` at mode `0600` with three empty names. A key gets in from there with -`bin/iterable-keys --take server|mobile`, which reads it off the clipboard — where the dashboard's -copy button just put it — writes it to that file, clears the clipboard and prints only a tick. So -nobody pastes a key into a conversation, and the gates prove the keys by spending them. - -### The suites that pin all this - -`make test` runs them; they are offline unless noted. - -| Suite | What it holds still | +| Developer in a terminal | `bin/wizard` | Interactive menus, in-session authentication, and confirmation before changes | +| Script or CI | `bin/onboard` | Prints the ladder and next action, then exits without prompting | +| Coding agent | `bin/agent` | Emits one JSON object on stdout and the human-readable ladder on stderr | + +All three paths use the same state, actions, and gate results. Conversation +presentation differs, but selecting an option must have the same effect in each +front end. + +## Component map + +| Command | Responsibility | |---|---| -| `tests/agent-next-action.sh` | Every ladder state → one owner and one action, against fixture state files. The agent path has no human to notice a mis-route | -| `tests/agent-questions.sh` | The chat and the terminal show the same screens | -| `tests/key-propagation.sh` | G9 tells "key hasn't propagated yet" apart from "key is broken" | -| `tests/fcm-classify.sh` | The FCM verdict, against recorded bodies — the two 403s that mean opposite things | -| `tests/itbl-classify.sh` | The Iterable verdict: a wrong key stays distinguishable from a wrong request | -| `tests/key-types-match-spec.sh` | The key type each gate sends, against Iterable's published spec (needs the network) | -| `tests/itbl-transport.sh` | The HTTP status survives the call — the bug that broke G10 live | -| `tests/device-token-log.sh` | G14 against recorded logcat: which absences mean something, and which mean nothing | -| `tests/device-notify.sh` | G16 against recorded `dumpsys notification`: nothing but the push we sent reads as the push we sent | -| `tests/device-pick.sh` | Which device the gates read, with a stubbed `adb` and `emulator` — the serial that isn't there, and the AVD that never boots | -| `tests/no-raw-http.sh` | Lint: every API call goes through a wrapper, and no credential reaches `resolved.env` | -| `tests/no-bare-commands.sh` | Lint: nothing tells a developer to run a path that cannot resolve from their own project | -| `tests/wizard-decline.exp` | Drives the wizard through a pty and asserts that declining changes nothing | - -## Exit codes - -`bin/onboard` is idempotent: it skips gates that are already green. When it needs the developer it -writes `.iterable/ASK.md`, exits `10`, and picks up where it left off next time. Safe to drive from -a loop or a cron tick. +| `bin/onboard` | Selects an interactive or report-and-exit front end | +| `bin/wizard` | Renders shared questions as terminal menus | +| `bin/agent` | Reports state and serializes the next action for a coding agent | +| `bin/gates` | Runs the read-only verifier and writes gate state | +| `bin/provision` | Creates or updates the approved Google and Firebase resources | +| `bin/proof-push` | Sends one marked proof notification | +| `bin/launch-app` | Opens the selected package's launcher activity on the selected device | +| `bin/discover` | Lists visible Firebase projects and their Android apps after approval | +| `bin/iterable-keys` | Guides the four-step Iterable dashboard workflow and captures API keys | +| `bin/handoff` | Prints the terminal handoff when the developer chooses that path | +| `bin/teardown` | Removes resources created by the onboarding program | + +Shared helpers and presentation logic live in `bin/config.sh`. Device-specific +checks live in `bin/gates-device.sh`; Iterable checks live in +`bin/gates-iterable.sh`. + +## State and the `next` protocol + +`state.tsv` is the source for the current ladder state. Exit codes describe the +overall result, but do not identify the next action. + +`bin/agent` derives a `next` object with these primary fields: + +```text +owner, kind, gate, step, command, summary +``` -| Code | Meaning | +- `owner` identifies who acts next: `human`, `tool`, `agent`, or `none`. +- `kind` identifies the branch, such as `authenticate`, `choose_target`, + `approve_firebase`, `provision`, `iterable_keys`, `install_app`, `run_app`, + `send_proof`, or `done`. +- `gate` identifies the blocking or pending verification gate. +- `step` explains the action in developer-facing language. +- `command` names the command to run when the repository can perform the + action. +- `summary` provides concise context for the caller. + +Reports contain paths and presence indicators for sensitive artifacts, never +credential values. The reporter identifies commands but does not execute +provisioning or send a proof notification. + +## Consent and ownership + +The program enforces consent in state rather than relying only on prompt text. +Approvals are scoped independently: + +- `list`: inspect projects visible to the current Google account; +- `firebase`: change the selected project; +- `create-app`: register an Android app in that project. + +The coding-agent path records the selected driver in `resolved.env`. +`DRIVER=agent` or `DRIVER=developer` exported only in the caller's environment +does not replace that recorded choice. + +For project discovery, the rendered question creates a one-time token under +`.iterable/asked/`. `bin/agent approve list` accepts the approval only when it +references the current token and the question has had time to be displayed. +The token is consumed after use. + +In a TTY, the wizard collects the answer directly. `APPROVED=1` is reserved for +approved automation with no interactive developer and must not be set by a +coding agent. + +## Developer-facing text + +The authoritative copy-review workflow is in +[`CONTRIBUTING.md`](../CONTRIBUTING.md). In summary: + +| Surface | Source | |---|---| -| `0` | All gates green — a push reached the device | -| `10` | Blocked on the developer — read `.iterable/ASK.md` | -| `20` | A gate failed — a real defect; see the run report | -| `30` | Tool error | -| `40` | Nothing is broken and nothing is proven — a gate ran and found the state simply hasn't happened yet | +| Migrated question copy | `copy/screens/*.md` | +| Unmigrated question copy and all option behavior | `bin/questions.sh` | +| Gate names and verdicts | `bin/gates`, `bin/gates-device.sh`, `bin/gates-iterable.sh` | +| Banners, summaries, endings, and ladder headings | `bin/config.sh` | +| Iterable dashboard instructions | `bin/iterable-keys` | +| Terminal-only text | `bin/wizard` | +| Coding-agent-only protocol text | `bin/agent` | -`40` exists because the alternative is a lie in both directions. A live run had G15 go **red** — -"No user exists with email …" — when the actual cause was that nobody had launched the app yet, so -no token existed to find. That reported a defect in someone's Iterable project for work that was -merely pending. Reporting `0` instead would have been worse. +Use `make screens-help` to list the shared question scenarios and `make screens` +to render them without credentials, network access, or a device. The renderer +does not cover every string in the table above. -## The Iterable half is deliberately hand-driven +`copy/screens/opening.md` is currently the only migrated screen. Its stable +option IDs connect writer-editable labels and descriptions to commands and +state changes in `bin/questions.sh`. -Three steps have no public API at all: creating an API key, creating a mobile app, and configuring -the push integration. That is verified, not assumed — none of the 131 paths in - can do any of them. +Two text constraints are enforced by `make test`: -So the developer clicks those three and the tool proves the result with a real call. It does **not** -drive a browser and does **not** mint keys in their project. The ladder marks G11 and G12 with `~` -rather than a tick, because no endpoint can read them back, and a gate that reads a local file to -decide a remote step happened is the false pass this tool exists to prevent. They are proved -indirectly: if G16 lands a push, both were right. +- Gate names have a 34-column display budget. +- Developer-facing output must not instruct installed users to run a bare + `bin/...` path that will not resolve from their project. -## The device half reads the device +## Gates and exit codes -G13, G14 and G16 run over `adb` against the developer's own app. They read; they never build, -install, launch or send. +The verifier contains eighteen gates, `G0` through `G17`. Important proof +boundaries include: -| Gate | What it reads | Why that and not the obvious thing | -|---|---|---| -| G13 | `dumpsys package` — version, install time, and whether `IterableFirebaseMessagingService` resolves | The APK on the device receives the push. A green local build proves nothing about what is installed, and "integrated, forgot to reinstall" is a real morning | -| G14 | `logcat` — `registerDeviceToken` and the code Iterable answered | The buffer rotates, so this can prove registration happened and can never prove it didn't. Silence is `pending`, forever | -| G16 | `dumpsys notification` — the record, its channel, and the proof marker | The notification service is neither the sender nor the app, so it can be fooled by neither, and it needs no code in the app under test | - -The wizard asks which device to use **even when only one is attached**, because a phone left plugged -in to charge is still the wrong thing to prove a push on, and `emulator-5556` is a port number -nobody recognises as their own device. Every line names the AVD instead. AVDs that aren't running -are offered too, and the wizard boots the one you pick. The choice is remembered as the **AVD -name**, not the serial: emulator serials are handed out in boot order, so the same AVD is -`emulator-5554` today and `5556` tomorrow. - -`bin/gates` never asks — it must stay safe to drive from a loop. With several devices attached it -names them all and exits. `ANDROID_SERIAL` overrides everything; `TARGET_DEVICE=` forgets the -remembered one. - -`bin/proof-push` writes a marker into the message it sends, and G16 requires that marker to match. -Sent some other way — a **Test Push** from the dashboard, say — there is no marker, and G16 falls -back to the SDK's own notification channel, which is as much as the OS can honestly tell you. - -**To watch it arrive, clear the shade first.** Android groups a second notification from the same -app with the first and marks the children `SILENT`: no banner, no sound. The push arrives, G16 sees -it, and the screen shows nothing — which looks exactly like a push that failed. G16 says so itself, -because a caveat only `bin/proof-push` prints is invisible to anyone running the verifier: +- `G9`: the service-account key can authenticate with FCM. +- `G13`: the installed APK exposes the expected Iterable messaging service. +- `G14`: the SDK attempted device-token registration. +- `G16`: Android's notification service contains the marked proof + notification. +- `G17`: server-side corroboration when a campaign-based send provides that + evidence. -``` -✓ G16 Push arrives on device "G16 proof itbl-onboard-1789996750" at 2:19:13 PM, 2s after send — SILENT, no banner -``` +Some Iterable dashboard configuration cannot be read back through a public API. +Those checks remain unverifiable until a later observable result proves the +configuration indirectly. -It also states how long ago every arrival landed, since re-running the ladder does not re-send and -a green can be hours old. If G16 is green and nothing was seen, those two lines say which it was. +| Code | Meaning | +|---|---| +| `0` | All required gates are green | +| `10` | Developer action is required | +| `20` | A gate found a defect | +| `30` | The tool itself failed | +| `40` | Nothing is broken, but a required state has not happened or cannot yet be proven | -The test identity is the other thing worth not guessing. `ITBL_EMAIL` has to be the value the app -passes to `setEmail()`, and the app decides that in code — so `bin/iterable-keys` reads the identity -out of the SDK's own `registerDeviceToken` request in `logcat` and offers it as the default. A wrong -answer makes G15 report "no user yet", which is indistinguishable from a broken Iterable project. +`bin/onboard` is idempotent and resumes from the recorded workspace state. +When developer action is required, it writes `.iterable/ASK.md`. -## The two gates that cannot be faked +## Iterable dashboard workflow -Eighteen gates, `G0`–`G17`, each a pure read performed independently of the step that produced it. -The last real one, G16, is a push landing on a real device; everything before it is a stepping -stone. The two that cannot be faked are **G9** (the service-account key actually authenticates -against FCM) and **G16** (the message actually arrives). They are deliberately independent proofs. +Iterable does not expose public API operations for creating API keys, creating +a mobile app, or configuring a Firebase push integration. The program therefore +guides the developer through four dashboard steps: -G17 was meant to corroborate G16 from the server side, and on the proof-send path it cannot: -measured 2026-09-21, a proof send that demonstrably reached the device raised **no** `pushSend` -event at all. So G17 is a gate only when `ITBL_CAMPAIGN_ID` is set and the send goes through a -campaign, and a `~` otherwise. The alternative — leaving it a gate — parks every successful run at -"not done" forever. +1. Create the server and mobile API keys. +2. Create the mobile app. +3. Configure the Firebase push integration. +4. Confirm the test identity used by the application. -## Workspace +The first three are dashboard configuration operations; the fourth connects the +application's identity to the verification flow. + +In a non-interactive session, `bin/iterable-keys --take server|mobile` reads a +newly copied key from the local clipboard, writes it to `.iterable/.env` with +mode `0600`, clears the clipboard, and prints only the result status. + +## Device boundary -`.iterable/` belongs to the developer and gitignores itself. It holds live Google session cookies -and a downloaded service-account key: treat it as sensitive and keep it local. +Device gates operate through `adb` against the selected application and device: -| Path | Owner | +| Gate | Evidence | |---|---| -| `.env` | the developer, via `bin/iterable-keys` — the Iterable keys, mode `0600`, never printed back. Created with the names and no values on the scripted path, filled by `--take server\|mobile` off the clipboard or by hand. Never overwritten once it holds a value: keys are shown once. `docs/onboard/env.example` lists the exact names the tool reads | -| `chrome-profile/` | the developer — sign in once | -| `ASK.md` | the tool — what it needs from the developer | -| `state.tsv` | the tool — gate results and resume point | -| `resolved.env` | the tool — choices safe to print: project, package, app id, proof marker. Never a credential | -| `artifacts/` | the tool — `google-services.json`, key (mode `0600`), apk | -| `runs//` | the tool — report, logs, browser session trace | -| `onboard`, `agent`, `gates`, … | the tool — one-line stubs, rewritten every run | +| G13 | `dumpsys package`: installed version, install time, and messaging-service resolution | +| G14 | `logcat`: the latest relevant device-token registration attempt and Iterable response | +| G16 | `dumpsys notification`: the notification record, channel, and proof marker | -`docs/onboard/inputs.yml` is a template for the same values and **reference only**: no code reads -it. The wizard asks for these and remembers the answers in `resolved.env`. +The developer chooses the target device even when only one is connected. +Remembered emulators are identified by AVD name because emulator serials can +change between boots. `ANDROID_SERIAL` explicitly overrides device selection; +clearing `TARGET_DEVICE` forgets the recorded choice. -### Why the stubs exist +The verification gates never launch or operate the application. After +installation, and again when `run_app` is the next step, the workflow invokes +`bin/launch-app` to open the selected package's launcher activity. The developer +still signs in, uses the app, and responds to the Android notification +permission prompt. -A host installs a plugin into a version-numbered cache directory and sets no environment variable -pointing at it. That path is too long to type — and worse, hosts keep old version directories on -disk, so a path saved in a runbook keeps working while quietly running the version it was written -against. +`bin/proof-push` adds a marker to the sent notification. G16 requires that +marker when verifying the proof-send path, preventing an unrelated notification +from satisfying the gate. -So every entry point a developer is told to run gets a stub in their own workspace, and the messages -print `.iterable/onboard` rather than the real path. Being rewritten on every run is what makes it -safe: a stub cannot outlive the install it points at without being replaced, and one that somehow -does exits `127` saying so rather than failing at `exec`. +## Workspace -Internals get no stub. `wizard` and `provision` are dispatched to, never typed. +The generated `.iterable/` directory belongs to the developer, gitignores +itself, and can contain live credentials. -## The agents +| Path | Owner and purpose | +|---|---| +| `.env` | Iterable API keys, test identity, and related API settings, mode `0600` | +| `ASK.md` | Current developer action requested by the program | +| `state.tsv` | Gate results and resume state | +| `resolved.env` | Non-secret choices such as project, package, app ID, device, and proof marker | +| `artifacts/` | `google-services.json`, service-account key, and other generated artifacts | +| `runs//` | Reports and diagnostic output | +| `onboard`, `agent`, `gates`, and related files | One-line entry-point stubs for the installed workflow | -Definitions live in [`../agents/`](../agents/) and are linked into `~/.claude/agents/` with -`make link-agents`. Two structural rules: `gate-auditor` never performs a provisioning action and no -agent audits its own gate; `web-operator` is the only agent with browser access, and it is -origin-confined to the three consoles. +The entry-point stubs avoid exposing a versioned plugin-cache path to the +developer. They are rewritten on each run so they follow the active plugin +installation. Internal commands such as `wizard` and `provision` do not receive +developer-facing stubs. -Build roles live in `tools/agents/` instead, because a host discovers `agents/` by convention — -anything in there loads into every client session whether or not a client could ever use it. -`make link-agents` links both, so a build machine still gets the whole team. +## Tests -## Not built +`make test` runs the offline suites. `make test-all` additionally runs checks +against Iterable's published API specification. -The recorded-browser tier (deferred on purpose, above), the negative-fixture suite for the Google -half, and the daily canary. +| Suite | Contract | +|---|---| +| `tests/agent-next-action.sh` | Each ladder state produces one owner and next action | +| `tests/agent-transitions.sh` | Recommended choices advance through the intended flow | +| `tests/agent-questions.sh` | Chat and terminal paths receive the same shared questions | +| `tests/ask-question.exp` | Interactive choices return the selected option | +| `tests/approval-gate.sh` | Scoped consent and one-time question tokens | +| `tests/launch-app.sh` | App launch remains limited to the selected launcher activity | +| `tests/text-surface.sh` | Screen source discovery, Markdown validation, and text budgets | +| `tests/no-bare-commands.sh` | Installed-user output contains resolvable commands | +| `tests/key-propagation.sh` | Delayed key propagation remains distinct from invalid credentials | +| `tests/fcm-classify.sh` | FCM responses map to the correct verdict | +| `tests/itbl-classify.sh` | Iterable API failures remain distinguishable | +| `tests/device-token-log.sh` | Device-token registration evidence | +| `tests/device-notify.sh` | Notification-service proof and marker matching | +| `tests/device-pick.sh` | Device and emulator selection | + +## Agent definitions + +Runtime agent definitions live under `agents/`: + +- `gcp-provisioner`: Google and Firebase provisioning. +- `iterable-api-client`: Iterable API interaction and verification. +- `web-operator`: browser interaction restricted to approved console origins. +- `gate-auditor`: read-only re-derivation of gate results. + +Build-only roles live under `tools/agents/` so they are not discovered in every +installed client session. `make link-agents` links both sets for local +development. + +## Deferred work + +- Recorded-browser assistance for dashboard operations. +- Negative fixtures for more Google-side failure modes. +- A scheduled end-to-end canary. diff --git a/bin/questions.sh b/bin/questions.sh index e024b91..751944d 100644 --- a/bin/questions.sh +++ b/bin/questions.sh @@ -16,6 +16,8 @@ # conversation: it becomes a record on disk that the gate spending it can check. That # is the whole reason consent survives being asked in a chat window. +source "$(dirname "${BASH_SOURCE[0]}")/screen-copy.sh" + # Lines on stdin, blanks included, as a JSON array. The blanks are paragraph breaks — # dropping them turns nine lines of context into one wall of text. q_arr() { @@ -74,47 +76,49 @@ q_free_text() { # workspace, and names the shape of the whole job rather than the next step of it — # because what is being agreed to here is the job. q_opening_context() { - echo "You need a google-services.json and the Iterable side set up. Neither can be invented, so this is the part that produces them — and none of it starts until you say so." - echo "" - part_map | plain | sed 's/^ //' | sed '/^$/d' - echo "" - echo "What it reads: your Firebase projects and the Android apps in each, your app's build files, and a connected device or emulator. Reading happens with the gcloud account you are already signed in as." - echo "" - echo "What it changes in your Google project: nothing yet. When it gets there you are asked again, on a screen that lists every change by name, and no is a complete answer to it." - echo "" - echo "What it never does: ask for a password, take an API key through this conversation, or write a placeholder google-services.json to make a build go green. If something is missing it says so and stops." - echo "" - echo "Part 2 is yours because Iterable has no API for it — no endpoint creates an API key, a mobile app, or a push integration. I walk you through those four steps and check what each one produced." + local line + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" == "{{part_map}}" ]]; then + part_map | plain | sed 's/^ //' | sed '/^$/d' + else + printf '%s\n' "$line" + fi + done < <(screen_copy_section opening Context) } q_opening() { - local here="Yes — set it up with me here" - printf '{"header":"Setup","prompt":%s,' \ - "$(jstr "Do you wish to proceed?")" + local id label description sep="" here + screen_copy_validate opening "agent terminal stop" "part_map" || return 1 + here="$(screen_copy_option_scalar opening agent Label)" + printf '{"header":%s,"prompt":%s,' \ + "$(jstr "$(screen_copy_scalar opening Header)")" \ + "$(jstr "$(screen_copy_scalar opening Prompt)")" printf '"context":%s,' "$(q_opening_context | q_arr)" - printf '"requires":%s,' "$(q_arr <<'EOF' -You sign in to Google yourself. If 'gcloud auth login' has not been run in your terminal, that is the first thing you do, and neither this tool nor the agent ever sees your password. -EOF -)" + printf '"requires":%s,' "$(screen_copy_section opening Requirements | q_arr)" printf '"recommended":%s,' "$(jstr "$here")" printf '"options":[' - # Records the driver, and that is not bookkeeping. A chat has no tty, so provisioning - # refuses to run from one unless the choice is on disk — say yes here without recording - # it and the run walks up to the first change and hands the developer a terminal block, - # which is the answer they just declined. - q_opt "$here" \ - "I conduct the whole thing here: every screen the terminal wizard shows, as a question in this conversation. Nothing changes without its own yes first, and the two Iterable API keys go from your clipboard into a 0600 file without passing through this conversation — I never see one." \ - "DRIVER=agent recorded; ladder read; nothing changed" "" \ - "$BIN/agent set DRIVER=agent" "$BIN/agent" - printf ',' - q_opt "I'd rather run the whole setup in my own terminal" \ - "Fallback, and a real one: the same work and the same questions as a terminal menu, with the Google sign-in hosted in the same session and every prompt answered by arrow keys instead of by relaying. Worth it if you would rather these choices were not in a chat at all." \ - "DRIVER=developer recorded; the block to follow is printed" "" \ - "$BIN/agent set DRIVER=developer" "$BIN/handoff" - printf ',' - q_opt "No — stop here" \ - "Nothing runs, nothing is read, and nothing is written — not even a workspace directory in your repository." \ - "nothing read; nothing written" "" + # Markdown controls labels, descriptions and their order. Stable ids connect each + # option to behavior here, so rewording or moving one cannot attach another option's + # command to it. + while IFS= read -r id; do + label="$(screen_copy_option_scalar opening "$id" Label)" + description="$(screen_copy_option_scalar opening "$id" Description)" + printf '%s' "$sep"; sep="," + case "$id" in + # Records the driver, and that is not bookkeeping. A chat has no tty, so + # provisioning refuses to run from one unless the choice is on disk. + agent) + q_opt "$label" "$description" \ + "DRIVER=agent recorded; ladder read; nothing changed" "" \ + "$BIN/agent set DRIVER=agent" "$BIN/agent" ;; + terminal) + q_opt "$label" "$description" \ + "DRIVER=developer recorded; the block to follow is printed" "" \ + "$BIN/agent set DRIVER=developer" "$BIN/handoff" ;; + stop) + q_opt "$label" "$description" "nothing read; nothing written" "" ;; + esac + done < <(screen_copy_option_ids opening) printf ']}' } @@ -177,19 +181,23 @@ q_choose_target() { # opens a browser and holds the session, and the whole promise of this tool is that the # password happens between the developer and Google with nothing in between. q_sign_in() { - local relay="Show me the command to run" + # Labelled for what it does, not for who does it, because both front ends show this + # screen and only one of them can host gcloud: a chat hands over the command, the + # wizard runs it in the terminal it already has. What varies goes in the option's + # description, which the chat shows and the terminal menu does not. + local go="Sign in with gcloud" printf '{"header":"Sign in","prompt":%s,' "$(jstr "Ready to sign in to Google?")" printf '"context":%s,' "$(q_arr <<'EOF' Nothing here can read your Google projects until gcloud has a session, and that sign-in is yours: it opens your own browser, and neither this tool nor I ever see the password. -One command in your terminal, then come back and say so. If you are already signed in as the right account, take the second option and I will check rather than ask again. +If you are already signed in as the right account, take the second option and I will check rather than ask again. EOF )" printf '"requires":[],' - printf '"recommended":%s,' "$(jstr "$relay")" + printf '"recommended":%s,' "$(jstr "$go")" printf '"options":[' - q_opt "$relay" \ - "You run it, your browser handles it. Come back when it says you are signed in." \ + q_opt "$go" \ + "One command in your own terminal. You run it, your browser handles it — come back when it says you are signed in." \ "nothing changed; waiting for you to sign in" "gcloud auth login" printf ',' q_opt "I'm already signed in — check again" \ @@ -549,10 +557,10 @@ q_pick_device() { # ------------------------------------------------------ part 3: the app's own turn # -# Signing in and answering the notification dialog happen inside the developer's app, -# behind whatever login it has. Neither is work an agent can do honestly: it has to guess -# which field is which, a wrong guess is indistinguishable from a screen that was slow, -# and every attempt costs minutes of tapping over adb against a UI nothing here has seen. +# The verifier opens the selected app, but signing in and answering the notification +# dialog happen inside it, behind whatever login it has. Neither is work an agent can do +# honestly: it has to guess which field is which, and a wrong guess is indistinguishable +# from a screen that was slow. # The permission dialog is worse than slow — it is a question Android asks *the person*, # and a tool that taps Allow has answered it on their behalf. # @@ -571,7 +579,7 @@ q_run_app_context() { fi echo " 2. Allow notifications when Android asks" echo "" - echo "Neither is mine to do. Everything from here only reads the device, and nothing here taps, types or launches anything on it: a tap I guessed at looks exactly like a screen that was slow, and Allow is yours to give." + echo "I open the selected app and stop there. Nothing here taps, types, signs in or grants permission: a tap I guessed at looks exactly like a screen that was slow, and Allow is yours to give." echo "" echo "Then say so below, and every device check runs again." } @@ -881,6 +889,77 @@ q_iterable_step() { printf ',"recommended":%s}' "$([[ "$rec" == null ]] && printf null || jstr "$rec")" } +# ------------------------------------------------------ the same words, in a terminal +# +# bin/wizard used to carry its own wording for every one of these moments, so each screen +# existed twice with nothing keeping the two in step. The sign-in moment said "I never see +# your password — gcloud opens your browser and handles it" in the terminal and "You run +# it, your browser handles it" in a chat, and neither was the other's source. One set of +# words now, two renderers: an agent gets the JSON, a person gets this. +# +# Words and labels only. The options' `commands` stay the caller's to run, exactly as they +# are on the agent path — running them in here would move consent recording into a +# renderer, and the renderer is the one place in this tool with no business deciding +# anything. +# +# q_render — the words, and nothing that waits for an answer. +# +# Separate from ask_question so "the terminal prints what the chat is handed" is an offline +# assertion. Joined to the menu it would need a pty, and a test that needs a pty to check a +# sentence is a test that ends up skipped — which is how the wording drifted unnoticed. +q_render() { + local q="$1" prompt + # Paragraphs arrive unwrapped on purpose — a chat window reflows them anyway — so the + # width belongs to whoever is drawing. Wrapped here rather than with fold, which would + # lose the indent on every continuation line. + prompt="$(printf '%s' "$q" | jqn 'process.stdout.write(j.prompt||"")')" + echo + [[ -n "$prompt" ]] && printf ' %s\n\n' "$(bold "$prompt")" + printf '%s' "$q" | jqn ' + const W = 76, pad = " ", out = []; + const wrap = s => { + if (!s) { out.push(""); return; } + let cur = ""; + for (const word of s.split(" ")) { + if (cur && (cur + " " + word).length > W) { out.push(pad + cur); cur = word; } + else cur = cur ? cur + " " + word : word; + } + if (cur) out.push(pad + cur); + }; + (j.context || []).forEach(wrap); + (j.requires || []).forEach(s => { out.push(""); wrap(s); }); + process.stdout.write(out.join("\n") + "\n\n"); + ' +} + +# ask_question [arg] — draw a screen, return which option came back. +# Sets ASK_LABEL and ASK_INDEX (1-based, in the order the screen lists them). +# 0 something was chosen 1 cancelled 2 no screen for that kind +ask_question() { + local q l i=0 + # Cleared before anything can fail, because the caller reads them on every return and + # config.sh runs under `set -u`: a return 2 that left them unset would take the wizard + # down with it rather than letting it fall back to its own prompt. + ASK_LABEL=""; ASK_INDEX=0 + q="$(question_for "$1" "${2:-}")" || return 2 + + # Everything a person reads goes to stderr, because stdout is the answer — the same + # split menu() keeps, and what lets a caller capture one without the other. + q_render "$q" >&2 + + local labels chosen + labels="$(printf '%s' "$q" | jqn \ + 'process.stdout.write((j.options||[]).map(o=>o.label).join("\n"))')" + [[ -n "$labels" ]] || return 1 + chosen="$(printf '%s\n' "$labels" | menu)" || return 1 + + while IFS= read -r l; do + i=$((i + 1)) + [[ "$l" == "$chosen" ]] && { ASK_INDEX=$i; ASK_LABEL="$l"; return 0; } + done <<< "$labels" + return 1 +} + # question_for [arg] — one question object on stdout, or rc 1 and nothing at all # when the step at hand is not a question. Silence is the honest answer for the many # kinds that are just work to do: an agent handed a question for `run_app` would go diff --git a/bin/screen-copy.sh b/bin/screen-copy.sh new file mode 100644 index 0000000..b70609e --- /dev/null +++ b/bin/screen-copy.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# Reader and validator for the constrained Markdown in copy/screens/. +# +# Deliberately dependency-free: the opening question has to explain that node is missing, +# so reading its words cannot itself require node. The format is Markdown for the person +# editing it, but only four level-two sections and Label/Description under stable option +# ids are structural. Everything else is prose. + +SCREEN_COPY_ROOT="${SCREEN_COPY_ROOT:-${TOOL_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/copy/screens}" + +screen_copy_file() { printf '%s/%s.md' "$SCREEN_COPY_ROOT" "$1"; } + +# screen_copy_validate +# +# Errors name the file and line a writer should fix. Option ids are behavior keys: their +# labels and order may change, but deleting or renaming an id would otherwise detach the +# words from the command it is meant to describe. +screen_copy_validate() { + local screen="$1" expected="${2:-}" placeholders="${3:-}" file + file="$(screen_copy_file "$screen")" + [[ -f "$file" ]] || { printf '%s: missing screen copy\n' "$file" >&2; return 1; } + + awk -v file="$file" -v expected="$expected" -v allowed="$placeholders" ' + function error(line, message) { + printf "%s:%s: %s\n", file, line, message > "/dev/stderr" + failed = 1 + } + function known_option(id, i) { + for (i in wanted) if (i == id) return 1 + return 0 + } + BEGIN { + split(expected, a, " "); for (i in a) if (a[i] != "") wanted[a[i]] = 1 + split(allowed, a, " "); for (i in a) if (a[i] != "") allowed_placeholder[a[i]] = 1 + required["Header"] = required["Prompt"] = required["Context"] = required["Requirements"] = 1 + } + { + text = $0 + rest = text + while (match(rest, /\{\{[^{}]+\}\}/)) { + token = substr(rest, RSTART + 2, RLENGTH - 4) + if (!(token in allowed_placeholder)) error(NR, "unknown placeholder {{" token "}}") + rest = substr(rest, RSTART + RLENGTH) + } + + if (text ~ /^# /) { + h1++ + if (h1 > 1) error(NR, "only one screen title is allowed") + next + } + if (text ~ /^## /) { + section = substr(text, 4) + option = field = "" + if (section in required) { + seen_section[section]++ + current = section + if (seen_section[section] > 1) error(NR, "duplicate \"" section "\" section") + } else if (section ~ /^Option: /) { + option = substr(section, 9) + current = "" + if (option !~ /^[a-z][a-z0-9_-]*$/) error(NR, "invalid option id \"" option "\"") + else if (!known_option(option)) error(NR, "unknown option id \"" option "\"") + seen_option[option]++ + if (seen_option[option] > 1) error(NR, "duplicate option \"" option "\"") + } else { + error(NR, "unknown section \"" section "\"") + current = "" + } + next + } + if (text ~ /^### /) { + field = substr(text, 5) + if (option == "") { + error(NR, "\"" field "\" belongs under an Option section") + } else if (field != "Label" && field != "Description") { + error(NR, "unknown option field \"" field "\"") + } else { + seen_field[option, field]++ + if (seen_field[option, field] > 1) + error(NR, "duplicate \"" field "\" for option \"" option "\"") + } + next + } + if (text !~ /^[[:space:]]*$/) { + if (option != "" && field != "") content_field[option, field]++ + else if (current != "") content_section[current]++ + else error(NR, "text is outside a recognised section") + } + } + END { + if (h1 == 0) error(1, "missing screen title (# ...)") + for (s in required) { + if (seen_section[s] != 1) error(1, "missing \"" s "\" section") + else if (content_section[s] == 0) error(1, "\"" s "\" section is empty") + } + for (id in wanted) { + if (seen_option[id] != 1) { + error(1, "missing option \"" id "\"") + continue + } + for (i = 1; i <= 2; i++) { + f = i == 1 ? "Label" : "Description" + if (seen_field[id, f] != 1) error(1, "option \"" id "\" is missing \"" f "\"") + else if (content_field[id, f] == 0) error(1, "\"" f "\" for option \"" id "\" is empty") + } + } + exit failed ? 1 : 0 + } + ' "$file" +} + +# Raw Markdown between one `## Name` heading and the next level-two heading. +screen_copy_section_raw() { + local screen="$1" section="$2" file + file="$(screen_copy_file "$screen")" + awk -v target="## $section" ' + $0 == target { active = 1; next } + active && /^## / { exit } + active { print } + ' "$file" +} + +# Raw Markdown under `### Field` in one stable `## Option: id`. +screen_copy_option_raw() { + local screen="$1" option="$2" field="$3" file + file="$(screen_copy_file "$screen")" + awk -v wanted_option="## Option: $option" -v wanted_field="### $field" ' + $0 == wanted_option { in_option = 1; next } + in_option && /^## / { exit } + in_option && $0 == wanted_field { active = 1; next } + active && /^### / { exit } + active { print } + ' "$file" +} + +# Markdown paragraph rules: physical line wrapping is ignored, while a blank line remains +# a paragraph break. This lets a writer wrap prose in their editor without changing what +# either front end receives. +screen_copy_paragraphs() { + awk ' + function flush() { + if (paragraph != "") { + if (pending && wrote) print "" + print paragraph + paragraph = "" + pending = 0 + wrote = 1 + } + } + /^[[:space:]]*$/ { flush(); if (wrote) pending = 1; next } + /^[[:space:]]*\{\{[^{}]+\}\}[[:space:]]*$/ { + flush() + if (pending && wrote) print "" + gsub(/^[[:space:]]+|[[:space:]]+$/, "") + print + pending = 0 + wrote = 1 + next + } + { + line = $0 + sub(/^[[:space:]]+/, "", line) + sub(/[[:space:]]+$/, "", line) + paragraph = paragraph == "" ? line : paragraph " " line + } + END { flush() } + ' +} + +screen_copy_section() { + screen_copy_section_raw "$1" "$2" | screen_copy_paragraphs +} + +screen_copy_scalar() { + screen_copy_section "$1" "$2" | awk ' + NF { value = value == "" ? $0 : value " " $0 } + END { printf "%s", value } + ' +} + +screen_copy_option_scalar() { + screen_copy_option_raw "$1" "$2" "$3" | screen_copy_paragraphs | awk ' + NF { value = value == "" ? $0 : value " " $0 } + END { printf "%s", value } + ' +} + +screen_copy_option_ids() { + local file; file="$(screen_copy_file "$1")" + sed -n 's/^## Option: \([a-z][a-z0-9_-]*\)$/\1/p' "$file" +} diff --git a/bin/wizard b/bin/wizard index e6184d7..a501d3d 100755 --- a/bin/wizard +++ b/bin/wizard @@ -8,6 +8,9 @@ # a terminal. source "$(dirname "${BASH_SOURCE[0]}")/config.sh" +# The screens themselves, so a moment that exists in a chat is worded once and this +# renders the same words rather than a second set of its own. +source "$(dirname "${BASH_SOURCE[0]}")/questions.sh" # The cursor is restored first in both traps: the picker hides it, and a Ctrl-C out of # a picker would otherwise leave the developer's shell with no cursor afterwards. @@ -57,19 +60,18 @@ part 1 "Google" # ---------------------------------------------------------------- G1: identity if [[ -z "$(tok)" ]]; then - echo - echo " $(bold "Sign in to Google")" - echo " I never see your password — gcloud opens your browser and handles it." - echo - if ask_yn "Run 'gcloud auth login' now?"; then - echo - gcloud auth login || { echo " Sign-in did not complete."; exit 10; } - else - echo " Can't continue without it. When you are ready, run this again:" - echo - run_line onboard - exit 10 - fi + # Hosted here rather than handed over: this is the one thing a terminal can do that a + # chat cannot. The words are the screen's either way. + ask_question sign_in; rc=$? + case "$rc:$ASK_INDEX" in + 0:1) echo; gcloud auth login || { echo " Sign-in did not complete."; exit 10; } ;; + 0:2) : ;; # already signed in; the check below is what decides + # Declined, aborted, or cancelled — all the same outcome, and nothing was read. + *) echo; echo " Nothing read and nothing recorded. Google needs a session before" + echo " anything here can see a project. When you are ready:" + echo; run_line onboard; exit 10 ;; + esac + [[ -n "$(tok)" ]] || { echo; echo " Still no gcloud session."; echo; run_line onboard; exit 10; } fi echo echo " $(green ✓) Signed in as $(gcloud config get-value account 2>/dev/null)" @@ -78,10 +80,19 @@ echo " $(green ✓) Signed in as $(gcloud config get-value account 2>/dev/null) # Always offer the choice. If a previous run remembered one, it is a default to # confirm, not a decision already made on your behalf. if [[ -n "$PID" && -n "$PACKAGE" ]]; then - echo - echo " Last time you chose: $(bold "$PID") / $PACKAGE" - echo - ask_yn "Use that again?" && KEEP=1 || KEEP=0 + # The options are read here; their `commands` are not run. Which project is used is a + # decision, and the wizard's own actions below are what record it. + if ask_question keep_target; then + case "$ASK_INDEX" in + 1) KEEP=1 ;; + 2) KEEP=0 ;; + *) echo; echo " Nothing changed — $PID / $PACKAGE is still what is on record."; exit 0 ;; + esac + else + # Cancelled. Not a yes and not a no: stopping leaves the remembered choice alone, + # which is what pressing q asked for. + echo; echo " Nothing changed."; exit 0 + fi else KEEP=0 fi @@ -136,52 +147,35 @@ fi # `emulator-5556` is a port number. Asked to confirm one, a developer is being # asked about a string they have no way to recognise — so every line here names # the AVD, and the remembered choice is stored as that name too. -echo -echo " $(bold "Which device")" -echo -echo " Part 3 proves the push on a real device. Choosing it now means every check" -echo " from here on reads the same one you will be watching." - # boot_avd lives in config.sh, so tests/device-pick.sh can drive its timeout. -# device_options lives in config.sh: the chat picker offers the same devices, and a -# list that exists twice is a list that comes to differ. Field 1 of every option is -# the machine value. Same contract as the project picker. - -if DEV="$(device_serial)"; then WHY=""; else WHY="$DEV"; DEV=""; fi +# One list rather than confirm-then-pick: device_options is where both front ends get their +# devices, so an attached phone appears alongside everything else and the answer means the +# same thing in either. Asked even when there is exactly one — a phone left plugged in to +# charge is still the wrong device to prove a push on, and the only person who knows which +# one is meant is the one reading this. +# +# The words come from the screen; the menu is drawn here from every row. A chat picker is +# held to four options and offers free text for the rest, which a terminal has no reason to +# copy: the list fits, so all of it is shown. CHOSE="" -if [[ -n "$DEV" ]]; then +OPTS="$(device_options 2>/dev/null)" +if [[ -z "$OPTS" ]]; then echo - echo " Device: $(bold "$(device_label "$DEV")")" + # device_serial's own reason first: nothing attached and a phone that never authorised + # adb need different things done about them. + DEV="$(device_serial)" || echo " $DEV" + echo " No devices and no AVDs. Create one in Android Studio, or attach a phone." + echo " Everything before the device steps still works — re-run when you have one." echo - # Asked even when there is exactly one. A phone left plugged in to charge, or - # yesterday's emulator, is still the wrong device to prove a push on — and the - # only person who knows which one is meant is the one reading this. - if ask_yn "Use it?"; then - CHOSE="$(avd_of "$DEV")"; CHOSE="${CHOSE:-$DEV}" - fi else - echo - echo " $WHY" -fi - -if [[ -z "$CHOSE" ]]; then - echo - echo " $(bold "Pick a device")" - echo - OPTS="$(device_options)" - if [[ -z "$OPTS" ]]; then - echo " No devices and no AVDs. Create one in Android Studio, or attach a phone." - echo " Everything before the device steps still works — re-run when you have one." - echo - else - LINE="$(printf '%s\n' "$OPTS" | menu)" || LINE="" - if [[ -n "$LINE" ]]; then - CHOSE="$(awk '{print $1}' <<< "$LINE")" - if [[ "$LINE" == *"start it"* ]]; then - echo - boot_avd "$CHOSE" >/dev/null || true - fi + q_render "$(question_for pick_device)" + LINE="$(printf '%s\n' "$OPTS" | menu)" || LINE="" + if [[ -n "$LINE" ]]; then + CHOSE="$(awk '{print $1}' <<< "$LINE")" + if [[ "$LINE" == *"start it"* ]]; then + echo + boot_avd "$CHOSE" >/dev/null || true fi fi fi diff --git a/copy/screens/README.md b/copy/screens/README.md new file mode 100644 index 0000000..2c07db5 --- /dev/null +++ b/copy/screens/README.md @@ -0,0 +1,34 @@ +# Editing screen copy + +This directory is the writer-facing part of the onboarding screens. The shell code keeps +commands, consent records and state changes; these Markdown files keep what a developer +reads. + +Start by finding the screen: + +```bash +make screens-help +make screens KIND=opening +``` + +Then edit its Markdown file and render it again. Physical line wrapping does not change the +output: lines inside one paragraph are joined, while a blank line remains a paragraph break. + +Each screen has these fixed sections: + +- `Header` — the short chip shown by a chat UI. +- `Prompt` — the question. +- `Context` — the explanation above it. +- `Requirements` — anything the developer has to do themselves. +- `Option: ...` — one answer, with `Label` and `Description`. + +The word after `Option:` is a stable behavior id. Do not rename or remove it. The label, +description and order of the option may change safely: the id is what connects it to its +command in `bin/questions.sh`. + +Text such as `{{part_map}}` is a placeholder filled by the tool. Leave known placeholders +where the dynamic content should appear. A typo, missing section, unknown option id or +unknown placeholder produces a file-and-line error from `make screens` and `make test`. + +Only `opening.md` has moved so far. It is the complete vertical slice for this editing model; +the remaining screens still live in `bin/questions.sh` and can move here one at a time. diff --git a/copy/screens/opening.md b/copy/screens/opening.md new file mode 100644 index 0000000..3df1fd6 --- /dev/null +++ b/copy/screens/opening.md @@ -0,0 +1,72 @@ +# Opening + +## Header + +Setup + +## Prompt + +Do you wish to proceed? + +## Context + +You need a google-services.json and the Iterable side set up. Neither can be invented, so +this is the part that produces them — and none of it starts until you say so. + +{{part_map}} + +What it reads: your Firebase projects and the Android apps in each, your app's build files, +and a connected device or emulator. Reading happens with the gcloud account you are already +signed in as. + +What it changes in your Google project: nothing yet. When it gets there you are asked again, +on a screen that lists every change by name, and no is a complete answer to it. + +What it never does: ask for a password, take an API key through this conversation, or write +a placeholder google-services.json to make a build go green. If something is missing it +says so and stops. + +Part 2 is yours because Iterable has no API for it — no endpoint creates an API key, a +mobile app, or a push integration. I walk you through those four steps and check what each +one produced. + +## Requirements + +You sign in to Google yourself. If 'gcloud auth login' has not been run in your terminal, +that is the first thing you do, and neither this tool nor the agent ever sees your password. + +## Option: agent + +### Label + +Yes — set it up with me here + +### Description + +I conduct the whole thing here: every screen the terminal wizard shows, as a question in +this conversation. Nothing changes without its own yes first, and the two Iterable API keys +go from your clipboard into a 0600 file without passing through this conversation — I never +see one. + +## Option: terminal + +### Label + +I'd rather run the whole setup in my own terminal + +### Description + +Fallback, and a real one: the same work and the same questions as a terminal menu, with the +Google sign-in hosted in the same session and every prompt answered by arrow keys instead of +by relaying. Worth it if you would rather these choices were not in a chat at all. + +## Option: stop + +### Label + +No — stop here + +### Description + +Nothing runs, nothing is read, and nothing is written — not even a workspace directory in +your repository. diff --git a/tests/agent-questions.sh b/tests/agent-questions.sh index 506abeb..31d851b 100755 --- a/tests/agent-questions.sh +++ b/tests/agent-questions.sh @@ -687,21 +687,21 @@ grep -qF "Which device" <<< "$(qdev run_app Pixel_9_Pro)" \ && bad "asked which device again when one is already on record" \ || ok "a recorded device is not re-asked: that part is settled" -# What replaced it. With the device settled, run_app is still a question, because signing -# in and answering the permission dialog happen inside the developer's app. Left silent, -# an agent filled the gap by driving the emulator over adb: minutes of guessed taps per -# attempt, and Android's permission question answered by a tool on the person's behalf. +# What replaced it. With the device settled, the verifier opens the app, but run_app is +# still a question because signing in and answering the permission dialog happen inside +# it. Left silent, an agent filled the gap by driving the UI over adb: guessed taps and +# Android's permission question answered by a tool on the person's behalf. app="$(qdev run_app Pixel_9_Pro)" -out="$(node "$TMP/shape.js" <<< "$app")" && ok "the run-it-yourself screen — $out" || bad "the run-it-yourself screen — $out" +out="$(node "$TMP/shape.js" <<< "$app")" && ok "the finish-in-app screen — $out" || bad "the finish-in-app screen — $out" grep -qiF "allow notifications" <<< "$app" \ && ok "it asks them to allow notifications rather than doing it for them" \ || bad "nothing on the screen asks for the notification permission" grep -qF "Sign in" <<< "$app" \ && ok "and to sign in, which is what makes a token exist" \ || bad "the screen never mentions signing in" -grep -qE "only reads the device|never.*taps|nothing here taps" <<< "$app" \ - && ok "and says outright that nothing here touches the device" \ - || bad "the screen does not rule out the tool working the app itself" +grep -qE "open the selected app.*stop there" <<< "$app" \ + && ok "and says the tool opens the app but stops before interacting with it" \ + || bad "the screen does not explain the launch-only boundary" grep -q '"commands":\["[^"]*/bin/agent"\]' <<< "$app" \ && ok "one option re-runs the checks, so proceeding is a choice on the list" \ || bad "no option re-reads the device — the developer has nothing to proceed with" @@ -711,6 +711,104 @@ grep -qF 'set ITBL_EMAIL={{answer}}' <<< "$app" \ && ok "and the address it actually signs in as can be corrected here" \ || bad "no way to say which identity the app really uses" +echo +echo " One set of words — the terminal prints what the chat is handed" +echo + +# The drift this closes: bin/wizard carried its own wording for the same moments, so the +# sign-in screen said "I never see your password — gcloud opens your browser and handles +# it" in a terminal and "You run it, your browser handles it" in a chat. Neither was the +# other's source, and nothing here noticed, because this suite asserted which screens +# exist and never which words they use. +# +# q_render is what the wizard now prints. So: every sentence the chat is handed has to +# come out of the renderer, or there is a second wording again. +# +# Split from ask_question precisely so this can be checked without a pty. A parity test +# that needs one is a test that ends up skipped, which is how the drift lasted this long. +prender() { WS="$DISC" PID=proj-number-8 PACKAGE="" PATH="$TOOLS:$PATH" bash -c \ + 'source bin/config.sh >/dev/null 2>&1; source bin/questions.sh + q_render "$(question_for "$1")"' _ "$1"; } + +# Every screen, not a chosen few: the list is the ladder's own kinds, derived the same way +# as above, so a screen written tomorrow is covered the day it is written. +for k in $(grep -o 'a_kind=[a-z_]*' bin/config.sh | cut -d= -f2 | sort -u); do + case "$k" in + install_tools|project_unreachable|provision|enable_firebase|investigate|\ + campaign_send|done|run_ladder|unknown|send_proof) continue ;; + esac + json="$(qdp "$k" 2>/dev/null)" || continue + # Compared after unwrapping: the renderer picks the width, so a line break it inserted is + # not a difference in wording. Anything else is. Option labels belong to menu, and + # printing them here as well would show every choice twice. + report="$(printf '%s' "$json" | MSHOWN="$(prender "$k")" node -e ' + let d=""; process.stdin.on("data",c=>d+=c).on("end",()=>{ + const j = JSON.parse(d); + const flat = s => s.replace(/\[[0-9;]*m/g,"").replace(/\s+/g," ").trim(); + const shown = flat(process.env.MSHOWN), out = []; + for (const s of [j.prompt, ...(j.context||[]), ...(j.requires||[])]) + if (s && s.trim() && !shown.includes(flat(s))) + out.push("not printed: " + flat(s).slice(0,58)); + for (const o of j.options||[]) + if (o.label && shown.includes(flat(o.label))) + out.push("printed twice, menu draws it: " + flat(o.label).slice(0,40)); + process.stdout.write(out.join("\n")); + })')" + if [[ -z "$report" ]]; then + ok "$k reads the same in both front ends" + else + bad "$k does not render what the chat is handed:" + printf '%s\n' "$report" | sed 's/^/ /' + fi +done + +# ---------------------------------------------------------- and the terminal asks through it +# A screen only stops drifting once the wizard is actually rendering it, so: the kinds it +# asks for have to exist, and the prompts it still words itself have to be the known ones. +grep -q 'source.*questions.sh' bin/wizard \ + && ok "the wizard renders the screens rather than carrying its own" \ + || bad "bin/wizard does not source questions.sh — its wording is its own again" + +# Both halves on record, because the wizard reaches these with a project and a package +# already chosen — keep_target exists only in that state. question_for as well as +# ask_question: the device step draws its own menu and renders the screen's words only, so +# that site names a kind too. +for k in $(grep -oE '(ask_question|question_for) [a-z_]*' bin/wizard \ + | awk '{print $2}' | sort -u); do + qd "$k" proj-number-8 >/dev/null 2>&1 \ + && ok "the wizard asks for $k, and there is a screen for it" \ + || bad "the wizard asks for $k and question_for has no screen for it" +done + +# The moments the terminal still words itself. Not a to-do list: each asks about something +# the two front ends reach differently, and a shared screen would say something untrue in +# one of them until that stops being so. +# +# the project and app pickers the chat draws them from discovered.json, which the wizard +# does not write — it lists live, one project's apps only +# the typed package name free text, not a pick +# "Register ...?", twice the screen's wording turns on whether a listing happened, +# which on this path it has, uncached +# "Delete the local key now?" no screen: bin/agent has no verb that deletes it, so a yes +# on the chat path would have nothing behind it +# "Go ahead?" the block above it is firebase_consent_banner, already +# shared with both other front ends; only the yes/no is local +# +# Counted, so a prompt written straight into the wizard fails here until it is either a +# screen or a line above. The device menu is not among them: it draws its own rows, all of +# them rather than a chat's four, but the sentences over it are the screen's. +grep -qF 'q_render "$(question_for pick_device)"' bin/wizard \ + && ok "the device menu is the wizard's, and the words above it are the screen's" \ + || bad "the wizard's device step words itself again" + +OWN=$(( $(grep -c 'ask_yn "' bin/wizard) + $(grep -c 'bold "Pick the' bin/wizard) \ + + $(grep -c 'read -r -p " Android package' bin/wizard) )) +if ((OWN == 7)); then + ok "the 7 moments the terminal still words itself are the 7 with a reason written down" +else + bad "the wizard words $OWN moments itself, not 7 — migrate it to a screen, or say why not" +fi + echo echo " The contract" echo diff --git a/tests/ask-question.exp b/tests/ask-question.exp new file mode 100755 index 0000000..a1ca448 --- /dev/null +++ b/tests/ask-question.exp @@ -0,0 +1,131 @@ +#!/usr/bin/expect -f +# ask_question is what makes the wizard and a chat read the same: one set of words in +# bin/questions.sh, rendered two ways. agent-questions.sh checks the words offline. This +# checks the other half — that the answer a person gives comes back as the option they +# picked, and that declining comes back as declining rather than as the first option. +# +# Needs a pty: the picker only draws its arrow-key form when stderr is a terminal, and the +# keys are escape sequences a pipe never delivers. +# +# No network, no credentials, no device. gcloud and adb are stubs that fail if called. + +set timeout 20 +set repo [file normalize [file dirname [info script]]/..] +set tag [pid] +set out "/tmp/itbl-ask-question-$tag.out" +set failed 0 + +# config.sh refuses a workspace inside the tool itself, which is where a run from the repo +# would land — and then the source fails, no menu opens, and every cancel case passes for +# the wrong reason. +set ws "/tmp/itbl-ask-question-$tag.ws" +file delete -force $ws +file mkdir $ws/tools +set env(WS) $ws + +proc ok {msg} { puts " \033\[32mPASS\033\[0m $msg" } +proc bad {msg why} { global failed; set failed 1; puts " \033\[31mFAIL\033\[0m $msg — $why" } + +# Returns "rc|index|label". The three are written together because they are one answer: +# an index without its return code cannot tell a choice from a cancel. +proc ask {kind keys} { + global out repo ws + file delete -force $out + spawn bash -c "cd $repo; . tests/lib/stub-toolchain.sh; stub_toolchain $ws/tools; \ + export PATH=$ws/tools:\$PATH; source bin/config.sh >/dev/null 2>&1; \ + source bin/questions.sh; ask_question $kind; rc=\$?; \ + printf '%s|%s|%s' \$rc \$ASK_INDEX \"\$ASK_LABEL\" > $out" + # Input sent while the pty is still in its original canonical mode can be swallowed + # when menu_keys changes modes. Wait for the menu itself, not an arbitrary sleep. + if {[llength $keys] && [lindex $keys 0] ne ""} { + expect { + -ex "enter choose" {} + eof { return "" } + timeout { + catch { close }; catch { wait } + return "" + } + } + } + foreach k $keys { send -- $k; sleep 0.1 } + expect { + eof {} + timeout { catch { close }; catch { wait }; return "" } + } + if {![file exists $out]} { return "" } + set f [open $out r]; set v [read $f]; close $f + return $v +} + +puts "" +puts " The screen in a terminal — the words, and the answer coming back" +puts "" + +# The sign-in screen: reachable from an empty workspace, so nothing has to be discovered +# or provisioned first. +set v [ask authenticate [list "\r"]] +if {$v eq "0|1|Sign in with gcloud"} { + ok "enter on arrival answers with the first option the screen lists" +} else { + bad "enter takes the first option" "got '$v'" +} + +if {[string match "0|2|*" [ask authenticate [list "\033\[B" "\r"]]]} { + ok "down then enter answers with the second" +} else { + bad "down then enter" "did not come back as option 2" +} + +# Nothing chosen has to be distinguishable from the first option chosen. It is the +# difference between a developer who declined and one who agreed to sign in. +set v [ask authenticate [list "q\r"]] +if {$v eq "1|0|"} { + ok "cancelling reports no answer, not the first one" +} else { + bad "cancelling reports no answer" "got '$v'" +} + +# A kind with no screen is a caller's bug, and the caller needs to be able to tell it +# apart from a cancel so it can fall back to its own prompt instead of proceeding. +set v [ask no_such_screen_here {}] +if {$v eq "2|0|"} { + ok "a kind with no screen is its own answer, distinct from a cancel" +} else { + bad "unknown kind returns 2" "got '$v'" +} + +# ------------------------------------------- and the words actually reach the terminal +# The offline check proves q_render prints them. This proves ask_question is what the +# terminal runs, so a wizard that stopped calling it would show here. +spawn bash -c "cd $repo; . tests/lib/stub-toolchain.sh; stub_toolchain $ws/tools; \ + export PATH=$ws/tools:\$PATH; source bin/config.sh >/dev/null 2>&1; \ + source bin/questions.sh; ask_question authenticate >/dev/null" +expect { + -ex "Ready to sign in to Google?" { + ok "the prompt a chat would be handed is the prompt printed here" + } + eof { bad "the prompt is printed" "the screen never appeared" } + timeout { bad "the prompt is printed" "nothing was drawn" } +} +# A fragment short enough to survive wrapping: the renderer breaks paragraphs to the +# terminal's width, so a sentence long enough to wrap never matches as one string. +expect { + -ex "it opens your own browser" { + ok "and so is the paragraph under it" + } + eof { bad "the context is printed" "the explanation was dropped" } + timeout { bad "the context is printed" "nothing further was drawn" } +} +expect { + -ex "enter choose" {} + eof { bad "the menu is ready" "the process exited before drawing it" } + timeout { bad "the menu is ready" "the question appeared but its options did not" } +} +send -- "q\r" +expect eof + +file delete -force $out +file delete -force $ws +puts "" +if {$failed} { puts " FAILED"; exit 1 } +puts " All good — one set of words, and the answer that comes back is the one given." diff --git a/tests/iterable-part.exp b/tests/iterable-part.exp index 9f892e8..96001d0 100755 --- a/tests/iterable-part.exp +++ b/tests/iterable-part.exp @@ -18,7 +18,8 @@ set timeout 8 set failed 0 -set ws "/tmp/itbl-part.ws" +set tag [pid] +set ws "/tmp/itbl-part-$tag.ws" set env(WS) $ws # The arrow-key form draws with escapes and redraws over itself; the numbered form is the # same decision in text a test can read. Both are shipped and digits work in both. @@ -46,6 +47,13 @@ proc fixture {g10} { proc drive {keys} { spawn -noecho bash -c {source bin/config.sh; iterable_part; echo "fork=$?"} set out "" + if {[llength $keys]} { + expect { + -re {Choose 1-} { append out $expect_out(buffer) } + eof { append out $expect_out(buffer); catch { wait }; return $out } + timeout { catch { close }; catch { wait }; return $out } + } + } foreach k $keys { send $k } expect { -re {.+} { append out $expect_out(buffer); exp_continue } @@ -122,7 +130,10 @@ if {[regexp {EU \(EDC} $run]} { } # The wizard prints the verdict itself at the end. Without this the developer got the # whole pending block twice — two identical boxes reading as two things to do. -if {[string first "ITBL_INTRO_SHOWN=1 QUIET_VERDICT=1" [exec cat bin/config.sh]] >= 0} { +set config_file [open "bin/config.sh" r] +set config_text [read $config_file] +close $config_file +if {[string first "ITBL_INTRO_SHOWN=1 QUIET_VERDICT=1" $config_text] >= 0} { ok "and runs it quiet, so the ending is printed once" } else { bad "the walk is quiet" "iterable-keys is invoked without QUIET_VERDICT — two endings" diff --git a/tests/menu-pick.exp b/tests/menu-pick.exp index ca9f7dd..60fa36f 100755 --- a/tests/menu-pick.exp +++ b/tests/menu-pick.exp @@ -6,13 +6,14 @@ # No network, no credentials, no device — only config.sh's menu functions. set timeout 20 -set out "/tmp/itbl-menu-pick.out" +set tag [pid] +set out "/tmp/itbl-menu-pick-$tag.out" set failed 0 # config.sh refuses to resolve a workspace inside the tool itself, which is where a # run from the repo would land. Without this the source fails, the menu never opens, # and the cancel cases pass for the wrong reason — they did, before this line existed. -set ws "/tmp/itbl-menu-pick.ws" +set ws "/tmp/itbl-menu-pick-$tag.ws" file delete -force $ws file mkdir $ws set env(WS) $ws @@ -27,8 +28,19 @@ proc pick {pre keys} { global out file delete -force $out spawn bash -c "$pre source bin/config.sh; printf 'alpha\\nbravo\\ncharlie\\ndelta\\n' | menu > $out" + expect { + -re {enter choose|Choose 1-} {} + eof { return "" } + timeout { + catch { close }; catch { wait } + return "" + } + } foreach k $keys { send -- $k; sleep 0.1 } - expect eof + expect { + eof {} + timeout { catch { close }; catch { wait }; return "" } + } if {![file exists $out]} { return "" } set f [open $out r]; set v [read $f]; close $f return $v @@ -76,7 +88,7 @@ if {[pick "" [list "3" "\r"]] eq "charlie"} { } # ---------------------------------------------------------------- cancelling -set v [pick "" [list "q"]] +set v [pick "" [list "q\r"]] if {$v eq ""} { ok "q cancels and chooses nothing" } else { bad "q cancels" "chose '$v'" } set v [pick "" [list "\033" "\033"]] @@ -85,7 +97,12 @@ if {$v eq ""} { ok "so does escape" } else { bad "escape cancels" "chose '$v'" } # A hidden cursor that is never restored outlives the process and leaves the # developer's own shell without one. Every exit path has to put it back. spawn bash -c "source bin/config.sh; printf 'alpha\\nbravo\\n' | menu > /dev/null" -send -- "q" +expect { + -ex "enter choose" {} + eof { bad "the cursor is restored" "picker exited before drawing" } + timeout { bad "the cursor is restored" "picker never became ready" } +} +send -- "q\r" # -ex, not glob: expect reads `[?25h` as a character class and matches nothing useful. expect { -ex "\033\[?25h" { ok "the cursor is restored on the way out" } diff --git a/tests/text-surface.sh b/tests/text-surface.sh new file mode 100755 index 0000000..8715f4b --- /dev/null +++ b/tests/text-surface.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# The words as a surface someone edits: that they can be found, and that they fit where they +# are printed. Whether they are the *right* words is not a thing a test can hold. +# +# tools/screens is what a docs editor reads: every screen's current wording, each headed by +# the file and line it is written on. Two ways it can fail without looking broken, and both +# leave someone with text and nowhere to type: +# +# the list goes empty kinds() reads question_for's own case arms. Reformat that case +# and the sed finds nothing, and screens prints a tidy nothing. +# an address goes missing where() reads which q_ function each arm calls. A screen it +# cannot place is headed "no q_ function", which is a dead end. +# +# The wording itself is checked in agent-questions.sh, against the JSON the chat is handed. +# Here, the pointer being a real one — and the one limit a person editing a gate name cannot +# see from the text they are editing. + +set -uo pipefail +cd "$(dirname "$0")/.." + +FAILED=0 +ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; } +bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; FAILED=1; } +COPY_TMP="$(mktemp -d)"; trap 'rm -rf "$COPY_TMP"' EXIT + +echo +echo " The words — findable, and inside the space they are printed in" +echo + +# The editor-facing entry point has to explain itself without requiring node or rendering a +# ten-second conversation. The names come from the same dispatch as the renderer, so help is +# discovery rather than another screen list to maintain. +HELP="$(tools/screens --help)" || { bad "tools/screens --help did not run"; echo " FAILED"; exit 1; } +if grep -qF 'make screens KIND=SCENARIO' <<< "$HELP" \ + && grep -qE '^ pick_device[[:space:]]+bin/questions.sh:' <<< "$HELP" \ + && grep -qE '^ iterable_step 4[[:space:]]+bin/questions.sh:' <<< "$HELP"; then + ok "screens help shows how to render one scenario and lists every kind of selector" +else + bad "screens help does not make the available scenarios discoverable" +fi + +LIST="$(tools/screens --list)" +if grep -qxF 'pick_device' <<< "$LIST" \ + && grep -qxF 'iterable_step 1' <<< "$LIST" \ + && grep -qxF 'iterable_step 4' <<< "$LIST"; then + ok "the plain scenario list includes ordinary and argument-taking screens" +else + bad "tools/screens --list omitted a screen scenario" +fi + +# The first writer-facing screen: prose in Markdown, actions still in shell. Validate the +# live file, then prove the stable option ids connect each editable label to the right +# command even though those ids never have to become developer-visible. +# shellcheck source=bin/screen-copy.sh +. bin/screen-copy.sh +if screen_copy_validate opening "agent terminal stop" "part_map"; then + ok "the opening Markdown has every required section, option id and placeholder" +else + bad "copy/screens/opening.md does not satisfy the writer-facing format" +fi + +OPENING="$(WS="$COPY_TMP/ws" bash bin/agent question opening)" +AGENT_LABEL="$(screen_copy_option_scalar opening agent Label)" +TERMINAL_LABEL="$(screen_copy_option_scalar opening terminal Label)" +STOP_LABEL="$(screen_copy_option_scalar opening stop Label)" +if printf '%s' "$OPENING" | AGENT_LABEL="$AGENT_LABEL" TERMINAL_LABEL="$TERMINAL_LABEL" \ + STOP_LABEL="$STOP_LABEL" node -e ' + let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{ + const q=JSON.parse(s), byLabel=l=>q.options.find(o=>o.label===l); + const a=byLabel(process.env.AGENT_LABEL), t=byLabel(process.env.TERMINAL_LABEL); + const stop=byLabel(process.env.STOP_LABEL); + if (!a || !a.commands.some(c=>c.includes("DRIVER=agent"))) process.exit(1); + if (!t || !t.commands.some(c=>c.includes("DRIVER=developer"))) process.exit(1); + if (!stop || stop.commands.length) process.exit(1); + })'; then + ok "editable option labels remain attached to behavior by stable ids" +else + bad "an opening option inherited another option's behavior" +fi + +# Writer mistakes should point at the copy, not fail later as malformed JSON. Each fixture +# breaks one rule and asks only for the useful part of the resulting message. +cp copy/screens/opening.md "$COPY_TMP/opening.md" +sed -i.bak 's/{{part_map}}/{{part_maps}}/' "$COPY_TMP/opening.md" +ERR="$(SCREEN_COPY_ROOT="$COPY_TMP" bash -c \ + 'source bin/screen-copy.sh; screen_copy_validate opening "agent terminal stop" part_map' 2>&1)" || true +if grep -qF 'unknown placeholder {{part_maps}}' <<< "$ERR"; then + ok "a placeholder typo names the file, line and unknown placeholder" +else + bad "an unknown Markdown placeholder did not produce a useful error" +fi + +sed '/^## Option: stop/,$d' copy/screens/opening.md > "$COPY_TMP/opening.md" +ERR="$(SCREEN_COPY_ROOT="$COPY_TMP" bash -c \ + 'source bin/screen-copy.sh; screen_copy_validate opening "agent terminal stop" part_map' 2>&1)" || true +if grep -qF 'missing option "stop"' <<< "$ERR"; then + ok "removing a behavior-backed option says which stable id is missing" +else + bad "a missing Markdown option did not name its stable id" +fi + +cp copy/screens/opening.md "$COPY_TMP/opening.md" +printf '\n## Prompt\n\nAnother question?\n' >> "$COPY_TMP/opening.md" +ERR="$(SCREEN_COPY_ROOT="$COPY_TMP" bash -c \ + 'source bin/screen-copy.sh; screen_copy_validate opening "agent terminal stop" part_map' 2>&1)" || true +if grep -qF 'duplicate "Prompt" section' <<< "$ERR"; then + ok "duplicating a fixed section says which heading must be removed" +else + bad "a duplicate Markdown section did not produce a useful error" +fi + +# --where rather than the whole thing: the render is ten seconds of node per pass, and it is +# not what is fragile here. +W="$(tools/screens --where)" || { bad "tools/screens --where did not run"; echo " FAILED"; exit 1; } + +# Counted against the dispatch directly, so the check does not share kinds()' sed with the +# thing it is checking. +ARMS=$(sed -n '/^question_for() {/,/^}/p' bin/questions.sh \ + | grep -cE '^ [a-z_|]+\)') +LISTED=$(grep -c $'^[a-z_]*\t' <<< "$W") +if ((ARMS > 10 && LISTED >= ARMS)); then + ok "$LISTED screens listed, from $ARMS arms of the dispatch" +else + bad "$LISTED screens listed for $ARMS arms — the list is reading the dispatch wrong" +fi + +# An empty second field, not the words "no q_ function": that sentence is printed by the +# display path, and a test that greps for it would pass on a kind that resolved to nothing. +MISSING="$(awk -F'\t' '$2 == "" { print $1 }' <<< "$W")" +if [[ -z "$MISSING" ]]; then + ok "every kind resolves to somewhere in the file" +else + bad "screens cannot place these kinds — their readers get sent nowhere:" + printf ' %s\n' $MISSING +fi + +# Nothing dropped on the way. where() looks each function up by its definition line and skips +# the ones it cannot find, so a function written as `q_foo () {` disappears from the address +# of a screen that still has two other functions in it — no empty field, and no mention of +# the one you would need to edit. The line numbers themselves are read from the file at +# render time, so they cannot be stale; being there at all is the part worth asserting. +NAMED="$(sed -n '/^question_for() {/,/^}/p' bin/questions.sh | grep -oE 'q_[a-z_]+' | sort -u)" +DROPPED="" +for fn in $NAMED; do + grep -qE "[0-9]+ +$fn( |\$)" <<< "$W" || DROPPED+=" $fn" +done +if [[ -z "$DROPPED" ]]; then + ok "all $(wc -w <<< "$NAMED" | tr -d ' ') screen functions appear in the addresses" +else + bad "the dispatch calls these and no address mentions them:$DROPPED" +fi + +# One screen rendered for real, because everything the render needs — the stub toolchain, the +# fixture discovery cache, config.sh sourced from outside a workspace — can break at once and +# --where would not notice. pick_device uses all three. +OUT="$(tools/screens pick_device 2>&1)" +if grep -q 'Which device' <<< "$OUT" && grep -q 'Pixel_9_Pro' <<< "$OUT"; then + ok "a screen renders with its sample devices in it" +else + bad "tools/screens pick_device did not render — the fixtures or the render broke:" + printf '%s\n' "$OUT" | sed 's/^/ /' | head -12 +fi + +# The fixtures hold a fake key path and a fake account; they are a temp directory for a +# reason, and a docs editor running this all afternoon should not be leaving a trail. +LEFT=$(ls -d /tmp/iterable-screens.* 2>/dev/null | wc -l | tr -d ' ') +((LEFT == 0)) && ok "the sample workspace goes away with the process" \ + || bad "$LEFT sample workspaces left in /tmp — the cleanup trap is not firing" + +# Gate names print into a fixed column — `%-4s %-34s` in bin/gates — and the verdict starts +# where that column ends. A name one character over does not wrap; it shoves that gate's +# verdict right while every other row stays put, and the ladder stops reading as a list. +# Nothing about the sentence tells you that, which is why it is here and not a comment. +NAMES="$(grep -hoE '^(gate|note) G[0-9]+ +"[^"]*"' bin/gates bin/gates-*.sh \ + | sed 's/.*"\(.*\)"/\1/')" +WIDE="$(awk 'length > 34 { print length ": " $0 }' <<< "$NAMES")" +COUNT=$(grep -c . <<< "$NAMES") +if ((COUNT < 10)); then + # Otherwise the check passes by finding nothing, which is what it would do the day the + # ladder is declared some other way. + bad "only $COUNT gate names found — this is not reading the ladder any more" +elif [[ -z "$WIDE" ]]; then + ok "all $COUNT gate names fit the 34-column field bin/gates prints them in" +else + bad "a gate name is wider than its column — the verdict beside it gets pushed out of line:" + printf '%s\n' "$WIDE" | sed 's/^/ /' +fi + +echo +((FAILED)) && { echo " FAILED"; exit 1; } +echo " All good — the wording has an address, and it fits." diff --git a/tests/wizard-decline.exp b/tests/wizard-decline.exp index aa5505e..6d11f44 100755 --- a/tests/wizard-decline.exp +++ b/tests/wizard-decline.exp @@ -16,7 +16,8 @@ set timeout 180 # refuses to resolve a workspace inside itself, which is where a test run from the # repo would land, and a run that picks a project must not overwrite the choice the # developer is actually working on. -set ws "/tmp/itbl-wizard-decline.ws" +set tag [pid] +set ws "/tmp/itbl-wizard-decline-$tag.ws" file delete -force $ws file mkdir $ws set env(WS) $ws @@ -27,7 +28,7 @@ set env(WS) $ws # work. On a fully provisioned project the wizard exits before the consent prompt, # so the assertion this test exists for is unreachable and it silently skipped. proc first_project_needing_work {} { - return [exec bash -c { + spawn -noecho bash -c { source bin/config.sh i=0 while IFS=$'\t' read -r p _; do @@ -35,10 +36,18 @@ proc first_project_needing_work {} { [[ -n "$(android_apps_of "$p")" ]] || continue gcloud iam service-accounts describe "itbl-onboard-fcm@$p.iam.gserviceaccount.com" \ --format='value(email)' >/dev/null 2>&1 && continue - echo "$i"; exit 0 + echo "PROJECT_INDEX=$i"; exit 0 done <<< "$(firebase_projects)" - echo 0 - }] + echo PROJECT_INDEX=0 + } + set found 0 + expect { + -re {PROJECT_INDEX=([0-9]+)} { set found $expect_out(1,string); exp_continue } + eof {} + timeout { catch { close }; catch { wait } } + } + catch { wait } + return $found } set proj [expr {$argc > 0 ? [lindex $argv 0] : [first_project_needing_work]}] diff --git a/tools/screens b/tools/screens new file mode 100755 index 0000000..c604bc5 --- /dev/null +++ b/tools/screens @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# Every screen a developer can be shown, printed from the source of it. +# +# tools/screens all of them +# tools/screens pick_device one +# tools/screens --help usage and every available scenario +# tools/screens --list just the scenario names, one per line +# tools/screens --where just the addresses, for the test that they all resolve +# +# For anyone who needs to read or change the wording — the whole conversation on one +# screenful, each screen headed by the file and line the words live on, so reading it and +# editing it are the same two minutes. `make screens` runs it. +# +# Nothing is stored. A committed dump of this would be a second copy of the words to keep +# true, and the first time it fell behind it would be worse than no dump at all: it shows +# you text without telling you where to type. This renders live from the Markdown and +# shell sources every time you ask. +# +# It is not a contract. INTENDED-FLOW.md says what the path is; this says what the words +# currently are. If the two disagree, the code and this agree with each other and the +# document is the one that is wrong. +# +# No network, no credentials, no device: a stub toolchain and a fixture discovery cache, so +# what you read is the real screen with sample projects in it rather than a mock of one. + +set -uo pipefail +cd "$(dirname "$0")/.." +Q=bin/questions.sh + +# The dispatch table is the list of screens — one place, and it is the same place the +# renderer reads, so there is no second list here to fall behind it. +kinds() { + sed -n "/^question_for() {/,/^}/p" "$Q" \ + | grep -oE '^ [a-z_|]+\)' | tr -d ' )' | tr '|' '\n' +} + +# where — the functions the dispatch hands it to, and the line each is written on. +# Read off the case arm rather than guessed from the name: `choose_driver` is answered by +# q_opening, and `run_in_terminal` by q_approve_firebase. +where() { + local fns fn line copy out="" sep="" + fns="$(sed -n "/^question_for() {/,/^}/p" "$Q" \ + | awk -v k="$1" '$0 ~ ("^ ([a-z_]+\\|)*" k "(\\||\\))") { a = 1 } + a { print; if (/;;/) exit }' \ + | grep -oE 'q_[a-z_]+' | sort -u)" + for fn in $fns; do + # A screen whose prose has moved to the writer-facing Markdown names that first. + # Derived from q_opening -> opening.md, so adding the next one needs no registry here. + copy="copy/screens/${fn#q_}.md" + if [[ -f "$copy" ]]; then + out+="$sep$copy:1 words"; sep=" + " + fi + line="$(grep -n "^$fn() {" "$Q" | cut -d: -f1)" + [[ -n "$line" ]] || continue + out+="$sep$Q:$line $fn behavior"; sep=" + " + done + printf '%s' "$out" +} + +# The selectors accepted on the command line. Iterable's four steps share one kind in the +# dispatch, but each has different words, so they are four scenarios here. +scenarios() { + local k n + for k in $(kinds); do + if [[ "$k" == iterable_step ]]; then + for n in 1 2 3 4; do printf '%s %s\n' "$k" "$n"; done + else + printf '%s\n' "$k" + fi + done +} + +scenario_table() { + local scenario kind source + while IFS= read -r scenario; do + kind="${scenario%% *}" + source="$(where "$kind" | tr '\n' ' ' | tr -s ' ')" + printf ' %-24s %s\n' "$scenario" "${source:-no q_ function found}" + done < <(scenarios) +} + +usage() { + cat <<'EOF' +Usage: + make screens + make screens KIND=SCENARIO + make screens-help + + tools/screens + tools/screens SCENARIO + tools/screens --list + tools/screens --where + +With no scenario, every screen is rendered. Use --list for copy-friendly scenario +names; --where prints the dispatch addresses without rendering. + +Available scenarios: +EOF + scenario_table +} + +# Help and source discovery need neither node nor a sample workspace. Keep them usable on +# a machine that is only trying to find the words or learn the command. +case "${1:-}" in + -h|--help|help) usage; exit 0 ;; + --list) scenarios; exit 0 ;; + --where) + for k in $(kinds); do + printf '%s\t%s\n' "$k" "$(where "$k" | tr '\n' ' ' | tr -s ' ')" + done + exit 0 ;; +esac + +command -v node >/dev/null || { echo "screens needs node — the questions are JSON." >&2; exit 30; } + +# Copied rather than sourced: config.sh resolves a workspace as it loads and refuses one +# inside the tool, which is exactly where a run from the repo would land. Two lines is +# cheaper than the exception that would need. +dim() { printf '\033[2m%s\033[0m' "$1"; } +bold() { printf '\033[1m%s\033[0m' "$1"; } + +# So the fixtures live in a temp directory, and go away with the process. Named rather than +# left to mktemp's default, because some screens print the workspace path back at you and +# `/tmp/iterable-screens.aB12cD/artifacts/sa-key.json` reads as the sample it is, where +# macOS's `/var/folders/wp/dvfc7…/T/tmp.358ugbAljz/` reads as something to worry about. +WSD="$(mktemp -d /tmp/iterable-screens.XXXXXX)"; trap 'rm -rf "$WSD"' EXIT +# shellcheck source=tests/lib/stub-toolchain.sh +. tests/lib/stub-toolchain.sh +stub_toolchain "$WSD/tools" +# An emulator too, which the shared stub leaves out: without it the device screen lists +# whatever AVDs happen to be on this machine, and the same command would print different +# text to two people reading it together. +printf '#!/bin/sh\n[ "$*" = "-list-avds" ] && printf "Pixel_9_Pro\\nOld_Tablet\\n"\nexit 0\n' \ + > "$WSD/tools/emulator" +chmod +x "$WSD/tools/emulator" + +# Five projects, because at four the pickers start saying "N more not shown" and that +# sentence is one of the ones worth reading. +node -e ' +const projects = []; +for (let i = 1; i <= 5; i++) projects.push({ id: `proj-number-${i}`, name: `Project ${i}`, apps: [] }); +projects[1].apps = [{ package: "com.other.thing", appId: "1:2:android:aaa" }]; +projects[3].apps = [{ package: "com.dogshelter", appId: "1:4:android:bbb" }]; +process.stdout.write(JSON.stringify({ account: "you@example.com", projects })); +' > "$WSD/discovered.json" + +# One screen, in whichever state has something to say. Most speak with a project and a +# package on record; the ones that are about choosing those speak only before. Which state +# was used is printed, because a screen reads differently depending on it. +render() { + local kind="$1" arg="${2:-}" state + for state in chosen fresh; do + if [[ "$state" == chosen ]]; then P=proj-number-4; K=com.dogshelter; else P=""; K=""; fi + WS="$WSD" PID="$P" PACKAGE="$K" PATH="$WSD/tools:$PATH" \ + bash -c ' + source bin/config.sh >/dev/null 2>&1; source bin/questions.sh + q="$(question_for "$1" "$2")" || exit 1 + q_render "$q" + printf "%s" "$q" | jqn '\'' + const W = 72, out = []; + const wrap = (s, pad) => { + let cur = ""; + for (const w of (s || "").split(" ")) { + if (cur && (cur + " " + w).length > W) { out.push(pad + cur); cur = w; } + else cur = cur ? cur + " " + w : w; + } + if (cur) out.push(pad + cur); + }; + (j.options || []).forEach((o, i) => { + out.push(" " + (i + 1) + ") " + o.label + (o.label === j.recommended ? " (recommended)" : "")); + wrap(o.description, " "); + if (o.relay) out.push(" you run: " + o.relay); + out.push(""); + }); + if (j.free_text) wrap("or type " + j.free_text.hint + " — " + j.free_text.effect, " "); + process.stdout.write(out.join("\n") + "\n"); + '\'' + ' _ "$kind" "$arg" && { + if [[ "$state" == chosen ]]; then + printf ' %s\n' "$(dim "with proj-number-4 / com.dogshelter already on record")" + else + printf ' %s\n' "$(dim "with nothing chosen yet")" + fi + return 0 + } + done + return 1 +} + +one() { # [arg] + local kind="$1" arg="${2:-}" name="$1" w rule="" + [[ -n "$arg" ]] && name="$1 $arg" + w="$(where "$kind")" + ((${#name} < 60)) && rule="$(printf '%.0s─' $(seq 1 $((60 - ${#name}))))" + echo + printf ' %s %s\n' "$(bold "── $name")" "$(dim "$rule")" + printf ' %s\n' "$(dim "${w:-no q_ function — see $Q}")" + render "$kind" "$arg" || printf '\n %s\n' "$(dim "no screen in either state — this step is work, not a question")" +} + +if (($# > 0)); then + requested="$*"; known=0 + while IFS= read -r scenario; do + [[ "$scenario" == "$requested" ]] && known=1 + done < <(scenarios) + if ((known == 0)); then + printf 'Unknown screen scenario: %s\n\n' "$requested" >&2 + printf 'Run tools/screens --help to see every available scenario.\n' >&2 + exit 2 + fi + one "$@" + exit 0 +fi + +for k in $(kinds); do + # The four dashboard steps are one kind with an argument, and each is a different screen. + if [[ "$k" == iterable_step ]]; then + for n in 1 2 3 4; do one "$k" "$n"; done + else + one "$k" + fi +done + +echo +printf ' %s\n' "$(dim "question sources are listed above · other text surfaces are mapped in CONTRIBUTING.md")" +echo