@imagina-build/ias-cli 0.3.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,7 +45,8 @@ npm run pack:release --prefix packages/cli -- ./release
45
45
  | Comando | Qué hace |
46
46
  | --- | --- |
47
47
  | `ias setup [status\|next\|check\|services\|reset] [--create-app] [--name "…"] [--ios-bundle-id <id>] [--android-package <id>] [--apple-team-id <id>] [--app <código>] [--services <lista>] [--team <código>] [--local-builds] [--all] [--build <perfil>] [--publish] [--message "…"] [--wait] [--open] [--item <op://…>] [--dry-run]` | La checklist de alta de una app: comprueba cada paso contra los ficheros y el servidor, hace los que puede aquí (`ias init`, `ias login`, `ias sdk install`, `ias vault init`, la aplicación con `--create-app`, los proyectos OTA y de distribución y el código de alta del runner) y dice qué tiene que hacer una persona. Un paso no avanza mientras el anterior no esté hecho. `next` hace el siguiente; `check` sale con 1 mientras falte algo; `services` elige `updates`, `distribution` y `push`; `reset` olvida el estado local. |
48
- | `ias init [--app <código>] [--force] [--dry-run]` | Escribe `ias.json` a partir de `app.json`, `eas.json`, `ota.config.json` y `dist.config.json`. Dice de dónde sale cada parte y qué se queda fuera. |
48
+ | `ias init [--app <código>] [--force] [--dry-run] [--no-skill]` | Escribe `ias.json` a partir de `app.json`, `eas.json`, `ota.config.json` y `dist.config.json`. Dice de dónde sale cada parte y qué se queda fuera. Deja lista la skill de IAS: añade su catálogo a la configuración de OpenCode y la instala en `.claude/skills/ias` (Claude Code) y `.agents/skills/ias` (Codex); `--no-skill` no toca nada de eso. |
49
+ | `ias skill install\|update [--global] [--opencode] [--claude] [--codex] [--force] [--dry-run]` | Escribe la skill del agente (`SKILL.md` y `references/*.md`) desde el propio CLI, sin red, para los tres arneses: `.opencode/skills/ias`, `.claude/skills/ias` y `.agents/skills/ias` del proyecto, o `~/.config/opencode/skills/ias`, `~/.claude/skills/ias` y `~/.agents/skills/ias` con `--global`. `--opencode`, `--claude` y `--codex` lo restringen a uno o varios. Es idempotente; sin `--force` se para si un fichero existe con otro contenido y los lista, sin dejar una instalación a medias. `update` es `install --force`, y `--dry-run` enseña el plan. |
49
50
  | `ias doctor` | Comprueba Node, `git`, `op`, Xcode y sus herramientas (`plutil`, `security`, `sips`, `unzip`), `aapt2` del SDK de Android y la configuración de la app. En una app con `package.json`, también el SDK `@imagina-build/ias`: instalado, de una versión compatible con la de `ias` (la misma *major* y, mientras es 0, la misma *minor*) y con su plugin en `app.json`; si la app no lo usa, es un aviso. No abre ningún secreto. |
50
51
  | `ias sdk install [--from <origen>] [--dry-run]` | Instala `@imagina-build/ias` con el gestor de paquetes de la app (`packageManager` de `package.json` o su lockfile: npm, yarn, pnpm o bun), añade su plugin al final de los `plugins` de `app.json` sin tocar lo demás (con `app.config.*` no lo toca y dice qué añadir) y dice qué falta: `ias init`, `expo-updates`, `expo-notifications` y `expo-task-manager`, y un prebuild o una build nueva. El paquete sale de `--from` (un `.tgz`, su URL, una carpeta o una versión), de `IAS_SDK_SOURCE`, del `packages/sdk` del repositorio si `ias` corre desde un clon (compilado), o de npm como `@imagina-build/ias@^<versión de ias>`. |
51
52
  | `ias update init\|publish\|republish\|rollback-to-embedded\|retire\|restore\|list\|check` | Las actualizaciones OTA, con el comportamiento de `tools/expo-ota` ([README](../../tools/expo-ota/README.md)). `init` crea el proyecto OTA, su clave de firma y su token en 1Password y escribe `ias.json` (Fase 2). `rollback` vale por `rollback-to-embedded`. `--channel` firma con la clave de ese canal y `--rollout <1-100>` empieza con ese porcentaje de las instalaciones (§3.1). `check` comprueba el canal por defecto de `app.json` y, con `--channel <canal>`, pide el manifiesto como una build de ese canal y lo verifica con su certificado de `ias.json`. |
@@ -84,6 +85,8 @@ npm run pack:release --prefix packages/cli -- ./release
84
85
  | `ias tokens create\|list\|revoke` | Los tokens de robot de la aplicación para la CI: crea uno y lo guarda en 1Password (sin imprimirlo), los lista sin su valor y revoca uno por su `id` o su nombre (`--dry-run` no revoca). Un `owner` con token personal; la API ya no obliga a crearlo en el panel (§2.1). |
85
86
  | `ias runners code` | Un código de alta de runner, de un solo uso y 30 minutos, con la orden para el Mac del runner. Un `owner`; después, `ias vault grant`. |
86
87
 
88
+ **La skill del agente.** Un proyecto nuevo queda listo para OpenCode, Claude Code y Codex. `ias init` añade el catálogo `https://ias.imagina.build/skills/` a `skills` de la configuración de OpenCode (crea `opencode.jsonc` si no hay ninguna; con comentarios o sin poder leerla, imprime la línea exacta para añadirla a mano), instala los ficheros en `.claude/skills/ias` y `.agents/skills/ias` (no en `.opencode/skills/ias`: el catálogo ya cubre OpenCode). No toca `AGENTS.md`. `--no-skill` no toca nada de eso; si una carpeta ya tiene otro contenido, lo dice, sigue con los demás destinos y da el comando `ias skill install --force`. `ias skill install` es el camino sin red (CI): escribe las tres carpetas del proyecto, o las globales con `--global`, y `--opencode`/`--claude`/`--codex` lo restringen. `ias doctor` y `ias setup` avisan, sin bloquear, si no encuentran la skill en ninguna de las ubicaciones.
89
+
87
90
  Cada comando tiene su ayuda: `ias update --help` o `ias update publish --help`. `--project <carpeta>` elige la app; si no, la carpeta actual.
88
91
 
89
92
  **Salida.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imagina-build/ias-cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
4
4
  "description": "The command line of Imagina Application Services (IAS): ias.json, OTA updates, internal distribution and the checks of a project.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -17,7 +17,7 @@ export { EXIT_CODE };
17
17
  export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
18
18
 
19
19
  /** The order of the commands in the help; the rest follow by name. */
20
- const ORDER = ['login', 'logout', 'whoami', 'apps', 'setup', 'init', 'doctor', 'sdk', 'update', 'dist', 'credentials', 'env', 'vault', 'policy', 'tokens', 'runners'];
20
+ const ORDER = ['login', 'logout', 'whoami', 'apps', 'setup', 'init', 'doctor', 'skill', 'sdk', 'update', 'dist', 'credentials', 'env', 'vault', 'policy', 'tokens', 'runners'];
21
21
 
22
22
  /** The options of every command. */
23
23
  export const GLOBAL_OPTIONS = {
@@ -12,6 +12,7 @@ import { EXIT_CODE } from '../../output.mjs';
12
12
  import { probe as systemProbe } from '../../process.mjs';
13
13
  import { SDK_PACKAGE, compatibleRange, isCompatible, packageManager, sdkState } from '../../sdk.mjs';
14
14
  import { isReference } from '../../secrets.mjs';
15
+ import { skillHint } from '../../skill.mjs';
15
16
 
16
17
  const MIN_NODE = 22;
17
18
  const MAC_TOOLS = ['plutil', 'security', 'sips', 'unzip'];
@@ -147,6 +148,8 @@ export function doctor(dir, { env, output }) {
147
148
  const count = (status) => checks.filter((check) => check.status === status).length;
148
149
  const failures = count('fail');
149
150
  output.log(`${output.prefix}: ${failures === 0 ? 'ready' : `${failures} ${failures === 1 ? 'thing stops' : 'things stop'} a command`}${count('warn') > 0 ? `, ${count('warn')} to look at` : ''}`);
151
+ const hint = skillHint(dir, env);
152
+ if (hint) output.log(`${output.prefix}: ${hint}`);
150
153
  return { ok: failures === 0, checks, [EXIT_CODE]: failures > 0 ? 1 : 0 };
151
154
  }
152
155
 
@@ -6,6 +6,7 @@ import { CONFIG_FILE, DEFAULT_SERVER, LEGACY_FILES, MANIFEST_PATH, readExpo, rea
6
6
  import { IasError } from '../../errors.mjs';
7
7
  import { validate } from '../../schema.mjs';
8
8
  import { isReference, writtenSecrets } from '../../secrets.mjs';
9
+ import { discoverSkill } from '../../skill.mjs';
9
10
 
10
11
  const ENVIRONMENTS = ['development', 'preview', 'production'];
11
12
  const CODE = /^[a-z0-9][a-z0-9-]{0,31}$/;
@@ -188,7 +189,7 @@ function submitProfiles(profiles, leave) {
188
189
  }
189
190
 
190
191
  /** Writes ias.json (unless it exists, or with force) and says where each part came from and what stayed out. */
191
- export function init(dir, { app, force = false, dryRun = false }, output) {
192
+ export function init(dir, { app, force = false, dryRun = false, noSkill = false, env = process.env }, output) {
192
193
  const file = join(dir, CONFIG_FILE);
193
194
  if (existsSync(file) && !force && !dryRun) throw new IasError(`${CONFIG_FILE} already exists in ${dir}`, { fix: 'ias doctor checks it; ias init --force writes it again, and loses what was changed by hand' });
194
195
  const { config, from, leftOut } = initialConfig(dir, { app });
@@ -204,7 +205,8 @@ export function init(dir, { app, force = false, dryRun = false }, output) {
204
205
  const legacy = Object.values(LEGACY_FILES).filter((name) => existsSync(join(dir, name)));
205
206
  if (legacy.length > 0) output.log(`${legacy.join(' and ')} can go once nothing runs tools/ any more: ias reads ias.json first.`);
206
207
  output.log('ias doctor checks it; ias sdk install puts the SDK @imagina-build/ias in the app, with its plugin, which writes ias.json into the builds.');
207
- return { file, written: !dryRun, config, from, left_out: leftOut };
208
+ const skill = noSkill ? undefined : discoverSkill(dir, { dryRun, force }, output);
209
+ return { file, written: !dryRun, config, from, left_out: leftOut, ...(skill ? { skill } : {}) };
208
210
  }
209
211
 
210
212
  function describe(part, value) {
@@ -222,6 +224,7 @@ export default {
222
224
  app: { type: 'string', value: '<code>', description: 'The code of the application (php artisan apps:create), if it cannot be read from updates.url or dist.config.json.' },
223
225
  force: { type: 'boolean', description: 'Write ias.json again if it exists.' },
224
226
  'dry-run': { type: 'boolean', description: 'Print the ias.json it would write, and write nothing.' },
227
+ 'no-skill': { type: 'boolean', description: 'Do not touch the agent skill: no catalog in opencode.jsonc and no .claude/skills/ias or .agents/skills/ias.' },
225
228
  },
226
- run: ({ values, cwd, output }) => init(resolve(cwd, values.project ?? '.'), { app: values.app, force: values.force, dryRun: values['dry-run'] }, output),
229
+ run: ({ values, cwd, env, output }) => init(resolve(cwd, values.project ?? '.'), { app: values.app, force: values.force, dryRun: values['dry-run'], noSkill: values['no-skill'], env }, output),
227
230
  };
@@ -0,0 +1,45 @@
1
+ // ias skill install|update: writes the agent skill of IAS from this package, with no network: the files are
2
+ // src/skill-files.generated.mjs, synced from .claude/skills/ias. All three harnesses by default (OpenCode, Claude Code
3
+ // and Codex), the project's folders or the ones of the home with --global, and --opencode/--claude/--codex restrict it.
4
+ import { resolve } from 'node:path';
5
+ import { installSkill } from '../../skill.mjs';
6
+
7
+ const OPTIONS = {
8
+ global: { type: 'boolean', description: 'Install in ~/.config/opencode/skills/ias, ~/.claude/skills/ias and ~/.agents/skills/ias instead of the project.' },
9
+ opencode: { type: 'boolean', description: 'Only OpenCode: .opencode/skills/ias (or ~/.config/opencode/skills/ias with --global).' },
10
+ claude: { type: 'boolean', description: 'Only Claude Code: .claude/skills/ias (or ~/.claude/skills/ias with --global).' },
11
+ codex: { type: 'boolean', description: 'Only Codex: .agents/skills/ias (or ~/.agents/skills/ias with --global).' },
12
+ 'dry-run': { type: 'boolean', description: 'Print what it would write, and write nothing.' },
13
+ };
14
+
15
+ const run = (force) => ({ values, cwd, env, output }) => installSkill(
16
+ { global: values.global, opencode: values.opencode, claude: values.claude, codex: values.codex, force, dryRun: values['dry-run'], dir: resolve(cwd, values.project ?? '.'), env },
17
+ output,
18
+ );
19
+
20
+ export default {
21
+ name: 'skill',
22
+ summary: 'Install the agent skill of IAS from the CLI, offline (OpenCode, Claude Code and Codex)',
23
+ description: [
24
+ 'The skill is the Markdown of .claude/skills/ias: SKILL.md and references/*.md. It goes to .opencode/skills/ias, .claude/skills/ias and .agents/skills/ias of the project (or ~/.config/opencode/skills/ias, ~/.claude/skills/ias and ~/.agents/skills/ias with --global); --opencode, --claude or --codex restrict it to one or more of them.',
25
+ 'Without --force, an existing file with other contents stops it before anything is written and lists the files; identical files are left as they are. install is idempotent, and update is install --force.',
26
+ 'ias init is the other way: it adds the catalog https://ias.imagina.build/skills/ to opencode.jsonc, so the agent reads the skill from the server without copying files, and installs the files for Claude Code and Codex.',
27
+ ].join('\n'),
28
+ subcommands: [
29
+ {
30
+ name: 'install',
31
+ summary: 'Write the skill in the project (or --global) from the copy this package carries',
32
+ options: {
33
+ ...OPTIONS,
34
+ force: { type: 'boolean', description: 'Replace files that exist with other contents.' },
35
+ },
36
+ run: run(false),
37
+ },
38
+ {
39
+ name: 'update',
40
+ summary: 'Install the skill again, replacing the files (ias skill install --force)',
41
+ options: OPTIONS,
42
+ run: run(true),
43
+ },
44
+ ],
45
+ };
package/src/setup.mjs CHANGED
@@ -14,6 +14,7 @@ import { createRunnerCode, provisionDistribution, provisionOta } from './provisi
14
14
  import { probe as systemProbe } from './process.mjs';
15
15
  import { SDK_PACKAGE, sdkState } from './sdk.mjs';
16
16
  import { SERVICES, inferServices } from './setup-state.mjs';
17
+ import { skillHint } from './skill.mjs';
17
18
  import { diagnose } from './commands/doctor/index.mjs';
18
19
  import { init } from './commands/init/index.mjs';
19
20
  import { expoModulesFor, installSdk } from './commands/sdk/index.mjs';
@@ -146,7 +147,7 @@ function buildSteps(ctx) {
146
147
  const project = projectSafe(ctx.dir);
147
148
  if (project.error) throw project.error;
148
149
  if (project.config) return { changed: false };
149
- init(ctx.dir, { app: ctx.values.app, dryRun: ctx.values['dry-run'] === true }, ctx.output);
150
+ init(ctx.dir, { app: ctx.values.app, dryRun: ctx.values['dry-run'] === true, env: ctx.env }, ctx.output);
150
151
  ctx.config = projectSafe(ctx.dir).config;
151
152
  return { changed: true };
152
153
  },
@@ -654,6 +655,8 @@ export function printPlan(plan, ctx, output) {
654
655
  const waiting = plan.steps.find((step) => !step.optional && step.status !== STATUS.DONE);
655
656
  if (waiting) output.log(`\nnext: ias setup next (${waiting.id}${waiting.kind === 'manual' ? ': a person' : ''})`);
656
657
  else output.log('\nthe checklist is complete: ias build, ias update publish and ias push are next.');
658
+ const hint = skillHint(ctx.dir, ctx.env);
659
+ if (hint) output.log(`\n${output.prefix}: ${hint}`);
657
660
  }
658
661
 
659
662
 
@@ -0,0 +1,10 @@
1
+ // Generated by packages/cli/scripts/sync-skill.mjs from .claude/skills/ias. Do not edit by hand.
2
+ // Run `npm run skill:sync` in packages/cli after changing the skill; packages/cli/test/skill.test.mjs checks it.
3
+ export const SKILL_FILES = {
4
+ "SKILL.md": "---\nname: ias\ndescription: >-\n Use when working with Imagina Application Services (IAS), the self-hosted alternative to Expo EAS: the `ias` CLI,\n `ias.json`, the `@imagina-build/ias` SDK (`ias.sync`, `ias.push` and its Expo config plugin), signed OTA updates,\n internal distribution, builds, store submissions, TestFlight, Google Play staged rollouts, the sealed credentials\n vault, the signed approval policy, push notifications, or moving an app from EAS. Also use when onboarding a new Expo\n app to IAS, when deciding which IAS service an app should use, or when a task mentions `ias`, `ias.json`,\n `@imagina-build/ias` or `ias.imagina.build`.\n---\n\n# Imagina Application Services (IAS)\n\nIAS is Imagina's platform for its mobile apps (Android, iOS, and above all Expo and React Native). It gives the same\nservices as Expo EAS, but on Imagina's own servers and machines, so nobody outside the team can sign a build or an\nupdate in the team's name. Everything revolves around one entity: **the application** (its `code`, such as\n`jscheduler`).\n\nThe public server is `https://ias.imagina.build` (the management panel lives at `/admin`). `sync.domibi.com` is the old\ndomain and still answers the same API while apps migrate; either origin works.\n\n## What IAS offers\n\nEvery service is optional per application; an app enables only the ones it uses.\n\n| Service | What it does | The EAS it replaces | Status |\n| --- | --- | --- | --- |\n| **Sync** | Offline-first data by spaces, with permissions, invitations and end-to-end encryption. Last Write Wins per row. | — (Firebase, Supabase) | In production |\n| **OTA updates** | An expo-updates (protocol 1) server with manifests signed on the Mac. Channels, rollouts, bsdiff patches. | EAS Update | In production |\n| **Internal distribution** | Install pages for ad hoc IPAs and APKs, with a QR and a stable link. | Internal distribution | In production |\n| **Credentials and secrets** | Certificates, profiles, keystores, API keys and per-environment variables, with expiries, sealed in a vault. | EAS Credentials, `eas env` | Phase 1 |\n| **Builds and store submission** | Builds signed on Imagina's own runners, approved with a passkey, submitted to the stores, Google Play rollouts (staged, halt, resume, complete) with a per-track policy, and distributed to TestFlight groups (Beta App Review is a separate, explicit approval). | EAS Build, EAS Submit, EAS Workflows' `testflight` job | Phase 2 |\n| **Push notifications** | APNs and FCM directly, to devices, users, spaces and segments, plus silent sync notices. | Expo Push Service | Phase 3 |\n| **CLI and SDK** | `ias` for the project; `@imagina-build/ias` for the app. | eas-cli, expo-updates, expo-notifications | CLI 0.3.x, SDK 0.2.x |\n\nDecision guide:\n\n- **A new Expo app in Imagina**: use Sync for its data, OTA for JavaScript updates, internal distribution for testers,\n and push for notifications. Start with `ias setup`.\n- **An app that already uses EAS**: do not rip EAS out in one go. Run `ias setup` to see the checklist, then\n `ias migrate eas` for profiles, credentials, build numbers and variables. The signing keys move into the vault.\n- **An app with only OTA needs**: `--services updates` in `ias setup` keeps the checklist short.\n- **An app with its own sync client** (like jScheduler before its pilot): `ias.push` can run on its own, with\n `session` and `onSyncNotice` hooks. See `references/sdk.md`.\n- **Never** propose IAS for a web app, a backend service or a non-Expo mobile stack without checking with the team.\n\n## Golden rules\n\n1. **`ias.json` goes in git and holds no secrets.** Tokens and private keys are `op://` references to 1Password, or\n environment variables. The CLI refuses a token or a PEM written in the file. If one was committed, rotate it.\n2. **The OTA private key never leaves the Mac.** The server stores only the public certificate; every manifest is\n signed locally with the key in 1Password. A build rejects anything signed with another key.\n3. **Entering IAS is a passkey.** `ias login` uses the device flow (RFC 8628): the terminal shows a code and the panel\n asks a member to approve it with a passkey. There is no password.\n4. **Credentials are sealed on the computer**, for the team's recovery key and the runners of the signed list. The\n server cannot open an envelope or sign one. `ias vault status` compares the server's keys with 1Password's; if they\n differ, stop and do not seal anything. The panel only reads the inventory (metadata and warnings): generating,\n importing, rotating and revoking are CLI actions, and the panel just copies the exact command.\n5. **Builds are made from a clean commit.** `ias build` refuses a dirty git tree: what is not in the commit is not in\n the build. A store build or submission waits for an owner's passkey approval.\n6. **A build carries its native configuration.** Changing `updates`, the push entitlements or the plugin needs a new\n build; an OTA update only carries JavaScript.\n7. **Nothing prints a secret.** No command prints a password, a private key, a token, or a non-`EXPO_PUBLIC_` variable.\n Do not add logging that does.\n8. **The repository is the source of truth.** This skill is a map; the contracts are in `docs/technical/` and the user\n guide in `docs/guide/` (Spanish).\n9. **A store upload needs the store identity.** The public App Store Connect API cannot create the app record and the\n Play API cannot create the app: `ias apps store <code>` reports the state, guides the 30-second form and\n `--check` saves the Apple ID; the runner refuses an upload without the app record (`asc_app_missing`) and a Play\n package that does not exist (`play_app_missing`).\n10. **The approval policy is signed locally and gates store jobs.** `ias policy init` writes `ias.policy.json` and\n `ias policy sign` signs it with the team key of 1Password; the server stores it, verifies the signature, serves it\n and applies it: a store job no rule covers is `409 policy_unmatched`, a rule with `count: 2` waits for two distinct\n members, an owner outside `require.members` cannot approve and a rotated policy expires waiting jobs with\n `policy_changed`. The runner verifies the signed policy and the N assertions before opening anything. A team\n without a policy keeps the legacy one-owner approval; `ias setup` never asks for one. There is no delegation\n (`ias-approvers-2`), no batch approval (one passkey over several jobs) and no cut-off date that retires the legacy\n mode; there is no policy for OTA updates either.\n\n## The first move: `ias setup`\n\n`ias setup` is the onboarding checklist. It is **gated**: each step is checked against the files and the server, and a\nstep cannot run while the one it needs is not done.\n\n```bash\nias setup # where the app is: every step, its state and what is next\nias setup next # do the next step, or say exactly what a person must do\nias setup next --all # run every automatic step until one needs a person\n\nias setup next --build preview # the first build, explicit (cost)\nias setup next --publish # the first OTA update, after a succeeded build\nias setup next --item \"op://IAS/IAS vault <host>\" # another 1Password item for the vault step\nias setup check # re-check everything; exits 1 while the checklist is incomplete\nias setup check sdk # one step\nias setup services updates,distribution,push\nias setup reset # forget the local state (it holds no secret)\n```\n\nSteps, in order:\n\n| Step | Kind | What it does / what a person does |\n| --- | --- | --- |\n| `environment` | check | Node 22, git, `op`; Xcode and the Android SDK are warnings until a local build |\n| `project` | auto | Writes `ias.json` with `ias init` from `app.json`/`eas.json` |\n| `login` | auto | `ias login` (passkey); on CI `IAS_TOKEN` makes it done |\n| `application` | person or auto | With `--create-app`, an owner creates it from `app.json` and `ias.json`; otherwise a person does it in the panel (`/admin/applications`) |\n| `services` | auto | Turns push on and writes its install key; makes the OTA and distribution projects (an owner's personal token), with the OTA signing key and both publishing tokens in 1Password |\n| `stores` | optional | The bundle id, the app record IAS saved and the Android package; if the app record is missing it prints the filled ASC form and `ias apps store <code> --check`. It never calls Apple: only the application on the server |\n| `sdk` | auto | `ias sdk install` plus the Expo modules of the services (`expo-updates`, `expo-notifications`, `expo-task-manager`) |\n| `vault` | auto | `ias vault init` (an owner; a passkey approves the public keys) |\n| `credentials` | person | Generates the Android upload key (`ias credentials generate android_upload_keystore`), the iOS certificate and App Store profile (`ias credentials generate ios`), and imports material made outside (`ias credentials import`) |\n| `signing` | optional check | The iOS distribution certificate and the App Store profile the server knows about: missing certificate, expired, retired or no App Store profile. Read-only and offline (the server's credential lists only); the fix is the single `ias credentials generate ios`, which checks Apple and reuses what is valid. `--rotate` and `--no-profiles` are the advanced paths; a change of the certificate's SHA-1 needs an owner's passkey. With several usable App Store profiles it never picks one: it lists them and `--profile <uuid\\|name>` chooses. `--sync-capabilities` (only with the flag, and it invalidates every profile of the App ID, the ad hoc one too) enables what `app.json` › `expo › ios › entitlements` asks for; `--delete-previous` removes in Apple the App Store profile the new one replaces |\n| `runner` | auto + person | `ias setup` makes the one-time code (`POST /runners/codes`); a person runs `ias-runner register` on that Mac and `ias vault grant` here |\n| `build` | person / opt-in | `ias setup next --build preview` or `ias build --profile preview --platform all` (or `--local`) |\n| `testflight` | optional | After an iOS store build is in App Store Connect: `ias testflight <build>` distributes it to the groups of `ias.json`; `ias testflight review` is a separate approval. Never run by `--all` |\n| `updates` | check / opt-in | The project, the signing key and the token are in place; `ias setup next --publish` or `ias update publish` signs the first update |\n| `distribution` | check | The project and stable link exist; the first internal build publishes itself |\n| `push` | check | Push on, `push.installKey` in `ias.json`, APNs/FCM credentials loaded |\n| `ci` | optional | A robot token in `IAS_TOKEN` for CI |\n\n`policy` is deliberately not a step: `ias setup` and `--all` never create or sign an approval policy. Without one the\nplatform behaves exactly as before.\n\n`--json` returns the whole checklist (`steps[].status` is `done`, `ready`, `locked`, `optional`), which is what an\nagent should read instead of scraping the text.\n\n## CLI essentials\n\nInstall: `npm install --global @imagina-build/ias-cli` (Node 22+). From a clone:\n`npm install --global ./packages/cli`. The full, generated command reference is\n[`references/cli-reference.md`](references/cli-reference.md).\n\n```bash\nias init # ias.json from app.json, eas.json, ota.config.json, dist.config.json\nias doctor # this computer and the app, without opening a secret\nias apps --create # the application, its identity and services (owner, personal token)\nias apps <code> --services … # replace its services; a service with pieces cannot be turned off\nias apps --create --dry-run # the plan of the new application, without creating it\nias apps store <code> # the App Store Connect identity: bundle id, app record, the next command\nias apps store <code> --check # read Apple and save the Apple ID in the application (vault key, on this Mac)\nias update init # OTA project + RSA key + certificate + token, in 1Password and ias.json\nias dist init # distribution project + token, in 1Password and ias.json\nias credentials generate ios # the iOS distribution certificate + App Store profile (reuses what is valid)\nias credentials generate ios --profile <uuid> # reuse that App Store profile when Apple has several usable ones\nias credentials generate ios --sync-capabilities # enable what app.json entitlements ask for (invalidates every profile of the App ID)\nias credentials generate ios --delete-previous # delete in Apple the App Store profile the new one replaces\nias credentials generate ios --rotate # a new certificate; replaces the credential with an owner's passkey\nias credentials generate ios --no-profiles # only the certificate; ias credentials generate ios finishes the profile\nias credentials revoke <id> # compromised certificate: Apple DELETE + retire, with an owner's passkey\nias credentials generate android_upload_keystore # PKCS12 + passwords in 1Password, sealed in the vault\nias credentials import # material made outside: a .p12, a profile, a keystore, a key\nias tokens create # a robot token for CI, kept in 1Password (never printed)\nias runners code # a one-time code to register a runner\nias sdk install --modules # the SDK, its plugin and the Expo modules the services need\nias login / whoami / apps # identity and the applications the token sees\nias vault init|status # the team's keys; compares server and 1Password\nias policy init # ias.policy.json, equivalent to today (one * rule, one approval); never signs by itself\nias policy check # shape, members, version, date and coverage against the server; writes nothing\nias policy sign # signs with the 1Password key and asks an owner for a passkey before storing it\nias policy show|status|diff # the policy of the server, verified, and how it compares with the file (--item for another 1Password item)\nias policy simulate [file] --platform ios --profile production --type build # the rule that would decide a job (--file still works)\nias credentials import|list # sealed signing material\nias credentials rotate|retire|revoke # next version, stop using it, revoke in Apple (owner, passkey)\nias devices list|sync # testers' iPhones/iPads: the ad hoc profile and a fresh signature of the internal build\nias devices sync --dry-run # the devices, the profile and the resign it would do: no Apple call, no vault write, no job\nias env list|set|pull # per-environment variables (EXPO_PUBLIC_* always plain)\nias build --profile preview # a build from the commit, followed to the end\nias builds list|logs|download\nias dist upload|list # internal IPA/APK, install page and stable link\nias update publish|check|list # signed OTA updates; check --channel <channel> checks that channel (the default one without it)\nias push send|devices # notifications\nias submit <build> # to App Store Connect or Google Play, as a draft (or inProgress/completed with a policy)\n # --changes-not-sent-for-review: Play applies it but a person sends it for review\nias releases list # read-only: what each Google Play track serves and the next step\nias releases view <track> --refresh # read Play again (no passkey) and wait\n # the server also re-reads Play every 15 minutes while a release is in review (read-only, no passkey)\nias releases policy <track> # the track's policy; an owner sets it (staged/full, ceiling, step, wait)\nias rollout <track> # no flags: read the release; --percent/--halt/--resume/--complete moves it\nias testflight <build> # iOS: distribute the build in App Store Connect to TestFlight groups\nias testflight distribute <build> # the same, spelled out\nias testflight status <build> # read what Apple says: processing, groups, notes, Beta App Review\nias testflight review <build> # a separate approval: send the build to Beta App Review\nias migrate eas # profile, credentials, numbers, env, checklist\n```\n\nEvery command takes `--project <folder>` and `--json`. Exit codes: `0` worked, `1` failed, `2` the command line did\nnot parse.\n\n`--dry-run` prints exactly what a mutating command would do and changes nothing (no API write, no 1Password write, no\nvault write, no Apple call, no runner job); it exits 0, wins over `--yes` and, with `--json`, returns a document that\nstarts with `{ \"dry_run\": true, … }`. It is there for `ias apps --create`, `ias setup`, `ias credentials retire`,\n`ias tokens revoke`, `ias vault init|approvers|grant|revoke`, `ias webhooks create|delete`, `ias github link`,\n`ias devices sync` and the commands that already had it (`update`, `dist`, `push send`, `testflight`, `rollout`,\n`credentials generate|import|rotate|revoke`, `migrate`). `ias credentials retire` and `ias tokens revoke` take an id or\na name; two with the same name are never picked silently, they are listed and the id is asked for.\n\nAuthorization order for publishing: `OTA_TOKEN`/`DIST_TOKEN`, `IAS_TOKEN`, the token in `ias.json`, then the one from\n`ias login`. The management API does not accept `ota_` or `dist_` tokens: use a personal token or an application robot\ntoken (`iasr_…`).\n\n## SDK essentials\n\n`@imagina-build/ias` is TypeScript with no runtime dependencies, runs on Hermes and Node 22, and does not ship native\nmodules. Entries:\n\n| Import | What it brings | Needs |\n| --- | --- | --- |\n| `@imagina-build/ias` | `ias`, `ias.sync`, `ias.push`, typed APIs and types | nothing |\n| `@imagina-build/ias/sqlite` | `openSqliteStore`, the sync store on SQLite | `expo-sqlite` |\n| `@imagina-build/ias/push` | `expoPushDevice`, `registerPushTask`, `hideSyncNotices` | `expo-notifications`, `expo-task-manager` |\n| `@imagina-build/ias/plugin` (or the package in `plugins`) | The Expo config plugin | nothing |\n\nMinimum integration:\n\n```ts\nimport Constants from 'expo-constants';\nimport { ias, iasAppConfig, type Session } from '@imagina-build/ias';\nimport { expoPushDevice } from '@imagina-build/ias/push';\nimport { openSqliteStore } from '@imagina-build/ias/sqlite';\n\nconst config = iasAppConfig(Constants.expoConfig?.extra);\n\nias.configure({\n server: config.server,\n sync: { application: config.app, mode: 'spaces', store: await openSqliteStore('app.db'), session: secure<Session>('ias-session') },\n push: { device: expoPushDevice(), installKey: config.installKey, state: secure('ias-push') },\n});\n\nawait ias.sync.start({ intervalMs: 5 * 60_000 });\nvoid ias.push.start();\n```\n\nThe plugin reads `ias.json` and writes `updates.url`, the certificate and keyid, `aps-environment`, the background\nmode and `extra.ias` into the native configuration. That means a **new build** after changing it. The full recipe,\nincluding the push task, encryption hooks and the API groups, is in [`references/sdk.md`](references/sdk.md) and\n`packages/sdk/README.md` (the authoritative, long version).\n\n## Repository map\n\n| Where | What |\n| --- | --- |\n| `packages/cli/README.md` | The CLI in depth: `ias.json`, auth, vault, builds, OTA, migration |\n| `packages/sdk/README.md` | The SDK in depth: sync modes, engine table, push, plugin |\n| `docs/guide/` | The user guide the server publishes at `/docs` (Spanish): quick start, CLI, SDK, builds, updates, distribution, push, security |\n| `docs/technical/API.md` | The app-facing contract (sync, OTA §36, distribution §37, push §38–39) |\n| `docs/technical/manage-api.md` | The management API the CLI and the panel use |\n| `docs/technical/runner-api.md` | Builds, runners, submissions, device sync |\n| `docs/technical/vault.md` | The `ias-vault-2` format: envelopes, keys, runners |\n| `docs/technical/SPEC.md`, `JSCHEDULER.md` | Sync design and a real app's model |\n| `docs/plans/` | Decisions and history (Spanish) |\n| `packages/runner/` | The runner that compiles builds (`ias-runner`) |\n\n## How a project gets this skill\n\nA new project gets the skill for all three harnesses; nobody copies files by hand. `ias init` does most of it:\n\n- **The catalog (OpenCode).** It adds `https://ias.imagina.build/skills/` to `skills` of the project's OpenCode\n configuration (`opencode.jsonc`, `opencode.json`, `.opencode/opencode.jsonc` or `.opencode/opencode.json`, the first\n that exists), and creates `opencode.jsonc` with `$schema` when there is none. A file with comments (JSONC) is never\n rewritten: it prints `\"skills\": [\"https://ias.imagina.build/skills/\"]` to add by hand. The catalog is why `ias init`\n does not copy `.opencode/skills/ias`.\n- **The files for Claude Code and Codex.** It installs the same files in `.claude/skills/ias` (Claude Code) and\n `.agents/skills/ias` (Codex). `--no-skill` skips the catalog and this.\n- **The files, offline, for any harness.** `ias skill install` writes `SKILL.md` and `references/*.md` from the CLI\n package itself to all three project folders: `.opencode/skills/ias`, `.claude/skills/ias` and `.agents/skills/ias`;\n with `--global`, to `~/.config/opencode/skills/ias`, `~/.claude/skills/ias` and `~/.agents/skills/ias`.\n `--opencode`, `--claude` and `--codex` restrict it to one or more. Without `--force` it stops, listing them, when a\n file exists with other contents; identical files are left alone. `ias skill update` is `install --force`, and\n `--dry-run` prints the plan. If `ias init` finds an older copy it does not overwrite it: it says so and prints\n `ias skill install --force`.\n- **Where each harness looks.** OpenCode: `.opencode/skills` or `~/.config/opencode/skills`, plus the catalog. Claude\n Code: `.claude/skills` or `~/.claude/skills`. Codex: `.agents/skills` or `~/.agents/skills` (`~/.codex/skills` is\n legacy and still read, but not written).\n\n`ias doctor` and `ias setup` hint at all of it, without blocking, when none of the locations has the skill.\n\n## Keeping this skill current\n\nThe CLI section is generated, not written by hand:\n\n```bash\nnode .claude/skills/ias/scripts/sync-cli-reference.mjs\n```\n\nRun it whenever a command, subcommand, option or summary changes. `packages/cli/test/skill.test.mjs` fails if\n`references/cli-reference.md` is stale, and it also checks that every top-level command appears and that the skill\nmentions `ias setup`. The copy the CLI carries for `ias skill install` is generated too:\n\n```bash\ncd packages/cli && npm run skill:sync\n```\n\nRun it after editing `SKILL.md` or any `references/*.md`, before `sync-cli-reference.mjs` if both changed; the same test\ncompares the copy with this folder byte for byte and fails when one was edited without the other. Update the prose\nreferences when a service changes phase or a new guide page appears.\n\n## More detail\n\n- [`references/platform.md`](references/platform.md): services, concepts, security model and EAS mapping.\n- [`references/sdk.md`](references/sdk.md): SDK integration, sync engine, push, plugin.\n- [`references/recipes.md`](references/recipes.md): common jobs, command by command.\n- [`references/troubleshooting.md`](references/troubleshooting.md): errors and their fixes.\n- [`references/cli-reference.md`](references/cli-reference.md): every command, generated.\n",
5
+ "references/cli-reference.md": "<!-- Generated by `.claude/skills/ias/scripts/sync-cli-reference.mjs` from the commands of packages/cli. Do not edit by hand. -->\n\n# `ias` command reference (0.3.3)\n\nRun `ias <command> --help` for the same help in the terminal. Every command takes `--project <folder>` (the current folder by default) and `--json` (one JSON document on stdout, progress on stderr).\n\n| Command | What it does |\n| --- | --- |\n| [`login`](#ias-login) | Authorize this computer with a passkey and keep its personal token |\n| [`logout`](#ias-logout) | Revoke the token of this computer and forget it |\n| [`whoami`](#ias-whoami) | Who the token belongs to, its team, where it comes from and its scopes |\n| [`apps`](#ias-apps) | The applications the token can see: the list, one of them, a new one (--create), its services or its store identity |\n| [`setup`](#ias-setup) | The checklist of a new app: what is done, what is next and what a person has to do |\n| [`init`](#ias-init) | Write ias.json from app.json, eas.json, ota.config.json and dist.config.json, without secrets |\n| [`doctor`](#ias-doctor) | Check Node, git, op, Xcode and its tools, the Android SDK, the app's configuration and the SDK @imagina-build/ias |\n| [`skill`](#ias-skill) | Install the agent skill of IAS from the CLI, offline (OpenCode, Claude Code and Codex) |\n| [`sdk`](#ias-sdk) | Put the SDK @imagina-build/ias in an Expo app |\n| [`update`](#ias-update) | Signed OTA updates (expo-updates): publish, go back, retire and check |\n| [`dist`](#ias-dist) | Internal builds on the install pages: upload an ad hoc IPA or an APK, list, retire and restore |\n| [`credentials`](#ias-credentials) | The signing credentials in the sealed vault: generate, import, list, show, rotate, retire and revoke |\n| [`env`](#ias-env) | The variables of an application per environment: list, set, pull and delete |\n| [`vault`](#ias-vault) | The keys of the sealed vault, and what the team signs with them: the approvers and the runners |\n| [`policy`](#ias-policy) | The signed policy of who approves which build or submission, and how many |\n| [`tokens`](#ias-tokens) | The robot tokens of an application for CI: create one, list them and revoke one |\n| [`runners`](#ias-runners) | The runners of the team: a one-time code to register one |\n| [`build`](#ias-build) | Build the app on a runner of IAS (or on this Mac with --local), from the commit of a clean working tree |\n| [`builds`](#ias-builds) | The builds of the application on IAS: list, view, logs, cancel and download |\n| [`devices`](#ias-devices) | The testers' iPhones and iPads: list them, and register the approved ones in Apple and sign again |\n| [`github`](#ias-github) | Link the GitHub repository of an application, so its tags, branches and pull requests start workflows |\n| [`migrate`](#ias-migrate) | Leave another service for IAS, step by step: eas |\n| [`push`](#ias-push) | Push notifications: send, the history, the devices and the APNs and FCM credentials |\n| [`releases`](#ias-releases) | The releases of Google Play: what each track serves, its history and its policy |\n| [`rollout`](#ias-rollout) | Move the release of a Google Play track: raise its percentage, halt, resume or complete it |\n| [`submit`](#ias-submit) | Send a store build to App Store Connect or Google Play, after an owner approves it (a draft by default) |\n| [`teams`](#ias-teams) | The teams you are in, and which one the token is of |\n| [`testflight`](#ias-testflight) | Distribute a build that is in App Store Connect to TestFlight groups, or send it to Beta App Review |\n| [`usage`](#ias-usage) | What the team used in a month, with its limits |\n| [`version`](#ias-version) | The build numbers the server keeps for each platform (autoIncrement takes the next) |\n| [`webhooks`](#ias-webhooks) | The outgoing webhooks of an application: list, create, delete and test them |\n| [`workflows`](#ias-workflows) | The workflows of ias.json that GitHub starts: list them and run one by hand |\n\n## `ias login`\n\nAuthorize this computer with a passkey and keep its personal token\n\nShows a code and opens the panel (Authorize the CLI), where a member of the team approves it with a passkey. The token (iasp_…, 90 days) goes to the macOS keychain, or to a file with mode 600 ($XDG_CONFIG_HOME/ias/tokens.json) elsewhere; IAS_TOKEN_STORE (keychain, file, none) chooses; none is the default on CI, where IAS_TOKEN holds a robot token. A token is of one team: --team preselects it in the panel, where you choose among yours (ias teams lists them); logging in for another team replaces the token of this server. The server is IAS_SERVER, server of ias.json or ias.imagina.build.\n\n| Option | What it does |\n| --- | --- |\n| `--name \"…\"` | The name the panel shows for this computer (its name by default). |\n| `--team <code>` | The code of the team the token is for (the panel asks if you have several and it is not given). |\n| `--no-browser` | Do not open the browser: print the link only. |\n\n## `ias logout`\n\nRevoke the token of this computer and forget it\n\n_No options of its own._\n\n## `ias whoami`\n\nWho the token belongs to, its team, where it comes from and its scopes\n\n_No options of its own._\n\n## `ias apps`\n\nThe applications the token can see: the list, one of them, a new one (--create), its services or its store identity\n\nWithout arguments, the list. With a code, one in detail. --create makes one (an owner with a personal token) with its identity and services; --services replaces the services of one (a service with pieces cannot be turned off). ias apps store <code> shows and settles its identity in App Store Connect (see ias apps store --help).\n\n| Subcommand | What it does |\n| --- | --- |\n| `store` | The identity of the app in App Store Connect: bundle id, app record and the next step |\n\nWithout a subcommand:\n\n| Argument | What it is |\n| --- | --- |\n| `<code>` | The code of one application, to see it in detail or to change its services. |\n\n| Option | What it does |\n| --- | --- |\n| `--create` | Make a new application with --code, --name and the identity flags; an owner with a personal token. |\n| `--code <code>` | The code of the new application, or of the one whose services change. |\n| `--name \"…\"` | The name in the panel (the code by default). |\n| `--ios-bundle-id <id>` | The bundle id of iOS, such as com.imagina.jscheduler. |\n| `--android-package <id>` | The package of Android, such as com.imagina.jscheduler. |\n| `--apple-team-id <id>` | The Apple team id (10 letters and digits). |\n| `--services <list>` | The services, comma-separated: credentials, builds, distribution, updates, sync, push. |\n| `--dry-run` | --create: say what it would create and create nothing (the same plan as ias setup --create-app --dry-run). |\n\n## `ias apps store`\n\nThe identity of the app in App Store Connect: bundle id, app record and the next step\n\nWithout flags it only reads: it shows the bundle id, the app record IAS knows (Apple ID and when it was checked) and what is missing, with the creation form already filled in (name, bundle id, language and SKU).\n--register-bundle-id registers the App ID in Apple only when it does not exist (idempotent, asks for confirmation unless --yes); --check asks Apple for the app record and saves its Apple ID with PATCH /apps/{code}; --set-asc-app-id <id> saves one by hand, without calling Apple. The App Store Connect key comes from the vault and is opened in memory on this Mac: it is never printed, and the server never sees it.\n\n| Argument | What it is |\n| --- | --- |\n| `<code>` | The code of the application (app of ias.json by default). |\n\n| Option | What it does |\n| --- | --- |\n| `--check` | Ask Apple for the app record and, when it exists, save its Apple ID in IAS. |\n| `--register-bundle-id` | Register the App ID in Apple when it does not exist yet (idempotent; asks for confirmation). |\n| `--set-asc-app-id <id>` | Save this Apple ID by hand, without calling Apple. |\n| `--yes` | Do not ask before registering the App ID in Apple. |\n| `--item <op://vault/item>` | The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json or op://IAS/IAS vault <host>). |\n| `--dry-run` | Register nothing in Apple and save nothing: say what it would do. |\n\n## `ias setup`\n\nThe checklist of a new app: what is done, what is next and what a person has to do\n\nThe steps, in order: environment, project (ias.json), login, application, services, stores, sdk, vault, credentials, signing, runner, build, testflight, updates, distribution, push and ci.\nias does the ones it can here: ias init, ias sdk install (with the Expo modules of the services), ias login and ias vault init. The rest are checked against the files and the server; a step says what a person has to do and the next step stays locked until it is done.\nWithout an action, it prints where the app is. next runs the next step, or says what a person has to do; with --build <profile> it makes the first build and with --publish the first OTA update, both explicit. check re-checks everything and exits with 1 while the checklist is not complete. services reads or sets the services of the app (updates, distribution, push); reset forgets the local state (it holds no secret).\nOptional steps (they never block and --all never runs them): stores, signing, testflight, push and ci; runner is optional with --local-builds, and a step with a service only counts when that service is on.\n\n| Argument | What it is |\n| --- | --- |\n| `<action>` | One of status, next, check, services, reset (status when left out). |\n| `<value>` | check: the step to check; services: updates, distribution, push or a comma-separated list. |\n\n| Option | What it does |\n| --- | --- |\n| `--create-app` | Create the application in IAS with the code of ias.json and the name, bundle id and package of app.json (an owner with a personal token). |\n| `--name \"…\"` | The name of the new application (the name of app.json by default). |\n| `--ios-bundle-id <id>` | The iOS bundle id of the new application (app.json by default). |\n| `--android-package <id>` | The Android package of the new application (app.json by default). |\n| `--apple-team-id <id>` | The Apple team id of the new application. |\n| `--app <code>` | The code of the application, when ias init cannot read it from app.json (the slug is only a guess). |\n| `--services <list>` | The services of the app: updates, distribution, push, or a comma-separated list. Saved for the next runs. |\n| `--team <code>` | The team the login is for, when the person is in several. |\n| `--local-builds` | The app is built on this Mac (ias build --local): the runner step is optional. |\n| `--item <op://vault/item>` | next: the 1Password item the vault step uses with ias vault init (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>). |\n| `--all` | next: run every automatic step until one needs a person. |\n| `--build <profile>` | next: build the app with this profile (preview, production…) and stop; an explicit opt-in. |\n| `--publish` | next: publish the first OTA update after the build (or after the last succeeded build). |\n| `--message \"…\"` | next --build or --publish: the message of the build or the update. |\n| `--wait` | next --build: download the artifacts when it succeeds (ias build --wait). |\n| `--open` | next: open the link of the step that needs a person. |\n| `--dry-run` | Do not write ias.json, install anything or change the configuration: say what would be done. |\n\n## `ias init`\n\nWrite ias.json from app.json, eas.json, ota.config.json and dist.config.json, without secrets\n\nThe server comes from updates.url of app.json, or dist.config.json, or is https://ias.imagina.build. The application's code, from updates.url, dist.config.json or --app.\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The code of the application (php artisan apps:create), if it cannot be read from updates.url or dist.config.json. |\n| `--force` | Write ias.json again if it exists. |\n| `--dry-run` | Print the ias.json it would write, and write nothing. |\n| `--no-skill` | Do not touch the agent skill: no catalog in opencode.jsonc and no .claude/skills/ias or .agents/skills/ias. |\n\n## `ias doctor`\n\nCheck Node, git, op, Xcode and its tools, the Android SDK, the app's configuration and the SDK @imagina-build/ias\n\nIt reads no secret. A fail means a command of ias will not work here; a warning, that something may be needed later.\n\n_No options of its own._\n\n## `ias skill`\n\nInstall the agent skill of IAS from the CLI, offline (OpenCode, Claude Code and Codex)\n\nThe skill is the Markdown of .claude/skills/ias: SKILL.md and references/*.md. It goes to .opencode/skills/ias, .claude/skills/ias and .agents/skills/ias of the project (or ~/.config/opencode/skills/ias, ~/.claude/skills/ias and ~/.agents/skills/ias with --global); --opencode, --claude or --codex restrict it to one or more of them.\nWithout --force, an existing file with other contents stops it before anything is written and lists the files; identical files are left as they are. install is idempotent, and update is install --force.\nias init is the other way: it adds the catalog https://ias.imagina.build/skills/ to opencode.jsonc, so the agent reads the skill from the server without copying files, and installs the files for Claude Code and Codex.\n\n| Subcommand | What it does |\n| --- | --- |\n| `install` | Write the skill in the project (or --global) from the copy this package carries |\n| `update` | Install the skill again, replacing the files (ias skill install --force) |\n\n## `ias skill install`\n\nWrite the skill in the project (or --global) from the copy this package carries\n\n| Option | What it does |\n| --- | --- |\n| `--global` | Install in ~/.config/opencode/skills/ias, ~/.claude/skills/ias and ~/.agents/skills/ias instead of the project. |\n| `--opencode` | Only OpenCode: .opencode/skills/ias (or ~/.config/opencode/skills/ias with --global). |\n| `--claude` | Only Claude Code: .claude/skills/ias (or ~/.claude/skills/ias with --global). |\n| `--codex` | Only Codex: .agents/skills/ias (or ~/.agents/skills/ias with --global). |\n| `--dry-run` | Print what it would write, and write nothing. |\n| `--force` | Replace files that exist with other contents. |\n\n## `ias skill update`\n\nInstall the skill again, replacing the files (ias skill install --force)\n\n| Option | What it does |\n| --- | --- |\n| `--global` | Install in ~/.config/opencode/skills/ias, ~/.claude/skills/ias and ~/.agents/skills/ias instead of the project. |\n| `--opencode` | Only OpenCode: .opencode/skills/ias (or ~/.config/opencode/skills/ias with --global). |\n| `--claude` | Only Claude Code: .claude/skills/ias (or ~/.claude/skills/ias with --global). |\n| `--codex` | Only Codex: .agents/skills/ias (or ~/.agents/skills/ias with --global). |\n| `--dry-run` | Print what it would write, and write nothing. |\n\n## `ias sdk`\n\nPut the SDK @imagina-build/ias in an Expo app\n\n| Subcommand | What it does |\n| --- | --- |\n| `install` | Install @imagina-build/ias with the app's package manager and add its config plugin to app.json |\n\n## `ias sdk install`\n\nInstall @imagina-build/ias with the app's package manager and add its config plugin to app.json\n\nThe package manager is the one of packageManager in package.json or of the lockfile (npm, yarn, pnpm or bun); npm without either.\n@imagina-build/ias comes from --from, from IAS_SDK_SOURCE, from the packages/sdk of the repository when ias runs from a clone, or from npm as @imagina-build/ias@^<version of ias>.\nWith app.config.js (or .ts), ias does not touch it and says what to add. Then it lists what is left: expo-updates, expo-notifications if the app uses push, and a prebuild or a new build.\n\n| Option | What it does |\n| --- | --- |\n| `--from <source>` | Where @imagina-build/ias comes from: a .tgz of a release (a file or its URL), a folder, or a version of the registry. |\n| `--modules` | Also install the Expo modules the services of ias.json need (expo-updates, expo-notifications and expo-task-manager) with npx expo install. |\n| `--dry-run` | Say what it would run and change, and change nothing. |\n\n## `ias update`\n\nSigned OTA updates (expo-updates): publish, go back, retire and check\n\nSigns on this Mac with the key of 1Password (updates.signing.privateKey, or updates.channels.<channel>.privateKey for another channel) and publishes to the server of updates.url in app.json. The token is the OTA project's (ota_…): IAS_TOKEN or updates.token.\n\n| Subcommand | What it does |\n| --- | --- |\n| `init` | Make the OTA project, its signing key and its publishing token, and write them to ias.json |\n| `publish` | Publish a commit (HEAD by default) for both platforms or one |\n| `republish` | Publish the commit of an earlier group again, with a new id and createdAt: the way back |\n| `rollback-to-embedded, rollback` | Publish a signed directive: the phones go back to the JavaScript of their build |\n| `promote` | Publish an earlier group in another channel, signed again on this Mac with that channel's key |\n| `rollout` | Change the rollout of a group: a share from 1 to 99, pause, or complete |\n| `channels` | The channels of the project, each with its keyid and certificate, and whether this Mac signs for it |\n| `retire` | Stop serving a group: the emergency brake (also in the panel) |\n| `restore` | Serve a retired group again |\n| `list` | The last groups, and the runtimes the phones ask for |\n| `check` | What a phone with the app's runtime gets now, checked end to end |\n\n## `ias update init`\n\nMake the OTA project, its signing key and its publishing token, and write them to ias.json\n\nMakes an RSA key and a self-signed certificate on this Mac, creates the OTA project on the server with the certificate and the keyid, and keeps the private key and the publishing token in 1Password. It writes their op:// references in ias.json and prints the manifest URL. It never prints a key or a token. It needs an owner's personal token (ias login) and the 1Password CLI; a project that already exists with the same certificate is left as it is.\n\n| Option | What it does |\n| --- | --- |\n| `--name \"…\"` | The name of the project in the panel (the app name by default). |\n| `--keyid <keyid>` | The keyid the builds carry (the app code + -ota-1 by default). |\n| `--item op://vault/item` | The 1Password item for the key and the token (op://IAS/<app> OTA by default). |\n| `--dry-run` | Say what it would make and change, and make nothing. |\n\n## `ias update publish`\n\nPublish a commit (HEAD by default) for both platforms or one\n\n| Option | What it does |\n| --- | --- |\n| `--message \"…\"` | What the update changes; the people who publish read it in list and in the panel. |\n| `--ref <commit>` | The commit to publish (HEAD by default). Only what is committed is published. |\n| `--platform ios|android` | Only one platform (both by default). |\n| `--channel <channel>` | The channel (the project's default one by default), signed with its own key: updates.channels.<channel> of ias.json, or app.json's. |\n| `--rollout <1-100>` | Start with this share of the installations (all by default); ias update rollout raises, pauses or completes it. |\n| `--dry-run` | Everything but publishing: the server checks it all and keeps nothing. |\n\n## `ias update republish`\n\nPublish the commit of an earlier group again, with a new id and createdAt: the way back\n\n| Argument | What it is |\n| --- | --- |\n| `<group>` | The group, from ias update list. |\n\n| Option | What it does |\n| --- | --- |\n| `--message \"…\"` | What the update changes; the people who publish read it in list and in the panel. |\n| `--platform ios|android` | Only one platform (both by default). |\n| `--channel <channel>` | The channel (the project's default one by default), signed with its own key: updates.channels.<channel> of ias.json, or app.json's. |\n| `--rollout <1-100>` | Start with this share of the installations (all by default); ias update rollout raises, pauses or completes it. |\n| `--dry-run` | Everything but publishing: the server checks it all and keeps nothing. |\n\n## `ias update rollback-to-embedded`\n\nPublish a signed directive: the phones go back to the JavaScript of their build\n\n| Option | What it does |\n| --- | --- |\n| `--message \"…\"` | What the update changes; the people who publish read it in list and in the panel. |\n| `--platform ios|android` | Only one platform (both by default). |\n| `--channel <channel>` | The channel (the project's default one by default), signed with its own key: updates.channels.<channel> of ias.json, or app.json's. |\n| `--rollout <1-100>` | Start with this share of the installations (all by default); ias update rollout raises, pauses or completes it. |\n| `--dry-run` | Everything but publishing: the server checks it all and keeps nothing. |\n\n## `ias update promote`\n\nPublish an earlier group in another channel, signed again on this Mac with that channel's key\n\n| Argument | What it is |\n| --- | --- |\n| `<group>` | The group, from ias update list --channel <its channel>. |\n\n| Option | What it does |\n| --- | --- |\n| `--to <channel>` | The channel it goes to, signed again with its key. |\n| `--message \"…\"` | What the update changes; the people who publish read it in list and in the panel. |\n| `--rollout <1-100>` | Start with this share of the installations (all by default); ias update rollout raises, pauses or completes it. |\n| `--dry-run` | Everything but publishing: the server checks it all and keeps nothing. |\n\n## `ias update rollout`\n\nChange the rollout of a group: a share from 1 to 99, pause, or complete\n\n| Argument | What it is |\n| --- | --- |\n| `<group>` | The group, from ias update list. |\n| `<percent|pause|complete>` | The share of installations (100 completes), pause (nobody new gets it), or complete (everyone does). |\n\n_No options of its own._\n\n## `ias update channels`\n\nThe channels of the project, each with its keyid and certificate, and whether this Mac signs for it\n\n_No options of its own._\n\n## `ias update retire`\n\nStop serving a group: the emergency brake (also in the panel)\n\n| Argument | What it is |\n| --- | --- |\n| `<group>` | The group, from ias update list. |\n\n_No options of its own._\n\n## `ias update restore`\n\nServe a retired group again\n\n| Argument | What it is |\n| --- | --- |\n| `<group>` | The group, from ias update list. |\n\n_No options of its own._\n\n## `ias update list`\n\nThe last groups, and the runtimes the phones ask for\n\n| Option | What it does |\n| --- | --- |\n| `--channel <channel>` | The channel (the project's default one by default), signed with its own key: updates.channels.<channel> of ias.json, or app.json's. |\n\n## `ias update check`\n\nWhat a phone with the app's runtime gets now, checked end to end\n\nAsks for the manifest as a phone would, verifies its signature and every file it points to, and needs no key and no token. Without --channel it checks the default channel of the project (the one of app.json); --channel asks as a build of that channel and checks the signature with its certificate in ias.json › updates.channels.\n\n| Option | What it does |\n| --- | --- |\n| `--platform ios|android` | Only one platform (both by default). |\n| `--channel <channel>` | The channel to check, as a build of it would (the default channel by default), signed with its certificate in ias.json. |\n\n## `ias dist`\n\nInternal builds on the install pages: upload an ad hoc IPA or an APK, list, retire and restore\n\nThe server and project come from ias.json (server, and distribution.project or app); the token is the distribution project's (dist_…): IAS_TOKEN or distribution.token. IPAs need the tools of macOS (unzip, plutil, security, sips); APKs, aapt2 of the Android SDK.\n\n| Subcommand | What it does |\n| --- | --- |\n| `init` | Make the distribution project and its publishing token, and write the token reference to ias.json |\n| `upload` | Upload a build and print its link and the stable one |\n| `list` | The stable link and the last 30 builds |\n| `retire` | Stop offering a build (also in the panel) |\n| `restore` | Offer a retired build again |\n\n## `ias dist init`\n\nMake the distribution project and its publishing token, and write the token reference to ias.json\n\nCreates the distribution project on the server and keeps its token in 1Password, with its op:// reference in distribution.token of ias.json. It prints the stable install link. It needs an owner's personal token (ias login) and the 1Password CLI; a project that already exists is left as it is.\n\n| Option | What it does |\n| --- | --- |\n| `--name \"…\"` | The name of the project in the panel (the app name by default). |\n| `--item op://vault/item` | The 1Password item for the token (op://IAS/<app> distribution by default). |\n| `--dry-run` | Say what it would make and change, and make nothing. |\n\n## `ias dist upload`\n\nUpload a build and print its link and the stable one\n\n| Argument | What it is |\n| --- | --- |\n| `<file>` | An ad hoc .ipa or an .apk (an .aab only installs through Google Play). |\n\n| Option | What it does |\n| --- | --- |\n| `--notes \"…\"` | What the build brings; the install page shows it. |\n\n## `ias dist list`\n\nThe stable link and the last 30 builds\n\n_No options of its own._\n\n## `ias dist retire`\n\nStop offering a build (also in the panel)\n\n| Argument | What it is |\n| --- | --- |\n| `<id>` | The id of the build, from ias dist list. |\n\n_No options of its own._\n\n## `ias dist restore`\n\nOffer a retired build again\n\n| Argument | What it is |\n| --- | --- |\n| `<id>` | The id of the build, from ias dist list. |\n\n_No options of its own._\n\n## `ias credentials`\n\nThe signing credentials in the sealed vault: generate, import, list, show, rotate, retire and revoke\n\nCredentials are sealed on this computer (ias-vault-2, docs/technical/vault.md) for the team's recovery key and the authorised runners, and signed by the team; the keys come from the 1Password item of ias vault init. Types: apple_distribution_certificate, apple_provisioning_profile, android_upload_keystore, apns_key, asc_api_key, google_service_account.\n\n| Subcommand | What it does |\n| --- | --- |\n| `generate` | Make signing material on this Mac: the Android upload key, the iOS certificate and the App Store profile |\n| `import` | Seal a credential and upload it, with its metadata |\n| `list` | The credentials of the application and the team, with their expiry |\n| `show` | One credential: its metadata, version, digest, who can open it and who signed it |\n| `rotate` | Replace the material of a credential with a new version |\n| `retire` | Stop using a credential (owner); it stays in the inventory |\n| `revoke` | Revoke the team's Apple distribution certificate in Apple and retire it (owner, with a passkey) |\n\n## `ias credentials generate`\n\nMake signing material on this Mac: the Android upload key, the iOS certificate and the App Store profile\n\nias credentials generate ios (the short path): reuses the team's distribution certificate and the App Store profile of the bundle id when they are valid, and makes what is missing — the RSA-2048 key, the CSR and the .p12 here, the certificate and the profile in App Store Connect — sealing them in the vault and keeping a recovery copy in 1Password. It never revokes anything and never picks among several certificates without --certificate. --rotate replaces the certificate with an owner's passkey approval and keeps the credential id; --no-profiles leaves the App Store profile for later.\nThe granular forms: android_upload_keystore makes a PKCS12 upload key with keytool; apple_distribution_certificate makes only the certificate; apple_provisioning_profile makes only the profile (app-store, or ad-hoc through ias devices sync). With several App Store profiles of the bundle id it never picks one: --profile names it. --sync-capabilities enables in the App ID what app.json › expo › ios › entitlements asks for (and Apple invalidates every profile of the App ID, the ad hoc one too). --delete-previous removes in Apple the App Store profile the new one replaces, after it is sealed and uploaded, and only with the flag.\nNothing prints a password. It needs openssl (macOS has it), a JDK for the keystore, a Team API key of App Store Connect with the Admin role (or App Manager with Certificates) and an owner of the team for the vault.\n\n| Argument | What it is |\n| --- | --- |\n| `<type>` | ios, apple_distribution_certificate, apple_provisioning_profile or android_upload_keystore. |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--alias <alias>` | android_upload_keystore: the key alias in the keystore (upload by default). |\n| `--item op://vault/item` | The 1Password item for the material: the keystore, or the certificate (.p12) and its password. Defaults to op://IAS/<app> Android upload or op://IAS/<team> Apple Distribution <serial>. |\n| `--name \"…\"` | The team name written in the CSR subject (the app name by default). |\n| `--force` | Replace the key and the item if they already exist; the old key stops working in a new upload. |\n| `--reuse` | Reuse the current distribution certificate, even when it expires within --if-expiring days. |\n| `--rotate` | Make a new distribution certificate even when the current one is valid (Apple's limit may refuse it; nothing is revoked automatically). |\n| `--certificate <sha1|id>` | Which distribution certificate of the team to use, when there are several (its SHA-1, its credential id or Apple's id). |\n| `--profile <uuid|name>` | apple_provisioning_profile and ios: which App Store profile of Apple to reuse, when there are several (its UUID or its exact name). Never guessed. |\n| `--sync-capabilities` | apple_provisioning_profile and ios: enable in the App ID the capabilities that app.json › expo › ios › entitlements asks for and Apple lacks, then make the profile again. Apple invalidates every profile of the App ID, the ad hoc one too: run ias devices sync --force after it. |\n| `--delete-previous` | apple_provisioning_profile and ios: after a new profile is sealed and uploaded, delete in Apple the App Store profile it replaces (only that one; never the ad hoc one, a dry run or another credential's). It asks first. |\n| `--type app-store|ad-hoc` | apple_provisioning_profile and ios: app-store (the default) or ad-hoc (which runs ias devices sync). |\n| `--bundle-id <id>` | The bundle id of the profile (the app's by default). |\n| `--team-id <id>` | The Apple team (the app's, or the key's, by default). |\n| `--if-expiring <days>` | Renew the certificate only when it expires within these days (30 by default; 0 never renews). |\n| `--no-profiles` | ios: make or reuse the certificate and leave the App Store profile for later; a store build needs it, so ias credentials generate ios finishes it. |\n| `--yes` | Do not ask before writing in Apple and the vault. |\n| `--dry-run` | Show the inventory and exactly what would be sealed, and make nothing. |\n\n## `ias credentials import`\n\nSeal a credential and upload it, with its metadata\n\nName the type and give its files (paths or op:// references) and passwords (op://…, env:NAME or -), or --eas with the folder of an EAS credentials.json. The type can be left out when the options say it. A credential already in the vault (the same certificate, profile or key) is skipped; ias credentials rotate replaces it.\n\n| Argument | What it is |\n| --- | --- |\n| `<type>` | One of apple_distribution_certificate, apple_provisioning_profile, android_upload_keystore, apns_key, asc_api_key, google_service_account. |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--p12 <file|op://…>` | apple_distribution_certificate: the .p12. |\n| `--password <op://…|env:NAME|->` | The password of the .p12 (never the password itself). |\n| `--profile <file|op://…>` | apple_provisioning_profile: the .mobileprovision. |\n| `--keystore <file|op://…>` | android_upload_keystore: the .jks or PKCS12 keystore. |\n| `--store-password <op://…|env:NAME|->` | The password of the keystore. |\n| `--key-alias <alias>` | The alias of the upload key in the keystore. |\n| `--key-password <op://…|env:NAME|->` | The password of the key (the keystore's by default). |\n| `--p8 <file|op://…>` | apns_key or asc_api_key: the .p8 (AuthKey_<id>.p8 names its key id). |\n| `--key-id <id>` | The key id of the .p8 (10 characters), if its file name does not say it. |\n| `--team-id <id>` | The Apple team of the .p8 (10 characters). |\n| `--issuer-id <uuid>` | asc_api_key: the issuer id of App Store Connect. |\n| `--service-account <file|op://…>` | google_service_account: the JSON key of the service account. |\n| `--eas <folder>` | The folder of an EAS credentials.json: imports its certificate, profile and keystore. |\n| `--name \"…\"` | The name the panel shows (from the material by default). |\n| `--force` | Import even if it is already in the vault or EAS's material has problems. |\n| `--dry-run` | Read, check and seal, but send nothing. |\n| `--item <op://vault/item>` | The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>). |\n\n## `ias credentials list`\n\nThe credentials of the application and the team, with their expiry\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--all` | Also the retired ones. |\n\n## `ias credentials show`\n\nOne credential: its metadata, version, digest, who can open it and who signed it\n\n| Argument | What it is |\n| --- | --- |\n| `<id>` | The id of the credential, from ias credentials list. |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--verify` | Check the signature of its envelope with the team's signing key of 1Password. |\n| `--item <op://vault/item>` | The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>). |\n\n## `ias credentials rotate`\n\nReplace the material of a credential with a new version\n\nTakes the options of import for the type of the credential, and seals the next version. When the credential is an apple_distribution_certificate and the new .p12 changes its SHA-1, it asks an owner for a passkey approval first: the server only accepts the new material with it.\n\n| Argument | What it is |\n| --- | --- |\n| `<id>` | The id of the credential, from ias credentials list. |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--p12 <file|op://…>` | apple_distribution_certificate: the .p12. |\n| `--password <op://…|env:NAME|->` | The password of the .p12 (never the password itself). |\n| `--profile <file|op://…>` | apple_provisioning_profile: the .mobileprovision. |\n| `--keystore <file|op://…>` | android_upload_keystore: the .jks or PKCS12 keystore. |\n| `--store-password <op://…|env:NAME|->` | The password of the keystore. |\n| `--key-alias <alias>` | The alias of the upload key in the keystore. |\n| `--key-password <op://…|env:NAME|->` | The password of the key (the keystore's by default). |\n| `--p8 <file|op://…>` | apns_key or asc_api_key: the .p8 (AuthKey_<id>.p8 names its key id). |\n| `--key-id <id>` | The key id of the .p8 (10 characters), if its file name does not say it. |\n| `--team-id <id>` | The Apple team of the .p8 (10 characters). |\n| `--issuer-id <uuid>` | asc_api_key: the issuer id of App Store Connect. |\n| `--service-account <file|op://…>` | google_service_account: the JSON key of the service account. |\n| `--name \"…\"` | A new name. |\n| `--dry-run` | Read, check and seal, but send nothing. |\n| `--item <op://vault/item>` | The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>). |\n\n## `ias credentials retire`\n\nStop using a credential (owner); it stays in the inventory\n\nTakes its id, or its name when only one credential has it: two with the same name are never picked silently, it lists them and asks for the id. --dry-run says what it would retire and changes nothing.\n\n| Argument | What it is |\n| --- | --- |\n| `<credential>` | The id of the credential (ias credentials list), or its name when only one has it. |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--dry-run` | Say what it would retire, and retire nothing. |\n\n## `ias credentials revoke`\n\nRevoke the team's Apple distribution certificate in Apple and retire it (owner, with a passkey)\n\nFor a compromised certificate, not for renewing: it shows the certificate Apple has, the provisioning profiles and applications that hold it and what happens to the builds, asks an owner to authorize it with a passkey and only then deletes it in App Store Connect (DELETE /v1/certificates) and retires the credential.\nA rejected or expired approval deletes nothing. A certificate Apple no longer has is still retired. The App Store profile of each affected application has to be made again with another certificate: ias credentials generate ios.\n\n| Argument | What it is |\n| --- | --- |\n| `<id>` | The id of the credential, from ias credentials list. |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--team-id <id>` | The Apple team of the certificate (the app's, or the App Store Connect key's, by default). |\n| `--yes` | Do not ask before revoking; the owner's approval with a passkey is still needed. |\n| `--dry-run` | Show the certificate, the profiles and the impact; ask nothing and change nothing. |\n| `--item <op://vault/item>` | The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>). |\n\n## `ias env`\n\nThe variables of an application per environment: list, set, pull and delete\n\nEXPO_PUBLIC_* are always plain: they end up in the bundle. Any other variable is secret unless --plain: it is sealed on this computer and its value never comes back.\n\n| Subcommand | What it does |\n| --- | --- |\n| `list` | The variables, with the values of the plain ones |\n| `set` | Create or replace a variable |\n| `pull` | Write the plain variables of an environment to .env.local, without the secrets |\n| `delete` | Delete a variable of an environment |\n\n## `ias env list`\n\nThe variables, with the values of the plain ones\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--environment <environment>` | development, preview or production (all three by default). |\n\n## `ias env set`\n\nCreate or replace a variable\n\nA plain value can be the second argument. A secret one comes with --from: op://… (1Password), env:NAME (a variable of this shell) or - (stdin), never on the command line.\n\n| Argument | What it is |\n| --- | --- |\n| `<name>` | The name: capitals, digits and underscores (EXPO_PUBLIC_… are always plain). |\n| `<value>` | The value of a plain variable. |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--environment <environment>` | development, preview or production. |\n| `--plain` | Keep it in plain (visible in the panel); EXPO_PUBLIC_* always are. |\n| `--secret` | Seal it (the default for any name but EXPO_PUBLIC_*). |\n| `--from <op://…|env:NAME|->` | Where the value is. |\n| `--item <op://vault/item>` | The 1Password item of the vault, to seal a secret. |\n\n## `ias env pull`\n\nWrite the plain variables of an environment to .env.local, without the secrets\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--environment <environment>` | development (by default), preview or production. |\n| `--file <file>` | Where to write (.env.local of the app by default). |\n| `--force` | Write over a file that ias env pull did not write. |\n\n## `ias env delete`\n\nDelete a variable of an environment\n\n| Argument | What it is |\n| --- | --- |\n| `<name>` | The name: capitals, digits and underscores (EXPO_PUBLIC_… are always plain). |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--environment <environment>` | development, preview or production. |\n\n## `ias vault`\n\nThe keys of the sealed vault, and what the team signs with them: the approvers and the runners\n\nThe recovery key opens every sealed credential and the signing key signs each envelope (docs/technical/vault.md), the passkeys that may approve a store build and the list of runners. Their private keys live only in 1Password; the server has the public ones.\n\n| Subcommand | What it does |\n| --- | --- |\n| `init` | Create the recovery and signing keys in 1Password and register them (owner, with a passkey) |\n| `status` | Compare the keys of the server with the ones of 1Password, and check the signed list of runners |\n| `approvers` | Sign the passkeys of the owners that may approve a store build (owner) |\n| `grant` | Let a registered runner open the team's credentials: the signed list and a wrap in each envelope |\n| `revoke` | Take a runner revoked in the panel out of the signed list and of every envelope |\n\n## `ias vault init`\n\nCreate the recovery and signing keys in 1Password and register them (owner, with a passkey)\n\nMakes both keys, creates the 1Password item with op (the private keys go through stdin and are never printed), and asks the server to register the public keys. A member approves it with a passkey in the panel, after checking that it shows the two kids printed here. If the item already exists from an attempt that was not approved, its keys are registered.\n\n| Option | What it does |\n| --- | --- |\n| `--item <op://vault/item>` | The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>). |\n| `--dry-run` | Show the plan and change nothing: no 1Password write, no signed document, no runner list and no envelope changed. |\n\n## `ias vault status`\n\nCompare the keys of the server with the ones of 1Password, and check the signed list of runners\n\nReads only the public keys of 1Password. Exits with 1 if a key does not match or the list of runners does not verify.\n\n| Option | What it does |\n| --- | --- |\n| `--item <op://vault/item>` | The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>). |\n\n## `ias vault approvers`\n\nSign the passkeys of the owners that may approve a store build (owner)\n\nReads the owners' passkeys from the server and shows each one with its member and fingerprint, marking what changes from the version the team signed before (verified with the team's key). After you confirm, it signs the next version of the ias-approvers-1 document with the team's key of 1Password and uploads it: the runners only build for the stores what one of these passkeys approved. Run it again after an owner adds or removes a passkey; the panel says so in Today.\n\n| Option | What it does |\n| --- | --- |\n| `--item <op://vault/item>` | The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>). |\n| `--yes` | Do not ask: you have already compared what it prints (without a terminal it is needed). |\n| `--dry-run` | Show the plan and change nothing: no 1Password write, no signed document, no runner list and no envelope changed. |\n| `--replace` | Sign over a document of the server that does not verify with the team's key (after checking why). |\n\n## `ias vault grant`\n\nLet a registered runner open the team's credentials: the signed list and a wrap in each envelope\n\nShows the runner's vault kid for you to compare with the one ias-runner register printed on its Mac. Then it signs a runner list with it, uploads it, and wraps every envelope of the team (credentials and secret variables) for it, opening each one with the recovery key of 1Password and signing it again. Envelopes that do not verify are never signed again: it says which.\n\n| Argument | What it is |\n| --- | --- |\n| `<runner>` | The runner: its id, its name (in quotes if it has spaces) or its vault kid, as ias vault status and the panel list them. |\n\n| Option | What it does |\n| --- | --- |\n| `--item <op://vault/item>` | The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>). |\n| `--yes` | Do not ask: you have already compared what it prints (without a terminal it is needed). |\n| `--dry-run` | Show the plan and change nothing: no 1Password write, no signed document, no runner list and no envelope changed. |\n\n## `ias vault revoke`\n\nTake a runner revoked in the panel out of the signed list and of every envelope\n\nRevoke it in the panel first (System › Runners): the server stops serving it at once. This signs a runner list without it and removes its wrap from every envelope, signing each one again, and lists what it could read, which is what to renew if its Mac may have been compromised (docs/technical/vault.md §10).\n\n| Argument | What it is |\n| --- | --- |\n| `<runner>` | The runner: its id, its name (in quotes if it has spaces) or its vault kid, as ias vault status and the panel list them. |\n\n| Option | What it does |\n| --- | --- |\n| `--item <op://vault/item>` | The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>). |\n| `--yes` | Do not ask: you have already compared what it prints (without a terminal it is needed). |\n| `--dry-run` | Show the plan and change nothing: no 1Password write, no signed document, no runner list and no envelope changed. |\n\n## `ias policy`\n\nThe signed policy of who approves which build or submission, and how many\n\nThe team signs an ias-policy-1 document with the key of 1Password (docs/technical/vault.md §2.5): for each application, platform, profile, job type and, for a submission, store and track, it names the members who may approve and how many of them. Without a policy everything behaves as today; with one, a store job no rule covers is denied and the runner verifies the same signed policy before running it.\n\n| Subcommand | What it does |\n| --- | --- |\n| `init` | Write ias.policy.json equivalent to today: one * rule, one approval, every owner with a passkey |\n| `check` | Check a policy file against the format, the members, the version and the server; writes nothing |\n| `sign` | Sign the policy with the team key of 1Password and upload it (owner, with a passkey) |\n| `show` | Show the policy of the server, verified with the team key, with the rules and their members |\n| `status` | The policy of the server against the local file: verified, expiring, uncovered, in sync |\n| `diff` | Compare the local policy with the one of the server: rules and members added, changed or removed |\n| `simulate` | What the local policy would decide for a job: the rule that matches, or the denial |\n\n## `ias policy init`\n\nWrite ias.policy.json equivalent to today: one * rule, one approval, every owner with a passkey\n\nReads the owners with a passkey from the server and writes the source of the policy; it does not sign it. The version is the next above the server's unless --version says otherwise, and it expires in 180 days unless --not-after says otherwise. It never overwrites an existing file without --force.\n\n| Option | What it does |\n| --- | --- |\n| `--out <file>` | Where to write it (ias.policy.json in the project by default). |\n| `--version <n>` | The version to write (the next above the server's by default). |\n| `--not-after <date>` | When it expires: 2027-04-28 or a whole UTC timestamp (180 days from today by default). |\n| `--force` | Write the file even if it already exists. |\n\n## `ias policy check`\n\nCheck a policy file against the format, the members, the version and the server; writes nothing\n\nValidates the shape and the semantics, verifies the signature if the file has one, and compares the version, the members (each needs a passkey in the current ias-approvers-1), the date and the coverage with the server. Exits 1 when something must be fixed and says the exact command or edit for each problem.\n\n| Argument | What it is |\n| --- | --- |\n| `<file>` | The policy file (ias.policy.json in the project by default). |\n\n| Option | What it does |\n| --- | --- |\n| `--item <op://vault/item>` | The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>). |\n\n## `ias policy sign`\n\nSign the policy with the team key of 1Password and upload it (owner, with a passkey)\n\nShows the version, the date, the hash, the diff from the policy the server has and the rules, then asks. It signs with the Ed25519 key of the vault item (read with op, never printed), uploads the document and opens the approval URL: an owner compares the hash and approves it with a passkey before anything is stored.\n\n| Argument | What it is |\n| --- | --- |\n| `<file>` | The policy file (ias.policy.json in the project by default). |\n\n| Option | What it does |\n| --- | --- |\n| `--item <op://vault/item>` | The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>). |\n| `--yes` | Do not ask: you have already compared the diff and the hash (without a terminal it is needed). |\n\n## `ias policy show`\n\nShow the policy of the server, verified with the team key, with the rules and their members\n\nReads GET /team/policy, verifies the signature with the team's signing key (compared with 1Password when it is there) and prints the version, the date, the hash and each rule with the members who may approve it. Exits 1 if the server's policy does not verify.\n\n| Option | What it does |\n| --- | --- |\n| `--item <op://vault/item>` | The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>). |\n\n## `ias policy status`\n\nThe policy of the server against the local file: verified, expiring, uncovered, in sync\n\nSays whether there is a policy, whether its signature verifies, when it expires, which rules lack members with a passkey, which app/profile combinations no rule covers, and whether ias.policy.json is the one of the server. Exits 1 only when the server's policy does not verify.\n\n| Option | What it does |\n| --- | --- |\n| `--item <op://vault/item>` | The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>). |\n\n## `ias policy diff`\n\nCompare the local policy with the one of the server: rules and members added, changed or removed\n\nVerifies the policy of the server with the team key, strips the signature from the local file if it has one, and prints the rules that appear, change or go and the members that enter or leave. It writes nothing.\n\n| Argument | What it is |\n| --- | --- |\n| `<file>` | The policy file (ias.policy.json in the project by default). |\n\n| Option | What it does |\n| --- | --- |\n| `--item <op://vault/item>` | The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>). |\n\n## `ias policy simulate`\n\nWhat the local policy would decide for a job: the rule that matches, or the denial\n\nMatches app, platform, profile and type (and, for a submit, target and track) with the rules of the local file, choosing the most specific one, and prints the rule and who may approve it. A testflight matches as a submission to App Store Connect and a release as one to Google Play with its track. Exits 1 when no rule matches: with a policy, the server denies that job.\n\n| Argument | What it is |\n| --- | --- |\n| `<file>` | The policy file (ias.policy.json in the project by default). |\n\n| Option | What it does |\n| --- | --- |\n| `--file <file>` | The policy file (ias.policy.json in the project by default); the positional form is the same. |\n| `--app <code>` | The application (app in ias.json by default). |\n| `--platform ios|android` | The platform of the job (needed, with --profile). |\n| `--profile <name>` | The build profile of the job (needed, with --platform). |\n| `--type build|submit|testflight|release` | The job type (build by default). |\n| `--target app_store_connect|google_play` | The store of a submit (its platform's by default). |\n| `--track <track>` | The Google Play track of a submit or a release (internal for a submit, production for a release, by default). |\n\n## `ias tokens`\n\nThe robot tokens of an application for CI: create one, list them and revoke one\n\nA robot token (iasr_…) works without a person, with the scopes it is given. create needs an owner's personal token and keeps the value in 1Password, with its op:// reference; list never shows a value. In CI, set IAS_TOKEN from the item.\n\n| Subcommand | What it does |\n| --- | --- |\n| `create` | Make a robot token, keep it in 1Password and print its item and scopes |\n| `list` | The robot tokens of the application, without their value |\n| `revoke` | Revoke a robot token or a legacy publishing token |\n\n## `ias tokens create`\n\nMake a robot token, keep it in 1Password and print its item and scopes\n\n| Option | What it does |\n| --- | --- |\n| `--name \"…\"` | The name of the token, such as «GitHub Actions» (CI by default). |\n| `--scopes <list>` | Comma-separated scopes: apps:read,builds:create by default. The values: apps:read, builds:create, credentials:read, credentials:write, distribution:upload, env:read, env:write, push:send, updates:publish. |\n| `--days <30|90|180|365>` | How long it lasts (90 by default). |\n| `--item op://vault/item` | The 1Password item for the token (op://IAS/<app> CI by default). |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--dry-run` | Say what it would make and change, and make nothing. |\n\n## `ias tokens list`\n\nThe robot tokens of the application, without their value\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n\n## `ias tokens revoke`\n\nRevoke a robot token or a legacy publishing token\n\nTakes its id, or its name when only one token has it: two with the same name are never picked silently, it lists them and asks for the id. --dry-run says what it would revoke and revokes nothing.\n\n| Argument | What it is |\n| --- | --- |\n| `<token>` | The id of the token (ias tokens list), or its name when only one has it. |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--dry-run` | Say what it would revoke, and revoke nothing. |\n\n## `ias runners`\n\nThe runners of the team: a one-time code to register one\n\nA runner registers with ias-runner register and the code this prints, and a member authorizes it with ias vault grant. The code is valid for 30 minutes and once.\n\n| Subcommand | What it does |\n| --- | --- |\n| `code` | Make a one-time code to register a runner, and print the command for its Mac |\n\n## `ias runners code`\n\nMake a one-time code to register a runner, and print the command for its Mac\n\n| Option | What it does |\n| --- | --- |\n| `--dry-run` | Say what it would make and make nothing. |\n\n## `ias build`\n\nBuild the app on a runner of IAS (or on this Mac with --local), from the commit of a clean working tree\n\nSends the commit (git archive), its SHA-256 and the build profile of ias.json resolved, and waits: a store build for the approval of an owner with a passkey (it opens the page), then the runner. It follows the status and the log until the build ends; Ctrl-C stops following, not the build. The version and the build number come from app.json; with autoIncrement, the server gives the next number (ias version). A store build needs a personal token (ias login); an internal one also takes a robot token with builds:create.\n\n| Option | What it does |\n| --- | --- |\n| `--platform ios|android|all` | The platforms (all by default: one build each, iOS first). |\n| `--profile <name>` | The build profile of ias.json (production by default). |\n| `--message \"…\"` | What the build is (the subject of the commit by default); internal builds show it as their notes. |\n| `--wait` | When it succeeds, download its artifacts (to ~/ias-builds/<app>/… or --out). With --local, wait for a build of this Mac that holds the lock. |\n| `--out <folder>` | Where the artifacts go (one folder per platform), or the output of --local. |\n| `--detach` | Do not follow the build: launch it, open the approval and leave (ias builds logs <id> --follow follows it later). |\n| `--local` | Build on this Mac with @imagina-build/ias-runner, one platform after the other: nothing goes to the server. |\n| `--credentials <op://vault/item|folder>` | With --local: the signing material (IAS_IOS_CREDENTIALS or IAS_ANDROID_CREDENTIALS by default). |\n\n## `ias builds`\n\nThe builds of the application on IAS: list, view, logs, cancel and download\n\n| Subcommand | What it does |\n| --- | --- |\n| `list` | The builds of the application, the newest first |\n| `view` | One build: its profile, commit, status, runner, error, artifacts and submissions |\n| `logs` | The log of a build, as the runner sent it |\n| `cancel` | Cancel a build (who launched it, or an owner); one in progress stops on its runner |\n| `download` | Download the artifacts of a build, each checked against its SHA-256 |\n\n## `ias builds list`\n\nThe builds of the application, the newest first\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--status <status>` | Only those in a status: uploading, awaiting_approval, queued, assigned, running, uploading_artifacts, succeeded, failed, rejected, canceled, expired. |\n| `--platform ios|android` | Only those of a platform. |\n\n## `ias builds view`\n\nOne build: its profile, commit, status, runner, error, artifacts and submissions\n\n| Argument | What it is |\n| --- | --- |\n| `<build>` | The id of the build, from ias builds list. |\n\n_No options of its own._\n\n## `ias builds logs`\n\nThe log of a build, as the runner sent it\n\n| Argument | What it is |\n| --- | --- |\n| `<build>` | The id of the build, from ias builds list. |\n\n| Option | What it does |\n| --- | --- |\n| `--follow` | Follow the status and the log until the build ends. |\n\n## `ias builds cancel`\n\nCancel a build (who launched it, or an owner); one in progress stops on its runner\n\n| Argument | What it is |\n| --- | --- |\n| `<build>` | The id of the build, from ias builds list. |\n\n_No options of its own._\n\n## `ias builds download`\n\nDownload the artifacts of a build, each checked against its SHA-256\n\n| Argument | What it is |\n| --- | --- |\n| `<build>` | The id of the build, from ias builds list. |\n\n| Option | What it does |\n| --- | --- |\n| `--kind <kinds>` | Only these kinds, separated by commas: ipa, aab, apk, dsyms, mapping, result, logs. |\n| `--out <folder>` | Where they go (~/ias-builds/<app>/<platform>-<version>-<number> by default). |\n\n## `ias devices`\n\nThe testers' iPhones and iPads: list them, and register the approved ones in Apple and sign again\n\nA tester registers a device with the link of the application (the panel: Distribución › Dispositivos) and a member approves it with a passkey. ias devices sync, on this computer, opens the App Store Connect API key of the vault with the recovery key of 1Password, registers the approved devices in Apple, makes the ad hoc profile again with them, seals and signs it into the vault and asks a runner to sign the newest internal iOS build again, without building it.\n\n| Subcommand | What it does |\n| --- | --- |\n| `list` | The devices of the application, with their status and the registration link |\n| `sync` | Register the approved devices in Apple, make the ad hoc profile again and sign the newest internal build again |\n\n## `ias devices list`\n\nThe devices of the application, with their status and the registration link\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--status <pending|approved|registered|rejected>` | Only the devices in this status. |\n\n## `ias devices sync`\n\nRegister the approved devices in Apple, make the ad hoc profile again and sign the newest internal build again\n\nIt shows what it will do in Apple and in the vault and asks to confirm it. The team's keys come from the 1Password item of ias vault init; the App Store Connect API key, from the vault (ias credentials import asc_api_key).\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--yes` | Do not ask: the devices were checked in the panel. |\n| `--force` | Make the profile again even if it already lists every approved device. |\n| `--no-resign` | Only the devices and the profile: do not sign the internal build again. |\n| `--detach` | Ask for the resign and do not wait for it. |\n| `--dry-run` | Say which devices it would register, which profile it would make and seal and which resign it would queue, and do none of it (no Apple call, no vault write, no job). |\n| `--item <op://vault/item>` | The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>). |\n\n## `ias github`\n\nLink the GitHub repository of an application, so its tags, branches and pull requests start workflows\n\nThe tags, branches and pull requests of the repository start the workflows of ias.json, GitHub gets the status of each build on its commit, and the internal builds of a pull request leave their install link in a comment. Only owners link a repository.\n\n| Subcommand | What it does |\n| --- | --- |\n| `link` | Link a repository; the first time, print the secret of the webhook GitHub will call |\n\n## `ias github link`\n\nLink a repository; the first time, print the secret of the webhook GitHub will call\n\n| Argument | What it is |\n| --- | --- |\n| `<repository>` | owner/name, or its https://github.com URL. |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--dry-run` | Say what it would link, without linking it or showing the webhook secret. |\n\n## `ias migrate`\n\nLeave another service for IAS, step by step: eas\n\n| Subcommand | What it does |\n| --- | --- |\n| `eas` | Leave EAS: ias.json, the signing credentials, the build numbers, the variables and what is left in expo.dev |\n\n## `ias migrate eas`\n\nLeave EAS: ias.json, the signing credentials, the build numbers, the variables and what is left in expo.dev\n\nSteps, in their order (each can run again without harm, and says what it did and what is left):\n (none) where the move is: what each step finds done, and the next one\n profiles the build and submit profiles of eas.json into ias.json, and what has no equivalent [--app, --server, --force, --dry-run]\n prepare a folder outside the project (mode 700) where you run eas credentials, and how [--eas-project, --owner]\n import <folder>: credentials.json of eas credentials sealed into the vault, as ias credentials import --eas does; the download is then deleted, also when it fails half way (not with --dry-run) [--force, --dry-run, --item]\n version the last build numbers of EAS (eas build:version:get, run here without a terminal) to the server; they never go down [--ios, --android, --profile, --dry-run]\n env the variables of EAS (eas env:list, or the .env of eas env:pull with --env-file): EXPO_PUBLIC_* plain, the rest sealed [--environment, --env-file, --force, --dry-run, --item]\n checklist what is left in expo.dev, the stores and the app; ias deletes nothing\neas credentials and eas env:pull handle secrets and ask questions: you run them. Nothing prints a password or a key.\n\n| Argument | What it is |\n| --- | --- |\n| `<step>` | One of status, profiles, prepare, import, version, env, checklist (status when left out). |\n| `<folder>` | import: the folder of ias migrate eas prepare, with the download of eas credentials. |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application in IAS (app of ias.json by default; profiles writes it in a new ias.json). |\n| `--server <origin>` | profiles: the server of IAS in ias.json, such as https://sync.domibi.com. |\n| `--eas-project <id>` | prepare: the id of the EAS project, when app.json no longer names it (extra.eas.projectId). |\n| `--owner <account>` | prepare: the Expo account of the project, when app.json does not say it. |\n| `--profile <profile>` | version: the build profile of eas.json whose numbers to bring (production by default). |\n| `--ios <number>` | version: the last iOS build number, instead of asking eas. |\n| `--android <number>` | version: the last Android version code, instead of asking eas. |\n| `--environment <environment>` | env: development, preview or production (all three from eas env:list by default; required with --env-file). |\n| `--env-file <file>` | env: the .env of eas env:pull, deleted afterwards when it is in the folder of prepare. |\n| `--force` | profiles: take eas.json's profiles over those of ias.json; import: import credentials already in the vault or with problems; env: replace variables already in IAS. |\n| `--dry-run` | Read and check everything, and send or write nothing; import keeps the download. |\n| `--item <op://vault/item>` | import and env: the 1Password item of the vault, to seal. |\n\n## `ias push`\n\nPush notifications: send, the history, the devices and the APNs and FCM credentials\n\nNotifications go straight to APNs and FCM through the server (docs/technical/manage-api.md §14). The token needs push:send; loading a credential needs an owner and a passkey.\n\n| Subcommand | What it does |\n| --- | --- |\n| `send` | Send a notification now, schedule it with --at, or count whom it reaches with --dry-run |\n| `messages` | The notifications sent and scheduled, with their figures, or one of them |\n| `cancel` | Cancel a scheduled notification |\n| `devices` | The devices registered for notifications, with the hint of their token |\n| `credentials` | The APNs and FCM credentials, or load one (an owner approves it with a passkey) |\n\n## `ias push send`\n\nSend a notification now, schedule it with --at, or count whom it reaches with --dry-run\n\nThe audience: --to all (by default), or --to topic:<name>, user:<id>, organization:<code>, space:<id>, group:<id> or device:<id>, repeated for more of the same kind; narrowed with --platform, --app-version, --runtime, --channel and --language. Translations and the rest of docs/technical/manage-api.md §14.2 go in a JSON file with --from; the options override it.\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--to <kind:id>` | Whom it goes to (all, topic:…, user:…, organization:…, space:…, group:…, device:…). |\n| `--title <text>` | The title. |\n| `--body <text>` | The text. |\n| `--locale <language>` | The language of the title and the text (es, en, pt-BR); needed with translations. |\n| `--data <name=value>` | Data the app gets with it, repeated for each value. |\n| `--platform <ios|android>` | Only these platforms. |\n| `--app-version <version>` | Only these versions of the app. |\n| `--runtime <runtime>` | Only these runtimes of expo-updates. |\n| `--channel <channel>` | Only these channels of expo-updates. |\n| `--language <language>` | Only devices in this language (es takes es-ES and es-MX). |\n| `--priority <high|normal>` | high (by default) delivers at once; normal when it suits the device. |\n| `--ttl <duration>` | How long APNs and FCM keep it for an unreachable device (0, 1h, 1d…; 28 days by default). |\n| `--collapse-id <id>` | A newer one with the same id replaces this one if not yet delivered. |\n| `--badge <number>` | The number of the icon on iOS. |\n| `--android-channel <channel>` | The notification channel on Android. |\n| `--background` | A silent push that only wakes the app with its --data. |\n| `--at <date>` | Schedule it (ISO 8601, between 1 minute and 30 days ahead). |\n| `--from <file.json>` | The notification as JSON (docs/technical/manage-api.md §14.2), with translations. |\n| `--dry-run` | Count whom it would reach now, by platform and language, and send nothing. |\n| `--wait` | Wait until every device has its result, and print the figures. |\n\n## `ias push messages`\n\nThe notifications sent and scheduled, with their figures, or one of them\n\n| Argument | What it is |\n| --- | --- |\n| `<id>` | The id of a notification (ias push messages lists them). |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--status <status>` | scheduled, sending, sent or canceled. |\n| `--limit <n>` | How many (20 by default, up to 100). |\n\n## `ias push cancel`\n\nCancel a scheduled notification\n\n| Argument | What it is |\n| --- | --- |\n| `<id>` | The id of a notification (ias push messages lists them). |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n\n## `ias push devices`\n\nThe devices registered for notifications, with the hint of their token\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--platform <ios|android>` | Only this platform. |\n| `--user <id>` | Only the devices of this user. |\n| `--topic <topic>` | Only the devices subscribed to this topic. |\n| `--status <active|retired|all>` | active (by default), retired or all. |\n| `--limit <n>` | How many (50 by default, up to 500). |\n\n## `ias push credentials`\n\nThe APNs and FCM credentials, or load one (an owner approves it with a passkey)\n\nWithout arguments, their metadata. With a platform and a file (a path or an op:// reference), it loads the .p8 key of APNs (ios) or the JSON of the FCM service account (android): the server checks it, an owner approves it with a passkey, and it is kept sealed with PUSH_CREDENTIALS_KEY.\n\n| Argument | What it is |\n| --- | --- |\n| `<platform>` | ios or android. |\n| `<file>` | The .p8 key or the JSON of the service account: a path or an op:// reference. |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--key-id <id>` | The id of the APNs key, if the file is not called AuthKey_<id>.p8. |\n| `--team-id <team>` | The Apple team of the key (the application's by default). |\n\n## `ias releases`\n\nThe releases of Google Play: what each track serves, its history and its policy\n\nWithout a subcommand, ias releases lists the tracks IAS knows of the application. list and view only read the state IAS keeps (no job, no passkey, no flags); --refresh queues the read-only release_status job, lets the runner read Play and waits for it. A track without a policy only takes drafts, as always; an owner allows staged rollouts there with ias releases policy <track> (the panel edits the same policy).\nTo move a release that is already in a track, use ias rollout.\n\n| Subcommand | What it does |\n| --- | --- |\n| `list` | The releases of every track, with their next step; --refresh reads Play again |\n| `view` | What one track serves, its policy, its last job and the next step |\n| `policy` | Show the release policy of a track, or set it (an owner) |\n\nWithout a subcommand:\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--refresh` | Read Play again with the read-only job, and wait for it. |\n\n## `ias releases list`\n\nThe releases of every track, with their next step; --refresh reads Play again\n\nOnly reads: one line per track with the version, version code, status, percentage, review lifecycle and when IAS last read it. It never creates a job unless --refresh is given.\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--refresh` | Read every known track again with the read-only job, and wait for it. |\n\n## `ias releases view`\n\nWhat one track serves, its policy, its last job and the next step\n\nOnly reads the state IAS keeps. With --refresh it queues the read-only release_status job (no approval), waits for the runner and shows what Play has.\n\n| Argument | What it is |\n| --- | --- |\n| `<track>` | The track of Google Play, as ias submit --track names it. |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--refresh` | Read Play again with the read-only job, and wait for it. |\n\n## `ias releases policy`\n\nShow the release policy of a track, or set it (an owner)\n\nWithout flags it shows the policy; without a row, it says that the track only takes drafts and gives the command. With flags an owner sets it: it is the permission of every rollout, so production needs one explicitly. The panel (application › Ajustes › Google Play) edits the same policy.\n\n| Argument | What it is |\n| --- | --- |\n| `<track>` | The track of Google Play, as ias submit --track names it. |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--mode <draft|staged|full>` | What the track allows: drafts only, a gradual rollout or completing it to everyone. |\n| `--max-percent <1-100>` | The ceiling of the rollout (staged and full). |\n| `--step <1-100>` | How many points one change may raise the rollout. |\n| `--min-minutes <minutes>` | The minimum wait between changes, to protect Play's review queue. |\n| `--dry-run` | Show the policy that would be saved, without saving it. |\n\n## `ias rollout`\n\nMove the release of a Google Play track: raise its percentage, halt, resume or complete it\n\nThe release has to be in the track already (ias submit put it there, draft, inProgress or completed). Without an action flag this is read-only: it shows the state, the policy and the next step, and creates nothing. With --percent (raise), --halt, --resume [--percent] or --complete it creates one job that an owner approves with a passkey; the runner checks Play and writes the change without sending an artifact again.\nThe policy of the track is the permission: an owner sets it with ias releases policy <track>. A rollout never goes down (who has the release keeps it); use --halt to stop new users. Play reviews from the last submission, so the policy can also set a minimum wait.\n\n| Argument | What it is |\n| --- | --- |\n| `<track>` | The track of Google Play, as ias submit --track names it. |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--percent <1-99>` | Raise the rollout to this whole percentage (never down). |\n| `--halt` | Stop serving it to new users; who has it keeps it. |\n| `--resume` | Resume a halted rollout, at its percentage or at --percent. |\n| `--complete` | Serve it to everyone (100 %); needs a full policy. |\n| `--version-code <n>` | The release to move (the track's current one by default). |\n| `--wait` | Wait until the runner moved it, and say the state before and after. |\n| `--dry-run` | Show what would be approved, without creating anything. |\n\n## `ias submit`\n\nSend a store build to App Store Connect or Google Play, after an owner approves it (a draft by default)\n\nThe runner sends it with the keys of the vault (asc_api_key, google_service_account). Google Play goes to the internal track by default and, like always, as a draft: the review is asked in Play. --release-status inProgress with --rollout serves a percentage and --release-status completed serves it to everyone, only when the track has a policy that allows it (an owner sets it with ias releases policy; without one, a track only takes drafts). --changes-not-sent-for-review applies the commit without sending it for review: a person sends it from Play Console, and until then nobody receives it (redundant with a draft). submit.<profile>.android.releaseStatus, rollout and changesNotSentForReview in ias.json are used the same way, and if the policy does not allow them the command warns and sends a draft instead.\n\n| Argument | What it is |\n| --- | --- |\n| `<build>` | The id of a store build that succeeded, from ias builds list. |\n\n| Option | What it does |\n| --- | --- |\n| `--track <track>` | Google Play: production, beta, alpha, internal (the default), a closed track or a form factor's (wear:production). |\n| `--asc-app-id <id>` | App Store Connect: the Apple ID of the app (submit.<profile>.ios.ascAppId by default). |\n| `--release-status <draft|inProgress|completed>` | Google Play: the state of the release (draft by default). |\n| `--rollout <1-99>` | Google Play: the percentage an inProgress release serves; 100 is completed. |\n| `--changes-not-sent-for-review` | Google Play: apply the release without sending it for review; a person sends it in Play Console (submit.<profile>.android.changesNotSentForReview by default). |\n| `--wait` | Wait until the runner has sent it, and say what the store answered. |\n\n## `ias teams`\n\nThe teams you are in, and which one the token is of\n\n_No options of its own._\n\n## `ias testflight`\n\nDistribute a build that is in App Store Connect to TestFlight groups, or send it to Beta App Review\n\nThe build has to be a store iOS build that succeeded and whose ias submit reached App Store Connect: TestFlight distributes a build Apple already has. Both actions are a job of the runner with its own approval: the runner opens the App Store Connect key (the server never sees it) and the owner approves the plan with a passkey.\nWithout --groups, the groups come from ias.json › submit.<profile>.ios.testflight.groups; the «What to Test» from --what-to-test with --locale (es-ES by default), --notes-file, ias.json or the message of the build. If there are no groups and none is configured, it lists the groups the app has and the exact command: it never creates one.\nias testflight <build> and ias testflight distribute <build> are the same command. ias testflight review is a separate command, with its own approval: it sends the build to Beta App Review (Apple). ias testflight status only reads.\n\n| Subcommand | What it does |\n| --- | --- |\n| `distribute` | Distribute the build to its TestFlight groups; the same as ias testflight <build> |\n| `review` | Send the build to Beta App Review, with its own passkey (a separate approval) |\n| `status` | What TestFlight and Beta App Review say of the build; it only reads |\n\nWithout a subcommand:\n\n| Argument | What it is |\n| --- | --- |\n| `<build>` | The id of a store iOS build that is in App Store Connect, from ias builds list. |\n\n| Option | What it does |\n| --- | --- |\n| `--groups <name>` | A TestFlight group by name; repeat it for more (ias.json › submit.<profile>.ios.testflight.groups by default). |\n| `--what-to-test \"…\"` | The «What to Test» of the build, in the locale of --locale. |\n| `--locale <locale>` | The locale of --what-to-test (es-ES by default). |\n| `--notes-file <file.json>` | A JSON object of locale: text, for several locales (at most 8). |\n| `--notify` | Notify the testers when the build is added (off by default). |\n| `--wait` | Wait until the runner finished, and say what Apple answered. |\n| `--dry-run` | Show what would be approved, without creating anything. |\n\n## `ias testflight distribute`\n\nDistribute the build to its TestFlight groups; the same as ias testflight <build>\n\nThe explicit form of the positional one, for scripts that read better with a verb: it takes the same options and does exactly the same, with the same approval.\n\n| Argument | What it is |\n| --- | --- |\n| `<build>` | The id of a store iOS build that is in App Store Connect, from ias builds list. |\n\n| Option | What it does |\n| --- | --- |\n| `--groups <name>` | A TestFlight group by name; repeat it for more (ias.json › submit.<profile>.ios.testflight.groups by default). |\n| `--what-to-test \"…\"` | The «What to Test» of the build, in the locale of --locale. |\n| `--locale <locale>` | The locale of --what-to-test (es-ES by default). |\n| `--notes-file <file.json>` | A JSON object of locale: text, for several locales (at most 8). |\n| `--notify` | Notify the testers when the build is added (off by default). |\n| `--wait` | Wait until the runner finished, and say what Apple answered. |\n| `--dry-run` | Show what would be approved, without creating anything. |\n\n## `ias testflight review`\n\nSend the build to Beta App Review, with its own passkey (a separate approval)\n\nBeta App Review is what external testers need: it is not done by distributing, it is not App Review, and Apple can reject it. The runner checks the test information of the app first and sends the build once; it does not wait for the review, which is human.\n\n| Argument | What it is |\n| --- | --- |\n| `<build>` | The id of a store iOS build that is in App Store Connect, from ias builds list. |\n\n| Option | What it does |\n| --- | --- |\n| `--wait` | Wait until the runner has sent it, and say the state Apple gave. |\n| `--dry-run` | Show what would be approved, without creating anything. |\n\n## `ias testflight status`\n\nWhat TestFlight and Beta App Review say of the build; it only reads\n\nReads the state IAS has of the build: Apple's processing, the groups it is in, the locales with notes and the state of Beta App Review. It never creates a job nor calls Apple.\n\n| Argument | What it is |\n| --- | --- |\n| `<build>` | The id of a store iOS build that is in App Store Connect, from ias builds list. |\n\n_No options of its own._\n\n## `ias usage`\n\nWhat the team used in a month, with its limits\n\nBuilds (minutes per platform, artifacts), updates (OTA), notifications, synchronization and testers' devices, per application. A limit is set by a platform administrator; at 100 % the builds, notifications or devices it covers are refused with 409 limit_exceeded.\n\n| Option | What it does |\n| --- | --- |\n| `--month <YYYY-MM>` | The month (the current one by default). |\n\n## `ias version`\n\nThe build numbers the server keeps for each platform (autoIncrement takes the next)\n\n| Subcommand | What it does |\n| --- | --- |\n| `get` | The last build number used on iOS and on Android |\n| `set` | Set the last build numbers, to bring them from EAS or app.json (owner); they only go up |\n\n## `ias version get`\n\nThe last build number used on iOS and on Android\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n\n## `ias version set`\n\nSet the last build numbers, to bring them from EAS or app.json (owner); they only go up\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--ios <number>` | The last build number of iOS (CFBundleVersion). |\n| `--android <number>` | The last version code of Android. |\n| `--from-app-json` | Take them from expo.ios.buildNumber and expo.android.versionCode of app.json. |\n\n## `ias webhooks`\n\nThe outgoing webhooks of an application: list, create, delete and test them\n\nA webhook gets a POST signed with IAS-Signature (HMAC-SHA256 with its secret, as Stripe signs) for each event it subscribes to: builds, store submissions, updates, rollouts, credentials, notifications and testers' devices. Only owners create, delete and test them; the secret is shown once.\n\n| Subcommand | What it does |\n| --- | --- |\n| `list` | The webhooks of the application, with their events and whether they are on |\n| `create` | Make a webhook and print its secret, once |\n| `delete` | Delete a webhook and its deliveries |\n| `test` | Send a test event now and say how the server answered |\n\n## `ias webhooks list`\n\nThe webhooks of the application, with their events and whether they are on\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n\n## `ias webhooks create`\n\nMake a webhook and print its secret, once\n\n| Argument | What it is |\n| --- | --- |\n| `<url>` | An https address of a public server. |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--events <events>` | The events it gets, separated by commas, or «all»: build.queued, build.awaiting_approval, build.succeeded, build.failed, submission.succeeded, submission.failed, update.published, update.rollout_paused, update.rollout_completed, credential.expiring, push.message.finished, device.pending. |\n| `--description \"…\"` | What it is for (up to 100 characters). |\n| `--dry-run` | Say what it would create, without creating it or showing a secret. |\n\n## `ias webhooks delete`\n\nDelete a webhook and its deliveries\n\n| Argument | What it is |\n| --- | --- |\n| `<webhook>` | The id of the webhook (ias webhooks list). |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--dry-run` | Say what it would delete, and delete nothing. |\n\n## `ias webhooks test`\n\nSend a test event now and say how the server answered\n\n| Argument | What it is |\n| --- | --- |\n| `<webhook>` | The id of the webhook (ias webhooks list). |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n\n## `ias workflows`\n\nThe workflows of ias.json that GitHub starts: list them and run one by hand\n\nA workflow is a list of steps, build and submit, that a tag, a branch or a pull request of the linked repository starts (ias github link). There is no step that publishes an OTA update: its signing key lives on a Mac. The code is the commit's tarball from GitHub; store builds and submissions wait for an owner's passkey as always.\n\n| Subcommand | What it does |\n| --- | --- |\n| `list` | The workflows of ias.json at a commit of the repository, with their triggers and steps |\n| `run` | Start a workflow by hand at a ref of the repository |\n\n## `ias workflows list`\n\nThe workflows of ias.json at a commit of the repository, with their triggers and steps\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--ref <ref>` | A branch, tag or commit of the repository (its default branch by default). |\n\n## `ias workflows run`\n\nStart a workflow by hand at a ref of the repository\n\n| Argument | What it is |\n| --- | --- |\n| `<workflow>` | The name of the workflow (ias workflows list). |\n\n| Option | What it does |\n| --- | --- |\n| `--app <code>` | The application (app of ias.json by default). |\n| `--ref <ref>` | A branch, tag or commit of the repository (its default branch by default). |\n| `--wait` | Follow the run until it ends; the exit code is 1 if it did not succeed. |\n",
6
+ "references/platform.md": "# IAS: concepts, services and security\n\nThis is the mental model behind the CLI and the SDK. The contracts are in `docs/technical/`; this page is the map.\n\n## The entities\n\n| Entity | What it is |\n| --- | --- |\n| **Application** (`code`) | The product: an Expo app with a code such as `jscheduler`, its iOS bundle id and Android package, and the services it uses. Tenancy always derives from the authenticated user or application. |\n| **Team** | A group of people. A personal token belongs to one team; a team's vault is its own. A person in two teams keeps two 1Password items. |\n| **Member roles** | `owner`, `admin`, `member` (the exact capabilities are in `docs/technical/manage-api.md` §1). Owners approve vault keys, robot tokens, store builds and submissions. |\n| **Robot token** | A token without a person behind it: `iasr_…` for an application, created by an owner with `ias tokens create` (kept in 1Password) or in the panel. CI uses it as `IAS_TOKEN`. The old project tokens `ota_…` and `dist_` still exist for publishing. |\n| **Vault** | The team's sealed material: the recovery key (X25519) and the signing key (Ed25519), both private halves in one 1Password item; the server has only the public keys. |\n| **Runner** | A machine that compiles. It registers with a one-time code, pins the team's signing key and the panel origin, and is authorized with `ias vault grant`. |\n| **OTA project** | The update namespace in `/api/v1/ota/<project>/…`. It has channels, each with its own certificate and keyid. The publishing token is shown once. `ias update init` makes the project, the key and the token, and `ias setup` calls it. |\n| **Distribution project** | The install pages in `/api/v1/distribution/<project>/…`, with a public stable link and a publishing token. `ias dist init` makes it. |\n| **Install key** | `push.installKey` (`iasi_…`). Not a secret: it lets the app register a device without a session. `ias setup` reads it from the API or makes it, and writes it to `ias.json`. |\n| **Runtime** | The fingerprint of the native build. An OTA update reaches only the builds with the same runtime. |\n\n## 1Password items\n\nThe CLI creates new items in the `IAS` vault (change it with `--item`) with titles that use the application code:\n\n| Title | Fields (canonical) | Used by |\n| --- | --- | --- |\n| `<code> OTA` | `private-key` (CONCEALED), `certificate`, `key-id`, `token` (CONCEALED), `project`, `server` | `ias update init` |\n| `<code> distribution` | `token` (CONCEALED), `project`, `server` | `ias dist init` |\n| `<code> CI` | `token` (CONCEALED), `scopes`, `expires-at`, `application` | `ias tokens create` |\n| `<code> Android upload` | `keystore` (file attachment), `store-password`, `key-alias`, `key-password` (CONCEALED) | `ias credentials generate` |\n| `IAS vault <host>` | `recovery-private-key`, `recovery-public-key`, `recovery-kid`, `signing-private-key`, `signing-public-key`, `signing-kid`, `format`, `server` | `ias vault init` |\n\nOld items (`op://Private/…`, `private-key.pem`, `credential`) still work: read aliases, never rename an item whose\nreference is in `ias.json`. Apple/Google files are imported from user-made items; the CLI does not create them.\n\n## The services\n\n### Sync (`ias.sync`)\n\nOffline-first data. The app writes rows locally and the SDK synchronizes them; the server stores opaque JSON strings.\nLast Write Wins per row by `updated_at`, with a byte comparison on ties; deletes are permanent tombstones. Spaces group\ndata and carry permissions, sections, groups, rules, links and invitations. Encryption is end-to-end in `spaces` mode:\nthe app supplies the crypto hooks and the keys never reach the server.\n\nModes: `personal` (the frozen v1 `POST /sync`, one user's data) and `spaces` with `batch` (default) or `space`\ntransport. Read `docs/technical/SPEC.md` for the data model and `docs/technical/API.md` chapters 1–35 for the contract.\n\n### OTA updates (`ias update`)\n\nThe server speaks protocol 1 of expo-updates. Publishing:\n\n1. `ias update publish` takes a commit, exports it, signs one manifest per platform with the RSA key of 1Password and\n uploads the missing files.\n2. The server verifies the signature with the channel's public certificate and serves the exact bytes.\n3. The app uses `expo-updates` unchanged.\n\nThe private key never reaches the server. Channels have their own key; `--channel staging` signs with it, and a build\nfor that channel carries its certificate (the config plugin selects it from `IAS_BUILD_PROFILE`). Rollouts start at a\npercentage (`--rollout`), and the server pauses one whose new update fails to start more than the previous one.\n`ias update check` asks for the manifest as a device would and verifies the signature end to end.\n\n### Internal distribution (`ias dist`)\n\nAn internal build (an ad hoc IPA or an APK) gets an install page at `/install/<token>`: `itms-services` on iPhone or\niPad, a download on Android, and a QR on a computer. Each build has its link; the project's stable link always serves\nthe newest build per platform. Files live in the private bucket, uploaded with a presigned URL and downloaded with a\n15-minute URL.\n\n### Credentials and variables (`ias credentials`, `ias env`)\n\nSigning material is sealed on the developer's computer in `ias-vault-2`, for the team's recovery key and the runners\nof the list the team signed, and signed with the team's signing key. The server stores the envelope and its metadata,\nnever a secret. Expiries are tracked.\n\n`ias env` stores variables per environment. `EXPO_PUBLIC_*` is always plain because it ends up in the bundle; anything\nelse is sealed unless `--plain`. `ias env pull` writes only the plain ones to `.env.local`.\n\nPush credentials are the exception: they are the one secret the server must be able to use to send notifications. A\nmember uploads them and an owner approves it with a passkey; the server keeps them encrypted with\n`PUSH_CREDENTIALS_KEY`.\n\n### Builds and submissions (`ias build`, `ias submit`)\n\n`ias build` tars the commit, uploads it, and a runner compiles it. A store build waits for an owner's passkey approval\n(or for the members and count of the team's signed policy). `ias submit` sends a finished store build to App Store\nConnect or Google Play; in Play it is a **draft** by default, and serving a percentage or completing it\n(`--release-status`, `ias rollout`) needs the track policy that an owner sets. The review is requested in the store.\nBuild numbers are kept by the server (`ias version`), and `autoIncrement` takes the next.\n\nAfter an iOS store submission the build is in TestFlight: `ias testflight <build>` distributes it to existing groups\nand `ias testflight review <build>` (a separate approval) sends it to Beta App Review.\n\n### Push (`ias.push`, `ias push`)\n\nAPNs and FCM directly. Devices register with a session (tied to a user) or with the install key (not tied). Topics,\nsegments, translations and scheduling are in the management API §14. Sync notices (§39) are silent pushes that wake a\ntask and run one sync cycle.\n\n## The security model\n\n- **Passkeys only** for the panel; device authorization for the CLI.\n- **Two keys per team**: recovery (opens envelopes) and signing (signs documents and envelopes). Both private keys\n live in 1Password; the server only has the public ones.\n- **The CLI does not trust the server's keys**: before sealing it compares them with 1Password's and stops on a\n mismatch. A runner trusts only the team's signing key and the panel origin it pinned at registration.\n- **Approvals bind to the request**: the approval stores a hash of what was asked; if it changes, it is not applied.\n- **The approval policy is optional and signed locally**: `ias-policy-1` says who may approve what and how many; without\n one, one owner's passkey is enough, and `ias setup` never creates it. There is no delegation, no batch approval and\n no policy for OTA.\n- **Secrets never print**: not in stdout, stderr, `ps`, a command line or a file left behind. `ias` rejects a token or\n a PEM written in `ias.json`.\n\n## IAS and EAS, side by side\n\n| EAS | IAS |\n| --- | --- |\n| `eas build` | `ias build` (runner) or `ias build --local` |\n| `eas submit` | `ias submit` (a Play draft unless the track policy allows a rollout) |\n| EAS Workflows' `testflight` job | `ias testflight` (distribute) and `ias testflight review` (Beta App Review) |\n| Play staged rollout (by hand in Play Console) | `ias releases policy` + `ias rollout` |\n| `eas update` | `ias update publish` |\n| `eas channel:edit` / rollouts | `ias update channels`, `ias update rollout` |\n| `eas credentials` | `ias credentials generate|import|list|rotate|retire|revoke` |\n| `eas env` | `ias env list|set|pull|delete` |\n| `eas device:create` | Panel link + `ias devices sync` |\n| `eas build:resign` | `ias devices sync` (resigns the last internal iOS build) |\n| `eas project:init` | `ias init` |\n| Expo Push Service | `ias push send` and `ias.push` |\n| `eas.json` | `ias.json` (profiles, updates, distribution, submit, workflows) |\n| EAS project id | IAS application `code` |\n\n`ias migrate eas` walks the migration: profiles, a folder outside the project for `eas credentials`, importing the\ncredentials into the vault, build numbers, variables and a final checklist. It never deletes anything.\n\n## What IAS does not do\n\n- It does not host the app's backend.\n- It does not replace the App Store or Google Play review.\n- It does not sign a build on the server with a key it can read: signing material is sealed.\n- It does not send an OTA update to a build with a different runtime.\n- It does not accept a store submission without an owner's approval.\n",
7
+ "references/recipes.md": "# Recipes\n\nEach recipe is a sequence of commands, with what a person has to do marked. Run everything from the app's folder\nunless a step says otherwise. Add `--json` to read a result from a script.\n\n## This skill in a new project\n\n`ias init` leaves the project ready for OpenCode, Claude Code and Codex; `ias skill install` writes the files offline:\n\n```bash\nias init # opencode.jsonc: skills [https://ias.imagina.build/skills/]; .claude/skills/ias; .agents/skills/ias\nias init --no-skill # none of that\nias skill install # .opencode/skills/ias, .claude/skills/ias and .agents/skills/ias, from the CLI package, no network\nias skill install --global # ~/.config/opencode/skills/ias, ~/.claude/skills/ias and ~/.agents/skills/ias\nias skill install --opencode # only .opencode/skills/ias (--claude and --codex the same, and they combine)\nias skill update # install --force, when the skill changed\n```\n\nWhere each harness reads it: OpenCode, `.opencode/skills` or `~/.config/opencode/skills` (plus the catalog in its\nconfiguration); Claude Code, `.claude/skills` or `~/.claude/skills`; Codex, `.agents/skills` or `~/.agents/skills`\n(`~/.codex/skills` is legacy and still read, but not written). If one folder already has an older copy, `ias init`\nleaves it as it is, says so and prints `ias skill install --force`.\n\nIf the OpenCode configuration has comments (JSONC), `ias init` does not rewrite it: it prints\n`\"skills\": [\"https://ias.imagina.build/skills/\"]` for a person to add by hand. To refresh the skill after editing\n`.claude/skills/ias`:\n\n```bash\ncd packages/cli && npm run skill:sync # the copy ias skill install ships\nnode .claude/skills/ias/scripts/sync-cli-reference.mjs # references/cli-reference.md\n```\n\n## A new Expo app on IAS\n\n```bash\nias setup # the whole checklist and its state\nias setup next --all # writes ias.json, logs in, installs the SDK; stops at the first person\n```\n\nThen, in order, as the checklist asks:\n\n1. **Application** — `ias apps --create --code <code> --name \"…\" --services updates,distribution,push`, or\n `ias setup --create-app` (it takes the code from `ias.json` and the name and identifiers from `app.json`). Without the\n flag, an owner creates it in the panel (`/admin/applications`).\n2. **Services** — `ias setup next` turns push on and writes its install key, and makes the OTA and distribution\n projects, the OTA signing key (`certs/ota-certificate.pem`) and both publishing tokens in 1Password. The commands on\n their own:\n ```bash\n ias apps <code> --services updates,distribution,push\n ias update init # OTA project, key and token\n ias dist init # distribution project and token\n ```\n3. **Vault** — an owner runs `ias setup next` (it runs `ias vault init`) and approves the two kids with a passkey.\n4. **Credentials** — generate the Android upload key, and the iOS certificate and App Store profile; import only what\n was made outside:\n ```bash\n ias credentials generate android_upload_keystore\n ias credentials generate ios # distribution certificate + App Store profile (reuses what is valid)\n # only material made outside IAS:\n ias credentials import apple_distribution_certificate --p12 certificate.p12 --password op://Private/Apple/password\n ias credentials import apple_provisioning_profile --profile store.mobileprovision\n ias credentials import android_upload_keystore --keystore upload.jks --store-password op://Private/Android/password --key-alias upload\n ```\n5. **Stores** (optional) — `ias apps store <code>` shows the bundle id and the App Store Connect app record, and\n prints the filled form when it is missing; `--check` saves the Apple ID. Android: the app is created by hand in Play\n Console. Neither API can create the app.\n6. **Runner** — `ias runners code` makes the one-time code; on the runner Mac:\n ```bash\n ias-runner register --server https://ias.imagina.build --code <code>\n ias-runner install # optional: start at login\n ```\n Then, on a member's computer: `ias vault grant \"<runner name>\"`.\n7. **Build** — `ias setup next --build preview` (or `ias build --profile preview --platform all`, or `--local`).\n8. **Updates** — `ias update init` already left the key and token in 1Password and their references in `ias.json`;\n publish with `ias setup next --publish` (or `ias update publish --message \"First update\"`) and check with `ias update check`.\n9. **Distribution** — a preview build publishes itself: `ias dist list` gives the install page and stable link.\n10. **Push** — `ias setup next` turns push on and writes `push.installKey`; add `push.android.googleServicesFile` if\n Android, load the APNs and FCM credentials with `ias push credentials`, and check a device in the panel.\n11. **CI** — `ias tokens create --name \"GitHub Actions\" --scopes apps:read,builds:create` keeps the robot token in\n 1Password; set `IAS_TOKEN` in CI from that item.\n\nRe-run `ias setup` at any time; `ias setup check` exits 1 while something required is missing, which is the CI gate.\n\n## An existing app that already has `ias.json`\n\n```bash\nias setup check # what changed: a certificate, a runner, a service\nias doctor # the computer, the config and the SDK\nias sdk install --modules # after adding a service that needs an Expo module\n```\n\n## Moving from EAS\n\n```bash\nias setup --services updates,distribution # the target\nias init # ias.json from app.json and eas.json\nias migrate eas profiles --app verifire --server https://ias.imagina.build\nias migrate eas prepare # a folder outside the project, mode 700\n# in that folder, as a person (they ask for secrets):\nEAS_NO_VCS=1 eas credentials -p ios\nEAS_NO_VCS=1 eas credentials -p android\nias migrate eas import <folder> --dry-run # check what it will seal\nias migrate eas import <folder> # deletes the download afterwards\nias migrate eas version # build numbers, which never go down\nias migrate eas env # variables: EXPO_PUBLIC_* plain, the rest sealed\nias migrate eas checklist # what is left in expo.dev, the stores and the app\n```\n\n`eas credentials` and `eas env:pull` handle secrets and ask questions, so a person runs them. `ias` runs only\n`eas build:version:get` and `eas env:list`, which are read-only.\n\n## A signed OTA update\n\n```bash\nias update check # what a device would receive now, end to end\nias update publish --message \"Fix the calendar\" --rollout 10\nias update rollout <group> 50 # 1–99; 100 or `complete` finishes it\nias update retire <group> # the emergency brake\nias update restore <group> # serve it again\nias update promote <group> --to production # re-signs the content with the target channel's key\n```\n\nThe key must be the one of the channel's certificate and must live outside the project (1Password `op://`, or a\n`600` file named by `IAS_UPDATES_PRIVATE_KEY`). A change to native code changes the fingerprint and needs a new build.\n\n## An internal build and a new tester\n\n```bash\nias build --profile preview --platform all\nias dist list # the install page and the stable link\n```\n\nFor a new iPhone or iPad:\n\n1. The tester opens the registration link (panel: the application › Distribution › Devices) in Safari and installs\n the profile; iOS marks it \"Not signed\" because it only sends the UDID, model and name.\n2. A member approves it in the panel with a passkey.\n3. `ias devices sync` registers the approved devices in Apple, regenerates the ad hoc profile, seals and signs it, and\n asks a runner to re-sign the newest internal iOS build.\n\n```bash\nias devices sync --dry-run # the devices, the profile and the resign it would do: no Apple call, no vault write, no job\nias devices sync --yes # in a script, once the devices were checked in the panel\n```\n\n## A notification\n\n```bash\nias push send --title \"New shift\" --body \"…\" --to topic:shifts --dry-run # count first\nias push send --title \"New shift\" --body \"…\" --to topic:shifts\nias push messages # history and counts\nias push devices --platform ios # tokens masked to their ends\n```\n\nA device appears once the app has run `ias.push.start()`; with a session it is tied to the user, and the install key\nkeeps it registered without one. Sync notices only go to devices registered with a session.\n\n## A first store submission (iOS and Android)\n\nThe stores need the app to exist before a build can land. IAS automates what their APIs allow and guides the rest:\n\n```bash\nias apps store <code> # read-only: bundle id, app record, what is missing\nias apps store <code> --register-bundle-id # create the App ID in Apple when it does not exist\n# A person creates the app record in https://appstoreconnect.apple.com/apps with the printed form\nias apps store <code> --check # read the app record and save its Apple ID in the application\nias build --profile production --platform ios\nias submit <build> # no --asc-app-id needed after --check\n```\n\nAndroid needs the app created in Play Console with the package of `app.json`; the API cannot create it. Once it\nexists, `ias submit <build>` uploads the `.aab` as a draft on the requested track. A missing Play app fails as\n`play_app_missing`, not as a permission problem. A new personal Play account needs a closed test with 12 testers for\n14 days before production.\n\n## The iOS signing material: certificate and App Store profile\n\nApple allows one distribution certificate per team, and the App Store profile of each app must contain it. Both are\nmade on the Mac (only the CSR travels to Apple; the private key never leaves it) with the team's App Store Connect key\nfrom the vault:\n\n```bash\nias credentials generate ios --dry-run # the inventory and the plan: certificates, profiles, what it would seal\nias credentials generate ios # makes or reuses both, asks before writing, seals them in the vault\nias credentials generate ios --profile <uuid> # when Apple has several usable App Store profiles\nias credentials generate ios --sync-capabilities # enable missing capabilities from app.json entitlements\nias credentials generate ios --delete-previous # delete in Apple the profile the new one replaces\nias build --profile production --platform ios\n```\n\n- If a valid certificate (its private key in the vault, Apple still lists it) and an App Store profile that holds it\n both exist, it says \"nothing to do\" and writes nothing.\n- **Several valid certificates**: it stops and lists them with their SHA-1; choose with\n `ias credentials generate ios --certificate <sha1|id>`. It never picks the first.\n- **No profile, an `INVALID` one or an expired one**: it makes the profile (`IOS_APP_STORE`, one certificate, no\n devices) and seals it as the next version; the next store build uses it. The ad hoc profile of `ias devices sync` is\n untouched.\n- **Several usable App Store profiles** (`ACTIVE`, no devices, not expired, with the chosen certificate): it does not\n pick one. It lists them (UUID, name, state, expiry, certificate SHA-1, bundle id, devices) and stops; choose with\n `--profile <uuid>` or `--profile \"<exact name>\"`. A valid one is reused and only sealed if it is not in the vault:\n no `POST /v1/profiles`. An `INVALID`, expired, device-listing or other-certificate one is refused with the reason\n and the exact fix (`profile_unusable`).\n- **Missing App ID capabilities**: `--sync-capabilities` reads `app.json` › `expo › ios › entitlements`, enables only\n the capabilities Apple lacks (never disables one), re-reads them and makes the store profile again. Apple\n invalidates **every** profile of the App ID, the ad hoc one too, so it warns and asks again; finish with\n `ias devices sync --force`. Entitlements the public API cannot enable (push, associated domains, App Groups, iCloud\n containers, Apple Pay merchant ids) stop it with `capability_unsupported` and the portal link. `--dry-run` enables\n nothing; no entitlements in `app.json` means nothing to sync.\n- **The old profile in Apple**: it stays there by default. `--delete-previous` deletes the one IAS had sealed and the\n new one replaces, only after the new one is sealed and uploaded, after asking again, and never the ad hoc profile,\n one another credential still holds, a dry run, or after a failure. A 404 is already gone.\n- **Apple's limit**: when a new certificate is needed but Apple still has one, it stops with\n `certificate_limit_reached`, lists the certificates and revokes nothing. If the old one has to go, revoke it with\n `ias credentials revoke <id>` (an owner approves it with a passkey) and run it again. A new certificate also\n invalidates the ad hoc profiles that hold it: run `ias devices sync --force` afterwards.\n- `--rotate` makes a new certificate even if the current one is valid and **asks an owner for a passkey** before\n touching Apple: it keeps the credential's id, seals the next version and records the authorization;\n `--if-expiring <days>` (30 by default, 0 never) decides when to renew; `--reuse` never makes one;\n `--no-profiles` makes or reuses only the certificate and leaves the App Store profile for\n `ias credentials generate ios`. A `403` means the API key needs the Admin role (or App Manager with access to\n Certificates, Identifiers & Profiles), not an Individual Key.\n- 1Password keeps one item per certificate (`op://IAS/<team> Apple Distribution <serial>`) with the `.p12` and its\n password: it is the recovery copy of the old material.\n\n```bash\nias credentials revoke <credential id> --dry-run # the certificate, its profiles and the impact\nias credentials revoke <credential id> # confirm, then an owner's passkey; DELETE in Apple + retire\nias credentials generate ios # make the substitute and the App Store profiles again\nias devices sync --force # the internal (ad hoc) builds need the new certificate too\n```\n\n- A **rejected or expired approval deletes nothing** in Apple and retires nothing. If Apple no longer has the\n certificate, it still retires the credential and says it was already gone. `--yes` skips the terminal question,\n never the passkey. Already-published apps keep working; internal builds stop installing; builds uploaded but not\n sent may become Invalid Binary. The team is left without a certificate until `ias credentials generate ios`.\n\n`ias setup` shows the same state in its optional `signing` step without calling Apple, and its fix is that one command.\n\n## TestFlight: distribute a build, then (maybe) Beta App Review\n\nUploading an IPA to App Store Connect is what puts a build in TestFlight; Apple processes it for 10–15 minutes. IAS\ndoes not touch it until asked, and it never does both steps at once:\n\n```bash\nias submit <build> --wait # ends with: build in TestFlight; ias testflight <build> distributes it\nias testflight <build> # to submit.<profile>.ios.testflight.groups of ias.json\nias testflight distribute <build> # the same, spelled out (same options, same approval)\nias testflight status <build> # read-only: processing, groups, locales, Beta App Review\nias testflight review <build> # only for external testers: its own passkey, Apple may reject it\n```\n\n- **No groups configured**: `ias testflight <build>` lists the groups the app has in App Store Connect and prints the\n exact command; it never creates a group (that is a person in App Store Connect › TestFlight).\n- `--groups \"QA Team\" --groups \"Clientes\"`, `--what-to-test \"Try signup\"` with `--locale en-US`, or\n `--notes-file notes.json` (`{ \"es-ES\": \"…\", \"en-US\": \"…\" }`, at most 8 locales, 4000 characters each).\n- `--notify` tells the testers (off by default). `--dry-run` prints the plan and creates nothing; `--wait` follows the\n job. The plan is approved with a passkey and the runner applies it with the `asc_api_key` at hand: the server never\n sees the `.p8`.\n- **Distributing does not send to Beta App Review.** External testers need the second command, its own approval and\n the app's test information (description, feedback email, contact), which the runner checks first\n (`testflight_info_missing` if missing).\n- A build can be tested for **90 days**; a `LOGIN_REQUIRED` app needs a demo account; a build missing export\n compliance is not distributed and the runner says `testflight_export_compliance`.\n- `ias testflight status` never mutates anything. `GET /builds/{id}/testflight` is what the panel and the CLI read;\n `testflight_builds` keeps the ids and states, never a key.\n\n## A Google Play rollout, step by step\n\n`ias submit` stays a draft on its own; serving a percentage is an explicit decision with a per-track policy and a\npasskey for every change:\n\n```bash\nias submit <build> --track production --wait # a draft: the review is asked in Play Console\nias releases view production --refresh # read-only: what Play has now (no passkey)\nias releases policy production --mode staged --max-percent 50 --step 20 --min-minutes 60 # an owner\nias rollout production --percent 10 # another passkey; the runner checks Play before writing\nias releases view production # inProgress 10 %, in review or approved\nias rollout production --percent 30 # +20, the step of the policy\nias rollout production --halt # stop new users; who has it keeps it\nias rollout production --resume # back to the same percentage\nias rollout production --complete # 100 %, needs a full policy\n```\n\n- **Without a policy a track only takes drafts**, exactly as before. `ias releases policy <track>` without flags shows\n it or the command that creates it; the panel (application › Settings › Google Play) edits the same row.\n- The policy is the permission: `draft`, `staged` (ceiling, step and wait) or `full` (also 100 %). The server enforces\n it; a rollout never goes down (use `--halt`), and repeating the current state answers `{changed:false}` without a job.\n- Every action carries the track, the action, the percentage and the `versionCode` in the canonical text; the runner\n verifies that release in Play and merges the track instead of replacing it. A change made by hand in Play Console is\n shown as drift the next time IAS reads it.\n- **Still human:** Google's review (hours to 7 days, restarted by each submission), managed publishing, the first\n release of an app (Play does not stage it) and the store listing.\n- `ias releases list` is the no-flag view; `ias rollout` without flags is read-only and tells the next step. Every\n error names the command that fixes it (`ias releases policy <track> …`, `ias releases view <track> --refresh`).\n\n## An approval policy (first use, and later changes)\n\nWithout a policy, a store build or a submission is approved by **one owner with a passkey**, as always. A policy lets\nthe team say *who* may approve *what* and *how many*, and the server and the runner apply it: no rule means denial,\n`count: 2` needs two distinct members over the same hash, and a member outside the rule cannot approve.\n\n```bash\nias vault approvers # the owners' passkeys must be signed first: the policy names them\nias policy init # ias.policy.json: one * rule, one approval, every owner with a passkey\nias policy check # shape, signature, version, members, date and coverage; writes nothing\nias policy sign # signs with the 1Password key, shows the diff and the hash, asks an owner for a passkey\nias policy show # the stored policy, verified, with its rules and members\n```\n\nThen edit `ias.policy.json` (it is the source, keep it in git) to say what the team needs, and repeat `check` and\n`sign` with a **higher** `version`:\n\n```json\n{\n \"format\": \"ias-policy-1\",\n \"version\": 2,\n \"not_after\": \"2027-04-28T00:00:00Z\",\n \"rules\": [\n { \"id\": \"ios-production\", \"app\": \"*\", \"platform\": \"ios\", \"profile\": \"production\", \"type\": \"build\", \"target\": \"*\", \"track\": \"*\", \"require\": { \"members\": [\"<id>\", \"<id>\"], \"count\": 2 } },\n { \"id\": \"play-internal\", \"app\": \"*\", \"platform\": \"android\", \"profile\": \"production\", \"type\": \"submit\", \"target\": \"google_play\", \"track\": \"internal\", \"require\": { \"members\": [\"<id>\"], \"count\": 1 } },\n { \"id\": \"todo\", \"app\": \"*\", \"platform\": \"*\", \"profile\": \"*\", \"type\": \"*\", \"target\": \"*\", \"track\": \"*\", \"require\": { \"members\": [\"<id>\", \"<id>\"], \"count\": 1 } }\n ]\n}\n```\n\n```bash\nias policy simulate --platform ios --profile production --type build # the rule that would win, or the denial\nias policy simulate ias.policy.json --platform ios --profile production --type build # the file as a positional (--file still works)\nias policy diff # what changes from the policy the server has\nias policy status # verified, expiring, uncovered, in sync with the file\nias policy show --item \"op://IAS/IAS vault ias.imagina.build\" # check/status/diff/show/sign take --item too\n```\n\n- The most specific rule wins; two rules that tie make the document invalid. No rule means denial, never a default.\n- A `target` and a `track` are only allowed on a `submit`; a track only with `target: google_play`.\n- The version only goes up. To go back to an earlier policy, sign a **higher** version with that content.\n- `ias policy sign` needs an owner's passkey after the signature: the 1Password key alone cannot change the policy.\n- **What is still missing:** delegation with `ias-approvers-2` (a developer or release manager in a rule), batch\n approval with one passkey over several hashes, and a policy for OTA. A team without a policy is still in the legacy\n one-owner mode: the cut-off date that retires it is a later phase.\n- The server refuses a store job no rule covers with `409 policy_unmatched` **before the upload URL**, and a rotated\n policy expires waiting jobs with `policy_changed`; the runner verifies the policy and the assertions again.\n\n## CI\n\n```bash\nexport IAS_TOKEN=iasr_… # a robot token: ias tokens create (an owner), kept in 1Password\nias build --profile production --platform all --detach\nias builds list --status awaiting_approval\n```\n\nA robot token sees only its application. It cannot create another robot token, change the vault or approve a store\nbuild; an owner does that with a passkey. Publishing from CI needs the OTA token and the private key, which is why\nOTA publishing usually stays on a Mac.\n\n## Rotating a certificate or a key\n\n- **OTA signing certificate**: `php artisan ota:project <project> --certificate=<new> --keyid=<new> --force` on the\n server, then update `ias.json`, then a **new build**: from that moment the server only accepts the new key in that\n channel, and old builds reject it.\n- **Distribution certificate or profile**: `ias credentials generate ios --rotate` makes a new certificate and the App\n Store profile with it, keeps the credential's id, seals the next version and asks an owner for a passkey first (the\n server only accepts the new SHA-1 with that approval). Apple's limit means the old certificate may have to be revoked\n first: `ias credentials revoke <id>` deletes it in Apple and retires the credential, also with an owner's passkey.\n Then `ias devices sync --force` for the ad hoc profile and a new store build. `ias credentials rotate <id>` is still\n the way to replace material imported by hand, and asks for the same approval when the SHA-1 changes.\n- **Vault keys**: changing them is not in v1 of the management API; do not attempt it. `ias vault status` detects a\n server that has other keys and stops sealing.\n\n## Unblocking a teammate\n\n- **`ias vault status` says MISMATCH**: do not seal anything. Compare the 1Password item with the server; if the\n server changed, treat it as a security incident and tell the platform owner.\n- **A runner was compromised**: revoke it in the panel first (`/admin/runners`), then `ias vault revoke <runner>` to\n sign a list without it and remove its wraps. Renew what it could read.\n- **The OTA rollout paused itself**: check the failed-update counters in the panel, fix the update and publish a new\n one; `ias update restore` only serves the old group again.\n\n## Trying a mutating command first\n\n`--dry-run` prints exactly what a command would do and changes nothing: no API write, no 1Password item, no vault\nwrite, no Apple call and no runner job. It exits 0, wins over `--yes`, and with `--json` returns a stable document that\nstarts with `{ \"dry_run\": true, … }`.\n\n```bash\nias apps --create --code my-app --name \"My app\" --services updates,distribution --dry-run # the plan ias setup --create-app --dry-run prints\nias credentials retire <id|name> --dry-run # by id, or by name when only one credential has it\nias tokens revoke <id|name> --dry-run\nias vault init --dry-run # the keys it would make and register; no item, no approval\nias vault approvers --dry-run # the ias-approvers-1 version it would sign\nias vault grant <runner> --dry-run # the runner list and the envelopes it would wrap\nias vault revoke <runner> --dry-run # the list without it and the envelopes it would change\nias webhooks create <url> --events all --dry-run # no secret is shown\nias webhooks delete <id> --dry-run\nias github link <owner/name> --dry-run\nias devices sync --dry-run\n```\n\n`ias credentials retire` and `ias tokens revoke` take the name when only one is called that way; with two of the same\nname they list the ids and ask for one, they never pick silently.\n",
8
+ "references/sdk.md": "# The SDK `@imagina-build/ias`\n\nThe authoritative long version is `packages/sdk/README.md`. This is the working summary.\n\n## Install\n\n```bash\nias sdk install --modules\n```\n\nIt uses the app's package manager (from `packageManager` in `package.json` or its lockfile: npm, yarn, pnpm or bun),\nadds the config plugin to `app.json` and installs the Expo modules the services need. `--from <tgz|folder|version>`\nchooses the source; from a clone of this repository it uses `packages/sdk` (build it first with\n`npm run build --prefix packages/sdk`). Without either, it installs `@imagina-build/ias@^<ias minor>` from npm.\n\nEntries:\n\n| Import | Contents | Peer modules |\n| --- | --- | --- |\n| `@imagina-build/ias` | `ias`, `ias.sync`, `ias.push`, `iasAppConfig`, typed APIs and types | none |\n| `@imagina-build/ias/sqlite` | `openSqliteStore`, `SqliteStore` | `expo-sqlite` |\n| `@imagina-build/ias/push` | `expoPushDevice`, `registerPushTask`, `hideSyncNotices` | `expo-notifications`, `expo-task-manager` |\n| `@imagina-build/ias/plugin` | The Expo config plugin | none |\n\nRequirements: Hermes with `TextEncoder` (RN 0.74+), `fetch`, and a UUID generator for new rows (`expo-crypto`'s\n`randomUUID`; RN does not provide `crypto.randomUUID`).\n\n## Configure once, at startup\n\nThe function must be safe to call twice: the push task calls it too when iOS launches the app for a silent push.\n\n```ts\n// src/ias.ts\nimport Constants from 'expo-constants';\nimport * as Crypto from 'expo-crypto';\nimport * as SecureStore from 'expo-secure-store';\nimport { ias, iasAppConfig, type PushState, type Session } from '@imagina-build/ias';\nimport { expoPushDevice } from '@imagina-build/ias/push';\nimport { openSqliteStore } from '@imagina-build/ias/sqlite';\n\nconst config = iasAppConfig(Constants.expoConfig?.extra);\n\nconst secure = <T>(key: string) => ({\n load: async () => JSON.parse((await SecureStore.getItemAsync(key)) ?? 'null') as T | null,\n save: async (value: T | null) => (value === null ? SecureStore.deleteItemAsync(key) : SecureStore.setItemAsync(key, JSON.stringify(value))),\n});\n\nlet starting: Promise<void> | null = null;\nexport function startIas(): Promise<void> {\n starting ??= (async () => {\n ias.configure({\n server: config.server,\n sync: {\n application: 'norte', // code or public_id; or `template` to sign in only with the email\n mode: 'spaces', // 'personal' is the frozen v1 POST /sync\n transport: 'batch', // or 'space': one POST /spaces/{space}/sync per space\n deviceName: 'iPhone de Marta',\n uuid: Crypto.randomUUID,\n store: await openSqliteStore('app.db'),\n session: secure<Session>('ias-session'),\n },\n push: {\n device: expoPushDevice(),\n installKey: config.installKey,\n state: secure<PushState>('ias-push'),\n },\n });\n })();\n return starting;\n}\n```\n\nThen, once per startup in the root layout:\n\n```ts\nawait startIas();\nvoid ias.push.start();\nias.sync.start({ intervalMs: 5 * 60_000 });\n```\n\n- Without `store`, rows live in memory. Without `session`, the session is memory too. Without `state`, `ias.push`\n re-registers on every launch and loses `replaces` and the topics to clear after a sign-out.\n- `ias.push` takes the session from `ias.sync`; `expoPushDevice()` reads the APNs environment, the app version, the\n locale, the timezone and the updates channel/runtime.\n\n## Sync\n\n```ts\nawait ias.sync.signIn({ email, password }); // derives auth_key from the prelogin\nawait ias.sync.signIn({ email, password, template: 'jscheduler' }); // the session brings its application\nawait ias.sync.register({ email, password, name, invitation, keys });\nawait ias.sync.signOut(); // revokes the token and forgets everything\n\nconst row = await ias.sync.write({ space, section: 'bro/3f9a', type: 'bro.prefs', data: { theme: 'dark' } });\nawait ias.sync.remove({ space, id: row.id }); // tombstone, never a DELETE\nconst one = await ias.sync.get({ space, id });\nconst tasks = await ias.sync.list({ space, type: 'task' });\nias.sync.syncSoon();\n```\n\n- `write` fixes `updated_at` once with a hybrid logical clock (`max(now, highest seen + 1)`) and validates what the\n server would reject (UUID, type length, section format, JSON ≤ 256 KiB).\n- Read rows with SQLite directly through the `plain` column (the clear JSON, also in encrypted spaces); write only\n through `ias.sync.write`.\n- One cycle at a time; calls during a cycle share the next. Network, `429` and `5xx` schedule a retry with\n `Retry-After` or exponential backoff and jitter (2 s…5 min).\n- Triggers belong to the app: `ias.sync.start({ intervalMs })`, `AppState` on `active`, NetInfo on connect, and\n `syncSoon()` after an edit.\n\n`sync()` returns a `CycleReport` (`rounds`, `sent`, `received`, `changed`, `dropped`, `quarantined`, `locked`,\n`rejected`, `unreadable`, `missing`, `corrupt`) and `subscribe(listener)` emits `signed-in`, `changed`, `synced`,\n`failed` and `signed-out`.\n\nResponses the engine handles by itself: `results`, `changes`, `has_more`, cover changes, `403`/`404` on a space,\n`403 write_scope_denied`, `409 stale_key_version`, `422` (one row at a time to isolate it), `422` on `last_seq`\n(corrupt space), `413` (split the payload), quotas, `401`, `429` and `5xx`. Quarantined rows: `ias.sync.quarantined()`\nand `ias.sync.retry({ space, id })`.\n\n## Encryption hooks\n\nThe SDK knows the protocol, not the cryptography. The app supplies `SyncCrypto`:\n\n```ts\nconst crypto: SyncCrypto = {\n encryptRow: ({ key, aad, plaintext }) => sealEnvelope(sodium, key, plaintext, aad),\n decryptRow: ({ key, aad, data }) => openEnvelopeText(sodium, key, data, aad),\n deriveSectionKey: (parent, segment, version) => sodium.crypto_generichash(32, utf8(`sync-section:${segment}:${version}`), parent),\n openKeyring: async ({ space, entries, signers, held, state, api }) => ({ keys, refused, extra }),\n needsKeyring: (state) => Date.now() - (state.extra?.checked as number ?? 0) > 86_400_000,\n derivePassword: async ({ password, kdf_salt, kdf_params }) => authKey(password, kdf_salt, kdf_params),\n};\n```\n\nEncryption only exists in `spaces` mode. An encrypted space without its current keys stays `locked`: the engine does\nnot advance its cursor and does not quarantine its rows.\n\n## Store\n\n`MemoryStore` and `SqliteStore` (`@imagina-build/ias/sqlite`). The schema is one `items` table plus `sync_spaces`,\n`sync_keys`, `sync_quarantine` and `sync_meta`. Every response and its cursor are saved in one transaction. If the\ndatabase is not encrypted at rest, the hook should return a key identifier that only the system keychain opens, not\nthe key in clear (jScheduler uses SQLCipher).\n\n## Push\n\n`ias.push` registers the device, keeps its `id`, handles token changes with `replaces`, re-registers on launch (and at\nleast once a day), ties the device to the user on `signIn`/`register`/`recover` and unregisters on `signOut` before\nrevoking the token. `start()` never throws: `status` is `registered`, `disabled`, `refused`, `unauthenticated` or\n`waiting`; events are `registered`, `unregistered`, `failed` and `sync-notice`.\n\nThe silent sync notice is handled by a task defined at module scope and imported before the router:\n\n```ts\n// src/push-task.ts\nimport { registerPushTask } from '@imagina-build/ias/push';\nimport { startIas } from './ias';\nregisterPushTask({ ready: startIas });\n```\n\n```ts\n// index.ts\nimport './src/push-task';\nimport 'expo-router/entry';\n```\n\nWrap the app's notification handler with `hideSyncNotices` so a sync notice that arrives with a title is never shown.\n\nFor an app with its own sync client (jScheduler before its pilot), configure `ias.push` with `session` and\n`onSyncNotice` and call `signedIn()`, `signingOut()` and `signedOut()` from the app.\n\n## Updates\n\nThe SDK has no updates client: the server speaks the expo-updates protocol and the app uses `expo-updates` as usual.\nThe plugin writes the configuration. Four lessons from jScheduler:\n\n- A development build may not register the native module: check `requireOptionalNativeModule('ExpoUpdates')` before\n importing `expo-updates`, or the app crashes on load.\n- A cold start that downloaded an update may already be running it: compare with `Updates.updateId` before offering it.\n- Do not reload in the middle of a form, a sheet or a sync cycle.\n- Failures are reported by `expo-updates` itself; the server pauses a rollout that fails more than the previous one.\n\n## The config plugin\n\n`\"plugins\": [\"@imagina-build/ias\"]` in `app.json`. It reads `ias.json` next to it and changes only the config object:\n\n| From `ias.json` | Into the build |\n| --- | --- |\n| `app`, `server`, `push.installKey` | `extra.ias` (read with `iasAppConfig`) |\n| `server` + `app` + `updates` | `updates.url = <server>/api/v1/ota/<app>/manifest` (only if `app.json` has none) |\n| `updates.signing` | `updates.codeSigningCertificate` and `codeSigningMetadata.keyid` |\n| `updates.channels.<channel>` | The certificate and keyid for a build of that channel |\n| The build's channel | `updates.requestHeaders['expo-channel-name']` |\n| — | `runtimeVersion: { policy: 'fingerprint' }` if the app has none |\n| `push` | `ios.entitlements['aps-environment']` |\n| `push.background` (default true) | `ios.infoPlist.UIBackgroundModes` with `remote-notification` |\n| `push.android.googleServicesFile` | `android.googleServicesFile` (the file must exist) |\n\nA value in `app.json` that contradicts `ias.json` is an error, never an override. With an `app.config.*`, `ias` cannot\nread or rewrite it and says what to add by hand.\n\n## Tests in this repository\n\n```bash\nnpm test --prefix packages/sdk\nnpm run typecheck --prefix packages/sdk\n```\n\nThey cover the 73 sync routes against recorded conversations, the frozen v1 contract, the engine (offline queue,\nconflicts, retries, encryption, spaces), the store, LWW, push (12 recorded exchanges, errors, topics, sync notices),\nthe Expo adapter, the plugin and the main entry point.\n",
9
+ "references/troubleshooting.md": "# Troubleshooting\n\nEvery `ias` error has the shape «what happened: how to fix it», and with `--json` it is\n`{ \"error\": { \"message\", \"fix\", \"code\" } }`. Read `code` first.\n\n## Identity and access\n\n| Message or code | What it means | What to do |\n| --- | --- | --- |\n| `not logged in to <server>` / `not_logged_in` | No token for that origin | `ias login` (or `IAS_TOKEN` on CI). A login is per server: `ias.imagina.build` and `sync.domibi.com` are different origins. |\n| `the token is a publishing token of a project (ota_…/dist_…)` | The management API does not take project tokens | Use a personal token or an application robot token (`iasr_…`). |\n| `role_forbidden` | The action needs an owner, or a personal token instead of a robot one | Ask an owner, or log in with your own passkey. |\n| `scope_missing` | The robot token lacks the scope | An owner makes a new one with `ias tokens create --scopes …` (or chooses them in application › Settings › Access): a token's scopes cannot change. |\n| `application_not_found` | The code of `ias.json` is not an application this token sees | `ias apps` lists them; check `app`, or create it with `ias apps --create` / `ias setup --create-app`. |\n| `application_exists` | The code already exists | Its code cannot change: use that application, or choose another code. |\n| `service_in_use` | A service still has pieces (an OTA project, builds…) | Remove them first; a service cannot be turned off while it has data. |\n| `push_not_enabled` | Push is off for the application | `ias apps <code> --services …` including `push`, or `ias setup`. |\n| `install_key_missing` | Push is on but has no install key | `ias setup next` makes it, or `POST /apps/{code}/push/install-key`. |\n\n## Configuration and secrets\n\n| Message | What it means | What to do |\n| --- | --- | --- |\n| `ias.json has a secret written in it` | A token or PEM is in the file | Move it to 1Password (`op://…`) or `IAS_TOKEN`; then **rotate it** if it was committed. |\n| `ias.json is not valid: …` | The schema rejects a field | `ias doctor` shows it; `packages/cli/schema/ias.schema.json` is the reference. |\n| `no private key` / `no publishing token` | The OTA command has no key or token | Set `updates.signing.privateKey` / `updates.token` as `op://` references, or the environment variables. If the token was lost, rotate it with `ias update init` (it creates a new one only when there is none) or `POST /apps/{code}/updates/token`. |\n| `project_exists` when making the OTA project | The application already has a project with another certificate | Rotating the certificate needs a new build: use `php artisan ota:project --force` with the new certificate, or keep the key the builds carry. |\n| `project_not_found` | The app has no OTA or distribution project yet | `ias update init` or `ias dist init` (an owner with a personal token). |\n| `the private key can be read by other users` | A key file is not `600` | `chmod 600 <file>`; a key inside the project is not allowed. |\n| `updates.url in app.json (…) is not the manifest of an IAS OTA project` | The build points at EAS Update or at another path | It must be `<server>/api/v1/ota/<project>/manifest`; changing it needs a new build. |\n\n## Vault and credentials\n\n| Message | What it means | What to do |\n| --- | --- | --- |\n| `the vault of <server> has no keys` / `vault_not_ready` | No `ias vault init` yet | An owner runs `ias vault init` and approves the two kids with a passkey. |\n| `ias vault status` says `MISMATCH` | The server's public keys differ from the 1Password item | Stop. Do not seal. Compare the kids; if the server changed, treat it as an incident. |\n| `the recovery private key of <item> is missing or does not match` | The 1Password item was edited or replaced | Restore it from 1Password history, or move it away and run `ias vault init` again (only if the server has no keys). |\n| `credential …: —` / `credential_unreadable` | The file, password or alias is wrong | Check the `.p12`, `.mobileprovision`, `.jks` or `.p8`; passwords come as `op://…`, `env:NAME` or `-`, never inline. |\n| `it needs an owner of the team` when sealing | A member without the role | Ask an owner; the private keys are shared through the 1Password item. |\n| `keytool` not found | No JDK | Install one (`brew install openjdk`), then `ias credentials generate android_upload_keystore`. |\n| `already has an upload key` | The 1Password item exists | It is already the key of the app; use it, or pass `--force` and make a new upload. |\n\n## Builds\n\n| Message | What it means | What to do |\n| --- | --- | --- |\n| `the working tree is not clean` | `ias build` builds a commit, not the folder | Commit or stash; the build must include everything. |\n| `there is no runner in the list it signed` | The runner registered but no member granted it | `ias vault grant \"<runner>\"` after comparing the kid with the one `ias-runner register` printed. |\n| `runner_revoked` | The runner was revoked in the panel | Register it again with `ias-runner register --force`; it gets new keys. |\n| A store build stays in `awaiting_approval` | A store build needs a passkey | An owner opens the approval URL and compares the hash with the panel. |\n| `build_not_found` | The build belongs to another application or token | `ias builds list` with the token of the right application. |\n\n## OTA updates\n\n| Message or code | What it means | What to do |\n| --- | --- | --- |\n| `signature_invalid` | The key does not match the channel's certificate | The private key in `ias.json` must be the one of the certificate the builds carry. |\n| `signature_key_mismatch` | The keyid differs from the project's | Align `updates.signing.keyId`, `app.json`'s keyid and the server's channel. |\n| `created_at_not_newer` | The server already serves something newer | Publish a new commit, or `ias update republish <group>` (it signs a new `createdAt`). |\n| `created_at_in_future` | This Mac's clock is ahead | Set the date and time automatically. |\n| `rollout_in_progress` | Another group is rolling out | Complete it (`ias update rollout <group> complete`) or retire it first. |\n| `assets_missing` | The manifest references files the server does not have | Publish again; it uploads the missing files. |\n| The app does not see an update | Runtime mismatch, or the build is older than the update | Check `ias update check`; a native change (fingerprint) needs a new build. Updates published before a build do not reach it. |\n| A rollout paused itself | The new update fails to start more than the previous one | Look at the panel's failed-update counters; fix and publish a new group. |\n\n## Distribution\n\n| Message | What it means | What to do |\n| --- | --- | --- |\n| The install page opens but iOS does not install | The iPhone is not in the ad hoc profile | Approve the device in the panel and `ias devices sync`; then use the new build. |\n| `server … is not https` | iOS only installs over https | Use the https origin (localhost or `.test` are the only exceptions). |\n| `build_size_mismatch` / `build_hash_mismatch` | The upload was cut or the file changed | Upload it again with `ias dist upload <file>`. |\n| The stable link serves an old build | Builds are newest-first and retired ones are skipped | `ias dist list`; a new upload becomes the stable link. |\n\n## App Store Connect and TestFlight\n\n| Message or code | What it means | What to do |\n| --- | --- | --- |\n| `asc_app_missing` | There is no app record for the bundle id, and neither Apple's public API nor IAS can create it | A person creates it at `appstoreconnect.apple.com/apps` with the form `ias apps store <code>` prints, then `ias apps store <code> --check` saves its Apple ID. |\n| `asc_app_mismatch` | The Apple ID saved in IAS belongs to another app | `ias apps store <code> --check` reads the right one for the bundle id. |\n| `bundle_id_not_in_apple` / `bundle_id_taken` | The App ID is missing, or already exists in Apple | `ias apps store <code> --register-bundle-id` registers it when it does not exist; if it is taken, use it or choose another bundle id. |\n| `asc_key_forbidden` / `asc_forbidden` | The App Store Connect key cannot do this | A team key with Admin or App Manager (Certificates, Identifiers & Profiles for identifiers): an Individual Key does not work. |\n| `certificate_limit_reached` | Apple still has a distribution certificate and refuses another | It revokes nothing: revoke the old one with `ias credentials revoke <id>` (an owner's passkey) and run `ias credentials generate ios` again. |\n| `profile_ambiguous` | Apple has several usable App Store profiles of the bundle id | `ias credentials generate ios --profile <uuid\\|\"exact name\">`; it never picks one. |\n| `capability_unsupported` | An entitlement needs the Apple Developer portal | Enable it by hand in Apple Developer › Identifiers and run the command again; the public API cannot enable push, associated domains, App Groups, iCloud or Apple Pay. |\n| `testflight_not_ready` | There is no store iOS build of IAS in App Store Connect | `ias build --platform ios --profile production`, then `ias submit <build> --wait`; `ias testflight status <build>` shows what is there. |\n| `testflight_info_missing` | Beta App Review needs the app's Test Information | Complete TestFlight › Test Information in App Store Connect (beta app description, feedback email, contact, demo account if it needs a login); the error links to it. |\n| `testflight_group_unknown` / `testflight_groups_missing` | A group of the plan does not exist, or none was given | `ias testflight status <build>` lists the groups the app has; `--groups \"<name>\"` names an existing one. IAS never creates groups. |\n| `testflight_export_compliance` | Apple does not distribute a build without the encryption answer | Answer export compliance in App Store Connect › TestFlight. |\n| `testflight_already_reviewed` | The build is already in Beta App Review | `ias testflight status <build>`; a rejected build needs a new submission, not another review. |\n\n## Google Play releases and rollouts\n\n| Message or code | What it means | What to do |\n| --- | --- | --- |\n| `play_app_missing` | Play has no app with that package | A person creates it in `play.google.com/console` and completes its setup; the API cannot create it. |\n| `release_policy_required` | The track has no policy, so it only takes drafts | An owner: `ias releases policy <track> --mode staged --max-percent 50 --step 20` (or `full`). |\n| `release_not_allowed` / `release_percent_above_cap` / `release_step_too_big` / `release_too_soon` | The policy of the track does not allow that move | `ias releases policy <track> …` changes it (an owner); `ias releases view <track>` shows the state and the wait. |\n| `release_percent_invalid` | A rollout serves a whole percentage from 1 to 99 | Use `--complete` (or `ias submit --release-status completed`) for 100 %. |\n| `release_state_changed` | Play is not what IAS last read (a change made by hand shows as drift) | `ias releases view <track> --refresh` reads Play again (read-only, no passkey). |\n| `release_not_published` | Play does not stage the first release of an app | Complete it in Play Console, or read the state with `--refresh` if the app published before IAS. |\n| `release_in_review` / `release_managed_publishing` | A review is in progress, or Play keeps the change until a person publishes | Wait, or publish in Play Console; IAS never simulates a publication. |\n\n## Approval policy\n\n| Message or code | What it means | What to do |\n| --- | --- | --- |\n| `policy_unmatched` | With a policy, a store job no rule covers is denied (before the upload URL) | `ias policy simulate …` names the missing rule; add it and sign a higher version. |\n| `policy_changed` | The policy rotated while the job waited, so the job expired | Run the command again; `ias policy show` says what the policy is now. |\n| `policy_version_backwards` | The version does not go above the server's | `ias policy init --version <n>` (a higher one) and `ias policy sign`; the version only goes up. |\n| `policy_missing` | The stored policy expired or does not verify | `ias policy status` says what is wrong; `ias policy init` and `ias policy sign` make a valid one. |\n| A rule cannot be satisfied | It names members without a passkey, or a `count` above them | `ias vault approvers` signs the current owners' passkeys, or lower the `count`. |\n\n## Push\n\n| Symptom | What it means | What to do |\n| --- | --- | --- |\n| `status: unauthenticated` | No session and no install key | Add `push.installKey` to `ias.json` and rebuild (the plugin writes it into `extra.ias`). |\n| `status: disabled` | `403 push_not_enabled` | Push is off for the application or the template; turn it on in the panel. |\n| Android has no token | No `google-services.json` | Set `push.android.googleServicesFile` and rebuild. |\n| iOS works in development but not in the store build | APNs environment (`sandbox` vs `production`) | `push.ios.apsEnvironment` or the profile; the plugin writes the entitlement. |\n| Sync notices do not arrive | No session, the template has them off, or the task is not registered | Register the device with a session, check the template's \"Avisos de cambios\", and import the push task before the router. |\n\n## SDK\n\n| Message | What it means | What to do |\n| --- | --- | --- |\n| `@imagina-build/ias x is in package.json, but not installed` | The lockfile and `node_modules` disagree | `<package manager> install`. |\n| `ias x works with @imagina-build/ias y` | The SDK and the CLI read `ias.json` with different rules | `ias sdk install` (same minor while 0, same major from 1). |\n| `app.json does not have its plugin` | The plugin is what writes `ias.json` into the build | `ias sdk install`; then a new build. |\n| `expo config` fails after adding the plugin | A contradiction between `app.json` and `ias.json`, or a missing certificate/`google-services.json` | The error names the field; the plugin never overrides `app.json` silently. |\n| Rows disappear after a restart | No `store` in `ias.configure` | `openSqliteStore('app.db')` with `expo-sqlite`. |\n\n## The computer\n\n`ias doctor` checks Node (22+), `git`, `op`, Xcode and its tools (`plutil`, `security`, `sips`, `unzip`), `aapt2`, the\nconfiguration and the SDK. A `fail` means a command will not work here; a `warn` means it may be needed later. It never\nopens a secret: an `op://` reference is checked for its form, not read.\n",
10
+ };
package/src/skill.mjs ADDED
@@ -0,0 +1,256 @@
1
+ // The agent skill of IAS: the catalog the server publishes, the copy the CLI carries (skill-files.generated.mjs, written
2
+ // by scripts/sync-skill.mjs from .claude/skills/ias) and the places a project can get it: an OpenCode catalog in
3
+ // opencode.jsonc and the files themselves in the folder of each harness (.opencode/skills/ias for OpenCode,
4
+ // .claude/skills/ias for Claude Code and .agents/skills/ias for Codex). Nothing here uses the network.
5
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import process from 'node:process';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { IasError } from './errors.mjs';
11
+ import { SKILL_FILES } from './skill-files.generated.mjs';
12
+
13
+ /** The OpenCode V2 catalog the server serves: index.json, ias/SKILL.md and ias/references/*.md. */
14
+ export const SKILL_CATALOG_URL = 'https://ias.imagina.build/skills/';
15
+
16
+ export const SKILL_NAME = 'ias';
17
+
18
+ /** Where OpenCode looks for a configuration, in the order ias init takes the first one it finds. */
19
+ export const OPENCODE_CONFIG_FILES = ['opencode.jsonc', 'opencode.json', '.opencode/opencode.jsonc', '.opencode/opencode.json'];
20
+
21
+ /** The harnesses a project can install the skill for, and the folders each one reads (relative to the project or the home). */
22
+ export const HARNESSES = ['opencode', 'claude', 'codex'];
23
+
24
+ export const SKILL_FOLDERS = {
25
+ opencode: { project: '.opencode/skills', global: '.config/opencode/skills' },
26
+ claude: { project: '.claude/skills', global: '.claude/skills' },
27
+ codex: { project: '.agents/skills', global: '.agents/skills' },
28
+ };
29
+
30
+ /** The line a JSONC configuration needs by hand: it is never rewritten. */
31
+ export const SKILL_CONFIG_LINE = `"skills": ["${SKILL_CATALOG_URL}"]`;
32
+
33
+ /** The folder of the skill in this repository, when ias runs from a clone (it is absent in an installed package). */
34
+ export const REPOSITORY_SKILL_DIR = fileURLToPath(new URL('../../../.claude/skills/ias/', import.meta.url));
35
+
36
+ /** The home of `env`, so the tests never touch the real one. */
37
+ export function homeOf(env = process.env) {
38
+ return env?.HOME || env?.USERPROFILE || homedir();
39
+ }
40
+
41
+ /**
42
+ * Where `ias skill install` writes: [{ harness, path }]. All three harnesses by default, the project's folders or the
43
+ * ones of the home with --global; `harnesses` (or --opencode, --claude, --codex) restricts it to one or more.
44
+ */
45
+ export function skillTargets({ global = false, harnesses, opencode = false, claude = false, codex = false, dir = process.cwd(), env = process.env } = {}) {
46
+ const requested = harnesses?.length ? harnesses : opencode || claude || codex ? [opencode && 'opencode', claude && 'claude', codex && 'codex'].filter(Boolean) : HARNESSES;
47
+ const unknown = requested.filter((harness) => !HARNESSES.includes(harness));
48
+ if (unknown.length > 0) throw new IasError(`unknown harness ${unknown.join(', ')}`, { fix: `choose --opencode, --claude or --codex` });
49
+ const root = global ? homeOf(env) : dir;
50
+ return [...new Set(requested)].map((harness) => ({ harness, path: join(root, SKILL_FOLDERS[harness][global ? 'global' : 'project'], SKILL_NAME) }));
51
+ }
52
+
53
+ /** Whether `a` and `b` are the same folder (the same path, or the same real path when both exist). */
54
+ function samePath(a, b) {
55
+ const one = resolve(a);
56
+ const other = resolve(b);
57
+ if (one === other) return true;
58
+ try {
59
+ return realpathSync(one) === realpathSync(other);
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ const sameContents = (file, contents) => existsSync(file) && readFileSync(file, 'utf8') === contents;
66
+
67
+ /**
68
+ * Installs the skill from this package (offline) in the target folders. Without `force`, an existing file with other
69
+ * contents stops it before anything is written, and the files are listed; `tolerant` instead leaves those targets as
70
+ * they are and writes the others (ias init does that, so an old copy never blocks it). The repository's
71
+ * .claude/skills/ias is the source: when a target is that same folder, it is skipped, never rewritten.
72
+ *
73
+ * Returns { dry_run, targets: [{ harness, path }], files, written, would_write, skipped, source, conflicts }.
74
+ */
75
+ export function installSkill({ global = false, harnesses, opencode = false, claude = false, codex = false, force = false, dryRun = false, tolerant = false, dir = process.cwd(), env = process.env } = {}, output) {
76
+ const targets = skillTargets({ global, harnesses, opencode, claude, codex, dir, env });
77
+ const files = Object.keys(SKILL_FILES);
78
+ const plan = targets.map((target) => {
79
+ const source = samePath(target.path, REPOSITORY_SKILL_DIR);
80
+ const missing = [];
81
+ const identical = [];
82
+ const different = [];
83
+ if (!source) {
84
+ for (const file of files) {
85
+ if (!existsSync(join(target.path, file))) missing.push(file);
86
+ else if (sameContents(join(target.path, file), SKILL_FILES[file])) identical.push(file);
87
+ else different.push(file);
88
+ }
89
+ }
90
+ return { ...target, source, missing, identical, different };
91
+ });
92
+
93
+ const conflicts = plan.filter((target) => !target.source && target.different.length > 0);
94
+ if (!force && !tolerant && conflicts.length > 0) {
95
+ const listed = conflicts.flatMap((target) => target.different.map((file) => join(target.path, file)));
96
+ throw new IasError(`the skill is already there with other contents (${listed.join(', ')})`, {
97
+ fix: 'ias skill update replaces it, or ias skill install --force; --dry-run shows the plan without writing',
98
+ });
99
+ }
100
+ const conflicting = new Set(force ? [] : conflicts.map((target) => target.path));
101
+
102
+ const written = [];
103
+ const wouldWrite = [];
104
+ const skipped = [];
105
+ for (const target of plan) {
106
+ if (target.source) {
107
+ skipped.push(target.path);
108
+ output?.log(`${output.prefix}: ${target.path} is the source of the skill (.claude/skills/ias): left as it is`);
109
+ continue;
110
+ }
111
+ if (conflicting.has(target.path)) {
112
+ for (const file of target.different) skipped.push(join(target.path, file));
113
+ output?.log(`${output.prefix}: ${target.path} already has the skill with other contents: left as it is; \`ias skill install --force\` replaces it`);
114
+ continue;
115
+ }
116
+ for (const file of files) {
117
+ const path = join(target.path, file);
118
+ const write = target.missing.includes(file) || target.different.includes(file);
119
+ if (!write) {
120
+ skipped.push(path);
121
+ continue;
122
+ }
123
+ if (dryRun) {
124
+ wouldWrite.push(path);
125
+ output?.log(`${output.prefix}: would write ${path}`);
126
+ continue;
127
+ }
128
+ mkdirSync(dirname(path), { recursive: true });
129
+ writeFileSync(path, SKILL_FILES[file]);
130
+ written.push(path);
131
+ output?.log(`${output.prefix}: wrote ${path}`);
132
+ }
133
+ }
134
+
135
+ if (dryRun) output?.log(`${output.prefix}: dry run: nothing was written (${wouldWrite.length} ${wouldWrite.length === 1 ? 'file' : 'files'})`);
136
+ else if (written.length === 0) output?.log(`${output.prefix}: the skill is already installed (${plan.filter((target) => !target.source).map((target) => target.path).join(', ')})`);
137
+
138
+ return {
139
+ dry_run: dryRun,
140
+ targets: plan.map(({ harness, path }) => ({ harness, path })),
141
+ files,
142
+ written,
143
+ would_write: wouldWrite,
144
+ skipped,
145
+ source: plan.filter((target) => target.source).map((target) => target.path),
146
+ conflicts: conflicts.map(({ harness, path, different }) => ({ harness, path, files: different })),
147
+ };
148
+ }
149
+
150
+ /** The first OpenCode configuration of `dir`, in the order OpenCode reads them. */
151
+ export function findProjectConfig(dir) {
152
+ return OPENCODE_CONFIG_FILES.map((name) => join(dir, name)).find((file) => existsSync(file));
153
+ }
154
+
155
+ /**
156
+ * Whether `text` has a // or /* comment outside a string. A URL such as https://ias.imagina.build is not a comment.
157
+ */
158
+ export function hasJsonComments(text) {
159
+ let inString = false;
160
+ let escaped = false;
161
+ for (let index = 0; index < text.length; index += 1) {
162
+ const character = text[index];
163
+ if (inString) {
164
+ if (escaped) escaped = false;
165
+ else if (character === '\\') escaped = true;
166
+ else if (character === '"') inString = false;
167
+ continue;
168
+ }
169
+ if (character === '"') inString = true;
170
+ else if (character === '/' && (text[index + 1] === '/' || text[index + 1] === '*')) return true;
171
+ }
172
+ return false;
173
+ }
174
+
175
+ /**
176
+ * The skill catalog in the project's OpenCode configuration. It creates opencode.jsonc when there is none and merges the
177
+ * URL into skills when the file is plain JSON. A JSONC file (comments) or one that does not parse is never rewritten:
178
+ * the exact line to add is returned instead. Returns { file, action, url, line?, changed }.
179
+ */
180
+ export function ensureSkillConfig(dir, { dryRun = false } = {}) {
181
+ const file = findProjectConfig(dir);
182
+ if (!file) {
183
+ const target = join(dir, OPENCODE_CONFIG_FILES[0]);
184
+ const config = { $schema: 'https://opencode.ai/config.json', skills: [SKILL_CATALOG_URL] };
185
+ if (!dryRun) writeFileSync(target, `${JSON.stringify(config, null, 2)}\n`);
186
+ return { file: target, action: 'created', url: SKILL_CATALOG_URL, changed: true };
187
+ }
188
+
189
+ const text = readFileSync(file, 'utf8');
190
+ let config;
191
+ try {
192
+ config = JSON.parse(text);
193
+ } catch {
194
+ return { file, action: 'manual', url: SKILL_CATALOG_URL, line: SKILL_CONFIG_LINE, changed: false };
195
+ }
196
+ if (hasJsonComments(text) || !config || typeof config !== 'object' || Array.isArray(config)) {
197
+ return { file, action: 'manual', url: SKILL_CATALOG_URL, line: SKILL_CONFIG_LINE, changed: false };
198
+ }
199
+ if (config.skills !== undefined && !Array.isArray(config.skills)) {
200
+ return { file, action: 'manual', url: SKILL_CATALOG_URL, line: SKILL_CONFIG_LINE, changed: false };
201
+ }
202
+ if (Array.isArray(config.skills) && config.skills.includes(SKILL_CATALOG_URL)) {
203
+ return { file, action: 'unchanged', url: SKILL_CATALOG_URL, changed: false };
204
+ }
205
+
206
+ config.skills = [...(config.skills ?? []), SKILL_CATALOG_URL];
207
+ if (!dryRun) writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
208
+ return { file, action: 'updated', url: SKILL_CATALOG_URL, changed: true };
209
+ }
210
+
211
+ /**
212
+ * The discovery of ias init: the catalog in the OpenCode configuration and the skill files for Claude Code and Codex
213
+ * (the OpenCode catalog already covers OpenCode, so .opencode/skills/ias is not copied).
214
+ * Returns { config, installs: [{ harness, path, changed }], changed } and prints the plan.
215
+ */
216
+ export function discoverSkill(dir, { dryRun = false, force = false } = {}, output) {
217
+ const config = ensureSkillConfig(dir, { dryRun });
218
+ if (config.action === 'manual') {
219
+ output?.log(`${output.prefix}: skill: ${config.file} has comments or is not plain JSON, so it is not rewritten; add this line:`);
220
+ output?.log(` ${config.line}`);
221
+ } else if (config.action === 'unchanged') {
222
+ output?.log(`${output.prefix}: skill: ${config.file} already has the catalog ${SKILL_CATALOG_URL}`);
223
+ } else {
224
+ const what = config.action === 'created' ? 'wrote' : 'added the catalog to';
225
+ output?.log(`${output.prefix}: skill: ${dryRun ? `would write` : what} ${config.file}${config.action === 'updated' ? ` (${SKILL_CATALOG_URL})` : ''}`);
226
+ }
227
+
228
+ const install = installSkill({ dir, harnesses: ['claude', 'codex'], force, dryRun, tolerant: true }, output);
229
+ const installs = install.targets.map(({ harness, path }) => ({
230
+ harness,
231
+ path,
232
+ changed: [...install.written, ...install.would_write].some((file) => file.startsWith(`${path}/`)),
233
+ }));
234
+ for (const conflict of install.conflicts) {
235
+ output?.log(`${output.prefix}: skill: ${conflict.path} has an older copy with other contents: add --force to ias init, or run \`ias skill install --force\``);
236
+ }
237
+ return { config, installs, changed: config.changed || installs.some((target) => target.changed) };
238
+ }
239
+
240
+ /** Whether the project (its configuration or a skill folder) or this computer already has the skill of IAS. */
241
+ export function hasSkill(dir, env = process.env) {
242
+ const config = findProjectConfig(dir);
243
+ if (config && readFileSync(config, 'utf8').includes(SKILL_CATALOG_URL)) return true;
244
+ const home = homeOf(env);
245
+ const folders = [
246
+ ...HARNESSES.flatMap((harness) => [join(dir, SKILL_FOLDERS[harness].project, SKILL_NAME), join(home, SKILL_FOLDERS[harness].global, SKILL_NAME)]),
247
+ join(home, '.opencode/skills', SKILL_NAME),
248
+ ];
249
+ return folders.some((folder) => existsSync(join(folder, 'SKILL.md')));
250
+ }
251
+
252
+ /** The non-blocking hint of ias setup and ias doctor, or undefined when the skill is already there. */
253
+ export function skillHint(dir, env = process.env) {
254
+ if (hasSkill(dir, env)) return undefined;
255
+ return 'the agent skill of IAS is not here: ias init installs it for Claude Code (.claude/skills/ias) and Codex (.agents/skills/ias) and adds its catalog to opencode.jsonc; ias skill install writes it for OpenCode, Claude Code and Codex (--global for ~/.config/opencode/skills/ias, ~/.claude/skills/ias and ~/.agents/skills/ias)';
256
+ }