cairn-ai 0.6.0 → 0.7.0
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/.agents/workflows/cairn-doctor.md +4 -1
- package/.agents/workflows/cairn-install.md +3 -1
- package/.agents/workflows/cairn-uninstall.md +3 -1
- package/.agents/workflows/cairn-upgrade.md +3 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.de.md +32 -5
- package/README.es.md +32 -5
- package/README.fr.md +32 -5
- package/README.ja.md +32 -5
- package/README.ko.md +34 -8
- package/README.md +34 -8
- package/README.pt.md +32 -5
- package/README.zh.md +32 -5
- package/commands/cairn-doctor.md +4 -1
- package/commands/cairn-install.md +11 -5
- package/commands/cairn-uninstall.md +9 -4
- package/commands/cairn-upgrade.md +8 -1
- package/package.json +2 -2
- package/scripts/cairn-auto-upgrade.mjs +334 -0
- package/scripts/cairn-lifecycle-claude.mjs +64 -18
- package/scripts/cairn-lifecycle-commands.mjs +355 -79
- package/scripts/cairn-lifecycle-hosts.mjs +98 -0
- package/scripts/cairn-lifecycle-integrity.mjs +106 -33
- package/scripts/cairn-lifecycle-layout.mjs +69 -0
- package/scripts/cairn-lifecycle-staging.mjs +45 -30
- package/scripts/cairn-lifecycle-transaction-journal.mjs +50 -7
- package/scripts/cairn-lifecycle.mjs +148 -45
- package/scripts/cairn-paths.mjs +12 -0
- package/scripts/cairn-state-context.mjs +25 -0
- package/scripts/cairn-state.mjs +54 -21
- package/scripts/cairn-update-state.mjs +219 -0
- package/scripts/cairn.mjs +13 -8
package/README.pt.md
CHANGED
|
@@ -102,12 +102,14 @@ cairn upgrade
|
|
|
102
102
|
cairn doctor
|
|
103
103
|
cairn uninstall
|
|
104
104
|
cairn toolcheck
|
|
105
|
+
cairn auto-upgrade status
|
|
105
106
|
```
|
|
106
107
|
|
|
107
|
-
- `cairn install`: instala de forma transacional custom marketplace source, versioned runtime, secoes config do Cairn e mirrors atuais.
|
|
108
|
-
- `cairn upgrade`: substitui apenas entradas intactas do ownership manifest apos staged validation e usa rollback em falhas.
|
|
109
|
-
- `cairn doctor`: valida ownership digests, features efetivos do Codex, status real do plugin e runtime locators sem reparar.
|
|
110
|
-
- `cairn uninstall`: remove
|
|
108
|
+
- `cairn install [--hosts all|codex,claude,antigravity]`: instala de forma transacional custom marketplace source, versioned runtime, secoes config do Cairn e mirrors atuais. O canonical runtime fica em `~/.cairn/install/` e o resto e instalado apenas para os hosts escolhidos. Sem `--hosts`, a primeira instalacao em um terminal pergunta os hosts; execucoes nao interativas e CI instalam todos.
|
|
109
|
+
- `cairn upgrade [--hosts all|codex,claude,antigravity]`: substitui apenas entradas intactas do ownership manifest apos staged validation e usa rollback em falhas. Mantem os hosts registrados, a menos que `--hosts` os altere, migra uma instalacao do Cairn 0.6.0 ou anterior e atualiza um Claude Code plugin cache que ainda tenha outra versao. Se a versao instalada for mais nova que esta CLI, `install` e `upgrade` recusam sem mudar nada; `--allow-downgrade` instala mesmo assim a versao desta CLI.
|
|
110
|
+
- `cairn doctor`: valida ownership digests, features efetivos do Codex, status real do plugin e runtime locators sem reparar. Imprime primeiro `Hosts: <ids>` e verifica apenas os hosts escolhidos (incluindo `Claude plugin cache version`). Uma instalacao legacy e relatada com a indicacao de migrar com `cairn upgrade`.
|
|
111
|
+
- `cairn uninstall`: primeiro compara cada entrada nao config do ownership manifest com seu installed digest e recusa todo o uninstall, sem mudar nada, se algum managed artifact foi modificado ou removido. Caso contrario, remove ou restaura as entradas gerenciadas e os retained runtimes, limpa os managed scaffolds vazios, preserva alvos unmanaged e seus diretorios pai e aplica rollback a falhas antes do commit. Tambem remove `~/.cairn/update/`, e mantem `~/.cairn/config.json` e `~/.cairn/projects/`. Uma instalacao com layout legacy e removida no lugar, sem migracao.
|
|
112
|
+
- `cairn auto-upgrade status|on|off|run`: `status` imprime em JSON a configuracao, a versao instalada, a ultima verificacao e o aviso pendente; `on`/`off` gravam `autoUpgrade` em `~/.cairn/config.json` e imprimem o mesmo relatorio; `run` consulta o npm agora, sem esperar o intervalo de 24 horas nem o backoff. Exit 0 e sucesso, 1 erro de execucao ou execucao com falha, 2 erro de uso.
|
|
111
113
|
- `cairn toolcheck`: detecta repository stacks e verifica required tools; apenas um installer suportado e aprovado e executado, caso contrario `installer-unavailable`.
|
|
112
114
|
- `cairn cleanup --root <repo>`: por padrao relata em modo read-only residuos legacy `.cairn` locais do projeto. `--yes` migra/reconcilia valid active state para o user-home store, remove apenas residuos conhecidos de state/lock/tool, preserva arquivos desconhecidos e remove o diretorio somente quando vazio.
|
|
113
115
|
- `cairn memory check|index [--root <repo>]`: `check` compara `MEMORY.md` com `docs/memory/*.md` e imprime um relatorio JSON; sai com 1 para notas nao indexadas, dead links, duplicatas, resumos desatualizados, caracteres de controle em resumos ou caminhos inseguros, e warnings so falham com `--strict`. Entradas sao linhas de lista sob `## Domain Knowledge` (ou todas as linhas de lista num arquivo antigo sem essa secao); um link de nota em outra secao e uma mencao que `index` nunca reescreve. `index` adiciona notas ausentes e, quando o `Summary:` muda, troca so o resumo apos o link (uma linha com varios links fica como esta); remove apenas linhas placeholder do Cairn que coincidem exatamente nessa secao, nunca apaga entradas nem dead links e preserva o restante byte a byte. `--dry-run` mostra uma previa. Se o ponto de insercao estiver dentro de um bloco de codigo nao fechado, `index` nao escreve nada e sai com 1 com `unclosed-fence`. Erros de uso saem com 2; `cairn memory` sem subcomando continua imprimindo a orientacao.
|
|
@@ -120,10 +122,35 @@ cairn toolcheck
|
|
|
120
122
|
|
|
121
123
|
O custom marketplace lifecycle separa source e versioned runtime. Staged validation precede cada commit, o ownership manifest vincula todos os managed digests e uma falha aciona reverse-order rollback. Artifacts modified ou unmanaged sao preserved e relatados como conflict. O Cairn altera apenas suas TOML sections e nao forca feature/agent settings publicos.
|
|
122
124
|
|
|
123
|
-
Clean uninstall remove o
|
|
125
|
+
Clean uninstall remove `~/.cairn/install/` e o Codex cache root `$CODEX_HOME/plugins/cache/cairn` somente quando todos os managed scaffold directories estao vazios; qualquer conteudo unmanaged e preservado. O source repository, repository `MEMORY.md`/`PLAN.md` e user-home Cairn goal state, o package global `cairn-ai`, package-manager caches e legacy backups/settings fora do ownership manifest atual nao sao removidos automaticamente.
|
|
124
126
|
|
|
125
127
|
Codex usa `skills/` e `commands/`. Claude Code usa comandos espelhados e agent definitions em `.claude/`. Antigravity usa `.agents/workflows` e global skills mirrors.
|
|
126
128
|
|
|
129
|
+
## Install Layout And Auto-Upgrade
|
|
130
|
+
|
|
131
|
+
O Cairn guarda a instalacao em `CAIRN_HOME` (padrao `~/.cairn`). Os caminhos de cada host continuam definidos separadamente por `CODEX_HOME`, `CLAUDE_HOME`, `ANTIGRAVITY_HOME`, `ANTIGRAVITY_CLI_HOME` e `CODEX_CONFIG_PATH`.
|
|
132
|
+
|
|
133
|
+
```text
|
|
134
|
+
~/.cairn/
|
|
135
|
+
install/ arquivos gerenciados pelo lifecycle
|
|
136
|
+
lifecycle.json ownership manifest (schema 2: hosts, origin, retained runtimes)
|
|
137
|
+
lifecycle.lock, transaction.json, transactions/
|
|
138
|
+
runtime/<version>/ canonical runtime usado por todos os runtime locators e pelos hooks do Claude Code
|
|
139
|
+
codex/ Codex local marketplace source
|
|
140
|
+
claude/ Claude Code local marketplace
|
|
141
|
+
projects/ goal state
|
|
142
|
+
config.json configuracoes do usuario ("autoUpgrade")
|
|
143
|
+
update/ estado do auto-upgrade
|
|
144
|
+
$CODEX_HOME/plugins/cache/cairn/cairn/<version>/ copia no Codex plugin cache (somente se Codex for escolhido)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- Hosts: apenas `install` e `upgrade` aceitam `--hosts`, com `all` ou uma lista separada por virgulas de `codex`, `claude` e `antigravity`. A selecao fica no ownership manifest e `upgrade`, `doctor` e `uninstall` a seguem. Ela muda com `cairn upgrade --hosts <list>`; um arquivo gerenciado modificado de um host removido causa recusa. O prompt interativo (todos por padrao, apenas Claude Code, apenas Codex, apenas Antigravity ou uma lista) aparece somente quando nada esta instalado.
|
|
148
|
+
- Migracao: uma instalacao do Cairn 0.6.0 ou anterior (tudo em `$CODEX_HOME/plugins/cache/cairn`) passa para `~/.cairn/install/` com um unico `cairn upgrade`, que tambem registra de novo o plugin do Claude Code a partir do novo caminho do marketplace. Uma migracao com falha ou interrompida e restaurada, e arquivos gerenciados modificados a recusam sem mudancas. A instalacao legacy migra com todos os hosts; um `--hosts` parcial fica para a execucao seguinte. Depois, gerencie a instalacao apenas com um Cairn CLI que conheca este layout e reinicie as sessoes abertas durante a migracao.
|
|
149
|
+
- Retencao: um upgrade que muda a versao mantem o canonical runtime anterior e a copia do Codex cache por 3 dias (no maximo 3 de cada tipo) para que sessoes iniciadas antes continuem funcionando. O install ou upgrade seguinte remove os vencidos, e uninstall remove todos. Uma copia do Codex cache igual ou superior a nova versao nunca e mantida, porque o Codex carrega a versao mais alta do cache.
|
|
150
|
+
- Claude Code plugin cache: se ainda tiver outra versao, `cairn upgrade` o atualiza com `claude plugin marketplace update cairn` e `claude plugin update cairn@cairn --scope user`. Se falhar, a instalacao atualizada e mantida, os comandos para concluir manualmente sao impressos e a saida e exit 1.
|
|
151
|
+
- Auto-upgrade: ativado por padrao. No maximo uma vez a cada 24 horas (e nao nas 24 horas apos um install ou upgrade), `SessionStart` inicia um processo separado que le a versao mais recente de `cairn-ai` no npm, instala essa versao exata com `--ignore-scripts` em `~/.cairn/update/stage/`, verifica e executa o `cairn-lifecycle.mjs upgrade --auto` dela. Todas as versoes novas sao aplicadas, inclusive major, e nunca ha downgrade. O `SessionStart` seguinte mostra o resultado (`updated`, `failed` ou `manual`) em uma linha, uma unica vez. Nao funciona para instalacoes a partir de um git checkout (`origin: "checkout"`) nem ao executar de um source checkout.
|
|
152
|
+
- Desativar: `cairn auto-upgrade off`, `CAIRN_AUTO_UPGRADE=0` (ou `false`, `off`, `no`) ou `"autoUpgrade": false` em `~/.cairn/config.json`. Um `config.json` ilegivel tambem desativa. O estado e o log da ultima execucao ficam em `~/.cairn/update/`.
|
|
153
|
+
|
|
127
154
|
## Antigravity Compatibility
|
|
128
155
|
|
|
129
156
|
Antigravity suporta Agent Skills e Workflows baseados em `SKILL.md` chamados como `/workflow-name`. Cairn instala estes paths para essa superficie.
|
package/README.zh.md
CHANGED
|
@@ -102,12 +102,14 @@ cairn upgrade
|
|
|
102
102
|
cairn doctor
|
|
103
103
|
cairn uninstall
|
|
104
104
|
cairn toolcheck
|
|
105
|
+
cairn auto-upgrade status
|
|
105
106
|
```
|
|
106
107
|
|
|
107
|
-
- `cairn install`: 以事务方式安装 custom marketplace source、versioned runtime、Cairn-owned config sections 和当前 host mirrors。
|
|
108
|
-
- `cairn upgrade`: staged validation 后只替换 ownership manifest 中未修改的 entry,失败时 rollback。
|
|
109
|
-
- `cairn doctor`: 只读验证 ownership digests、effective Codex features、真实 plugin status 和 runtime locators。
|
|
110
|
-
- `cairn uninstall`:
|
|
108
|
+
- `cairn install [--hosts all|codex,claude,antigravity]`: 以事务方式安装 custom marketplace source、versioned runtime、Cairn-owned config sections 和当前 host mirrors。 canonical runtime 位于 `~/.cairn/install/`,其余内容只为所选 host 安装。未提供 `--hosts` 时,在 terminal 中首次安装会询问 host,非交互运行和 CI 会安装所有 host。
|
|
109
|
+
- `cairn upgrade [--hosts all|codex,claude,antigravity]`: staged validation 后只替换 ownership manifest 中未修改的 entry,失败时 rollback。 除非用 `--hosts` 修改,否则沿用已记录的 hosts;会迁移 Cairn 0.6.0 及更早版本的安装,并更新仍为其他版本的 Claude Code plugin cache。 已安装版本比当前 CLI 更新时,`install` 和 `upgrade` 不做任何更改并拒绝;如仍要安装当前 CLI 的版本,加 `--allow-downgrade`。
|
|
110
|
+
- `cairn doctor`: 只读验证 ownership digests、effective Codex features、真实 plugin status 和 runtime locators。 第一行输出 `Hosts: <ids>`,只检查所选 host(包括 `Claude plugin cache version`)。发现 legacy 安装时提示运行 `cairn upgrade` 迁移。
|
|
111
|
+
- `cairn uninstall`: 先将 config 以外的所有 ownership manifest entry 与 installed digest 对照,只要有任何 managed artifact 被修改或删除,就不做任何更改并拒绝整个 uninstall。否则删除或恢复 managed entry 和 retained runtimes,清理空的 managed scaffold,preserve unmanaged target 及其父目录,并 rollback commit 前的失败。 同时删除 `~/.cairn/update/`,保留 `~/.cairn/config.json` 和 `~/.cairn/projects/`。legacy layout 的安装不经迁移直接原地删除。
|
|
112
|
+
- `cairn auto-upgrade status|on|off|run`: `status` 以 JSON 输出设置、已安装版本、上次检查时间和待显示的 notice;`on`/`off` 将 `autoUpgrade` 写入 `~/.cairn/config.json` 并输出同样的报告;`run` 忽略 24 小时周期和 backoff,立即检查 npm。exit 0 表示成功,1 表示执行错误或运行失败,2 表示用法错误。
|
|
111
113
|
- `cairn toolcheck`: 检测 repository stacks 和 required tools;只执行已批准的 supported installer,否则返回 `installer-unavailable`。
|
|
112
114
|
- `cairn cleanup --root <repo>`: 默认只读报告 legacy project-local `.cairn` residue。`--yes` 将 valid active state 迁移/协调到 user-home store,只删除 known state/lock/tool residue,保留 unknown files,并且仅在目录为空时清理目录。
|
|
113
115
|
- `cairn memory check|index [--root <repo>]`: `check` 对照 `MEMORY.md` 与 `docs/memory/*.md` 并输出一个 JSON 报告;存在 unindexed notes、dead links、duplicates、stale summaries、summary 中的控制字符或 unsafe paths 时 exit 1,warnings 仅在 `--strict` 下失败。条目是 `## Domain Knowledge` 下的列表行(没有该 section 的旧文件则为所有列表行);其他 section 中的 note 链接只是提及,`index` 不会改写。`index` 添加缺失的 notes,`Summary:` 变化时只替换链接后的 summary 部分(含多个链接的行保持原样);只删除该 section 内完全匹配的 Cairn placeholder 行,不删除条目或 dead links,其余内容逐字节保留。可用 `--dry-run` 预览。插入位置位于未闭合的 code fence 内时,`index` 不写入任何内容,并以 `unclosed-fence` exit 1。用法错误 exit 2;不带子命令的 `cairn memory` 仍输出原有指引。
|
|
@@ -120,10 +122,35 @@ cairn toolcheck
|
|
|
120
122
|
|
|
121
123
|
custom marketplace lifecycle 将 source 与 versioned runtime 分离。每次 commit 前执行 staged validation,ownership manifest 绑定全部 managed digests,失败时执行 reverse-order rollback。modified 或 unmanaged artifacts 会 preserve 并报告 conflict。Cairn 只修改自身 TOML sections,不强制 public feature/agent settings。
|
|
122
124
|
|
|
123
|
-
Clean uninstall 仅在所有 managed scaffold directory 均为空时删除
|
|
125
|
+
Clean uninstall 仅在所有 managed scaffold directory 均为空时删除 `~/.cairn/install/` 和 Codex cache root `$CODEX_HOME/plugins/cache/cairn`,并保留任何 unmanaged content。它不会自动删除 source repository、repository `MEMORY.md`/`PLAN.md`、user-home Cairn goal state、全局 `cairn-ai` package、package-manager cache,或当前 ownership manifest 之外的 legacy backup/settings。
|
|
124
126
|
|
|
125
127
|
Codex 使用 `skills/` 和 `commands/`。Claude Code 使用 `.claude/` 下的 mirrored commands 和 agent definitions。Antigravity 使用 `.agents/workflows` 和 global skills mirrors。
|
|
126
128
|
|
|
129
|
+
## Install Layout And Auto-Upgrade
|
|
130
|
+
|
|
131
|
+
Cairn 将安装内容放在 `CAIRN_HOME`(默认 `~/.cairn`)下。各 host 的路径仍分别由 `CODEX_HOME`、`CLAUDE_HOME`、`ANTIGRAVITY_HOME`、`ANTIGRAVITY_CLI_HOME`、`CODEX_CONFIG_PATH` 决定。
|
|
132
|
+
|
|
133
|
+
```text
|
|
134
|
+
~/.cairn/
|
|
135
|
+
install/ lifecycle 管理的文件
|
|
136
|
+
lifecycle.json ownership manifest (schema 2: hosts, origin, retained runtimes)
|
|
137
|
+
lifecycle.lock, transaction.json, transactions/
|
|
138
|
+
runtime/<version>/ 所有 runtime locator 和 Claude Code hooks 使用的 canonical runtime
|
|
139
|
+
codex/ Codex local marketplace source
|
|
140
|
+
claude/ Claude Code local marketplace
|
|
141
|
+
projects/ goal state
|
|
142
|
+
config.json 用户设置 ("autoUpgrade")
|
|
143
|
+
update/ auto-upgrade 状态
|
|
144
|
+
$CODEX_HOME/plugins/cache/cairn/cairn/<version>/ Codex plugin cache copy (仅在选择 Codex 时)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- Hosts: 只有 `install` 和 `upgrade` 接受 `--hosts`,值为 `all` 或 `codex`、`claude`、`antigravity` 的逗号列表。选择会记录在 ownership manifest 中,`upgrade`、`doctor`、`uninstall` 都遵循它。用 `cairn upgrade --hosts <list>` 修改;被移除 host 的 managed file 若已被修改则拒绝。交互式 prompt(全部(默认)、仅 Claude Code、仅 Codex、仅 Antigravity、逗号列表)只在尚未安装任何内容时出现。
|
|
148
|
+
- 迁移: Cairn 0.6.0 及更早版本的安装(`$CODEX_HOME/plugins/cache/cairn` 下的全部内容)只需一次 `cairn upgrade` 即迁移到 `~/.cairn/install/`,Claude Code plugin 也会用新的 marketplace 路径重新注册。迁移失败或中断时会恢复原状,存在被修改的 managed file 时不做任何改动并拒绝。legacy 安装以全部 host 迁移,因此非全部的 `--hosts` 需在下一次运行时指定。迁移后只用认识该 layout 的 Cairn CLI 管理,并重启迁移期间打开的 session。
|
|
149
|
+
- 保留期: 改变版本的 upgrade 会把上一个 canonical runtime 和 Codex cache copy 保留 3 天(每类最多 3 个),让 upgrade 前启动的 session 继续工作。过期的由下一次 install/upgrade 删除,uninstall 会全部删除。不低于新版本的 Codex cache copy 不会保留,因为 Codex 会加载 cache 中最高的版本。
|
|
150
|
+
- Claude Code plugin cache: 若仍为其他版本,`cairn upgrade` 会用 `claude plugin marketplace update cairn` 和 `claude plugin update cairn@cairn --scope user` 更新。更新失败时保留已升级的安装,输出手动完成的命令并以 exit 1 结束。
|
|
151
|
+
- Auto-upgrade: 默认开启。`SessionStart` 每 24 小时最多一次(install 或 upgrade 后 24 小时内不检查)启动一个 detached process:从 npm 读取最新的 `cairn-ai` 版本,以该确切版本加 `--ignore-scripts` 安装到 `~/.cairn/update/stage/` 并验证,然后运行该 package 的 `cairn-lifecycle.mjs upgrade --auto`。包括 major 在内的所有新版本都会应用,从不降级。下一个 `SessionStart` 用一行只提示一次结果(`updated`、`failed`、`manual`)。从 git checkout 安装(`origin: "checkout"`)或从 source checkout 运行时不会执行。
|
|
152
|
+
- 关闭: `cairn auto-upgrade off`、`CAIRN_AUTO_UPGRADE=0`(也可用 `false`、`off`、`no`),或在 `~/.cairn/config.json` 中设置 `"autoUpgrade": false`。无法读取的 `config.json` 也视为关闭。状态和上次运行日志位于 `~/.cairn/update/`。
|
|
153
|
+
|
|
127
154
|
## Antigravity Compatibility
|
|
128
155
|
|
|
129
156
|
Antigravity 支持通过 `/workflow-name` 调用的 `SKILL.md` based Agent Skills and Workflows。Cairn 会为该 surface 安装这些 paths。
|
package/commands/cairn-doctor.md
CHANGED
|
@@ -10,9 +10,12 @@ bunx cairn-ai@latest doctor
|
|
|
10
10
|
|
|
11
11
|
The doctor validates the custom marketplace installation and ownership manifest without repairing it. Diagnostics are read-only; rollback is performed only by lifecycle transaction recovery.
|
|
12
12
|
|
|
13
|
+
The first line is `Hosts: <ids>`; only the selected hosts are checked and only their host CLIs are run. A legacy installation is reported with a hint to run `cairn upgrade`.
|
|
14
|
+
|
|
13
15
|
Checks:
|
|
14
16
|
|
|
15
17
|
- Source plugin manifest.
|
|
18
|
+
- Canonical runtime under `~/.cairn/install/runtime/<version>/`.
|
|
16
19
|
- Codex cached installation.
|
|
17
20
|
- Marketplace file.
|
|
18
21
|
- Hook manifest link in the installed copy.
|
|
@@ -20,6 +23,6 @@ Checks:
|
|
|
20
23
|
- `cairn@cairn` installed/enabled/version from the actual Codex CLI.
|
|
21
24
|
- effective Codex plugin/hook/multi-agent feature state (reported, never forced).
|
|
22
25
|
- hook trust state.
|
|
23
|
-
- Claude Code commands and agents mirror files
|
|
26
|
+
- Claude Code commands and agents mirror files, plugin files, and `cairn@cairn` registered/enabled state and `Claude plugin cache version`.
|
|
24
27
|
- Antigravity IDE/CLI skills and workflows mirror files.
|
|
25
28
|
- Installed runtime locators and their referenced scripts, templates, and model guidance.
|
|
@@ -6,19 +6,25 @@ Run from the published or global package, not from the installed cache copy:
|
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
bunx cairn-ai@latest install
|
|
9
|
+
bunx cairn-ai@latest install --hosts claude
|
|
9
10
|
```
|
|
10
11
|
|
|
11
|
-
The current custom marketplace lifecycle remains in use; this is not a migration to `codex plugin add`. Do not invoke lifecycle install/upgrade from the
|
|
12
|
+
The current custom marketplace lifecycle remains in use; this is not a migration to `codex plugin add`. Do not invoke lifecycle install/upgrade from an installed copy (`~/.cairn/install/` or the Codex plugin cache) because that copy is the replacement target.
|
|
13
|
+
|
|
14
|
+
Hosts: `--hosts all` or a comma list of `codex`, `claude`, `antigravity` (also `--hosts=<list>`). Without it, a first install at a terminal asks once (all hosts is the default); non-interactive runs and CI install every host. The selection is recorded in the ownership manifest and followed by upgrade, doctor, and uninstall.
|
|
12
15
|
|
|
13
16
|
Actions:
|
|
14
17
|
|
|
15
|
-
- Stage and validate the Cairn installation, then transactionally commit the
|
|
16
|
-
- Add the `hooks/hooks.json` link to the installed
|
|
18
|
+
- Stage and validate the Cairn installation, then transactionally commit the canonical runtime `~/.cairn/install/runtime/<version>/` (`CAIRN_HOME` overrides `~/.cairn`) and, for Codex, the marketplace source `~/.cairn/install/codex/` and the versioned runtime cache `$CODEX_HOME/plugins/cache/cairn/cairn/<version>/`.
|
|
19
|
+
- Add the `hooks/hooks.json` link to the installed manifests.
|
|
17
20
|
- Register only Cairn-owned marketplace, plugin, and hook trust sections in `~/.codex/config.toml`; do not rewrite public feature or agent settings.
|
|
18
21
|
- Install `cairn-*` commands and agents mirror files for Claude Code.
|
|
19
|
-
- Generate the Claude Code plugin `cairn@cairn` (four skills plus hooks that call the
|
|
22
|
+
- Generate the Claude Code plugin `cairn@cairn` (four skills plus hooks that call the canonical runtime) in the Cairn-owned local marketplace `~/.cairn/install/claude/`, then register it with `claude plugin marketplace add` and `claude plugin install cairn@cairn --scope user` after the files commit. Skip with a manual command when the `claude` CLI is missing, and never touch a different marketplace named `cairn`.
|
|
20
23
|
- Install `cairn-*` skills and workflows mirror files for Antigravity IDE/CLI.
|
|
21
24
|
- Write runtime locators so all mirrors resolve scripts, templates, and model guidance from the installed plugin root.
|
|
22
|
-
-
|
|
25
|
+
- Install only the surfaces of the selected hosts; the canonical runtime is always installed.
|
|
26
|
+
- Record every current managed destination and digest, the host selection, and the install origin in the ownership manifest `~/.cairn/install/lifecycle.json`, and rollback committed phases in reverse order on failure.
|
|
27
|
+
- Automatic upgrades are on by default for a package install; see `cairn auto-upgrade status|on|off|run`.
|
|
23
28
|
- Use the separate pinned SHA-256 release 0.2.2 file-hash allowlist only to validate that exact legacy file set; it is not the current package file list or a cryptographic signature.
|
|
24
29
|
- Adopt an exact, unmodified supported legacy install only after release-integrity validation; preserve and reject modified or unknown artifacts.
|
|
30
|
+
- Refuse, changing nothing, when the installed version is newer than this CLI; `--allow-downgrade` installs this CLI's version anyway.
|
|
@@ -8,15 +8,20 @@ Run from the published or global package:
|
|
|
8
8
|
bunx cairn-ai@latest uninstall
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
The custom marketplace ownership manifest is authoritative. Uninstall
|
|
11
|
+
The custom marketplace ownership manifest is authoritative. Uninstall first compares every non-config managed destination with its recorded installed digest and refuses the whole uninstall, changing nothing, if any managed artifact was modified or removed. Otherwise it removes or restores the managed destinations; unmanaged targets and their parent directories are preserved. A failure rolls back the transaction.
|
|
12
12
|
|
|
13
13
|
Managed items:
|
|
14
14
|
|
|
15
15
|
- Cairn marketplace, plugin, and hook trust state blocks in `~/.codex/config.toml`.
|
|
16
|
-
- `~/.
|
|
16
|
+
- `~/.cairn/install/` (ownership manifest, canonical and retained runtimes, Codex marketplace source, Claude Code marketplace).
|
|
17
|
+
- `$CODEX_HOME/plugins/cache/cairn` (Codex versioned runtime cache and retained copies).
|
|
18
|
+
- The Claude Code plugin `cairn@cairn` registration.
|
|
19
|
+
- Auto-upgrade state in `~/.cairn/update/`.
|
|
17
20
|
- Claude Code `cairn-*` commands and agents mirror files.
|
|
18
21
|
- Antigravity IDE/CLI `cairn-*` skills and workflows mirror files.
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
Only the hosts recorded in the ownership manifest are touched. An installation made by Cairn 0.6.0 or earlier is removed in place without migrating.
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
After the transaction commits, Cairn removes empty managed scaffold directories bottom-up and removes `~/.cairn/install/` and `$CODEX_HOME/plugins/cache/cairn` only when empty, after releasing its own lifecycle lock. `~/.cairn/update/` loses its known state files and is removed when empty. A directory containing any unmanaged child is preserved; pruning never uses recursive deletion.
|
|
26
|
+
|
|
27
|
+
The source work folder, repository `MEMORY.md`, `PLAN.md`, `docs/`, and user-home Cairn goal state (`~/.cairn/projects/`) and settings (`~/.cairn/config.json`) are not deleted. The globally installed `cairn-ai` package, package-manager download caches, legacy backup files, and legacy shared settings not recorded by the current ownership manifest are also outside this command's scope and require a separate, explicit cleanup decision.
|
|
@@ -6,9 +6,10 @@ Run from the published or global package, not from the installed cache copy:
|
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
bunx cairn-ai@latest upgrade
|
|
9
|
+
bunx cairn-ai@latest upgrade --hosts codex,claude
|
|
9
10
|
```
|
|
10
11
|
|
|
11
|
-
The current custom marketplace lifecycle remains in use.
|
|
12
|
+
The current custom marketplace lifecycle remains in use. An installed copy (`~/.cairn/install/` or the Codex plugin cache) is a replacement target and must not upgrade itself.
|
|
12
13
|
|
|
13
14
|
Actions:
|
|
14
15
|
|
|
@@ -18,3 +19,9 @@ Actions:
|
|
|
18
19
|
- Refresh runtime locators for every installed surface.
|
|
19
20
|
- Stage and validate all candidates before commit; rollback committed phases in reverse order on failure.
|
|
20
21
|
- Preserve and report a conflict for modified, unmanaged, or invalid legacy artifacts.
|
|
22
|
+
- Keep the recorded hosts unless `--hosts` (`all` or a comma list of `codex`, `claude`, `antigravity`) changes them; deselected hosts are removed, and a modified artifact of a deselected host refuses the upgrade.
|
|
23
|
+
- Migrate an installation made by Cairn 0.6.0 or earlier (everything under `$CODEX_HOME/plugins/cache/cairn`) to `~/.cairn/install/` in one transaction, re-registering the Claude Code plugin from its new marketplace path. A failed or interrupted migration is restored; a legacy installation migrates with every host, so a narrower `--hosts` is refused until the next run. Restart sessions that were open during the migration.
|
|
24
|
+
- After a version change, keep the previous canonical runtime and Codex cache copy for 3 days (at most 3 of each); expired ones are removed by the next install or upgrade. A Codex cache copy at or above the new version is never kept, because Codex loads the highest cached version.
|
|
25
|
+
- When the Claude Code plugin cache holds a different version, run `claude plugin marketplace update cairn` and `claude plugin update cairn@cairn --scope user`. If that fails, keep the upgraded files, print those commands, and exit 1.
|
|
26
|
+
- Refuse, changing nothing, when the installed version is newer than this CLI; `--allow-downgrade` moves the installation to this CLI's version anyway.
|
|
27
|
+
- `upgrade --auto` is reserved for the auto-upgrade runner; it keeps the recorded hosts, never downgrades (it cannot be combined with `--allow-downgrade`), and refuses to run without an existing installation.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cairn-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Token-efficient agent harness with tool readiness for Codex, Claude Code, and Antigravity",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"README.*.md"
|
|
42
42
|
],
|
|
43
43
|
"scripts": {
|
|
44
|
-
"check": "node --check scripts/cairn.mjs && node --check scripts/cairn-paths.mjs && node --check scripts/cairn-safe-fs.mjs && node --check scripts/cairn-goal.mjs && node --check scripts/cairn-state.mjs && node --check scripts/cairn-cleanup.mjs && node --check scripts/cairn-lifecycle.mjs && node --check scripts/cairn-toolcheck.mjs && node -e \"JSON.parse(require('node:fs').readFileSync('package.json','utf8')); JSON.parse(require('node:fs').readFileSync('.codex-plugin/plugin.json','utf8')); JSON.parse(require('node:fs').readFileSync('hooks/hooks.json','utf8')); JSON.parse(require('node:fs').readFileSync('scripts/release-integrity-0.2.2.json','utf8'))\" && npm test",
|
|
44
|
+
"check": "node --check scripts/cairn.mjs && node --check scripts/cairn-paths.mjs && node --check scripts/cairn-safe-fs.mjs && node --check scripts/cairn-goal.mjs && node --check scripts/cairn-state.mjs && node --check scripts/cairn-cleanup.mjs && node --check scripts/cairn-lifecycle.mjs && node --check scripts/cairn-lifecycle-layout.mjs && node --check scripts/cairn-lifecycle-hosts.mjs && node --check scripts/cairn-update-state.mjs && node --check scripts/cairn-auto-upgrade.mjs && node --check scripts/cairn-toolcheck.mjs && node -e \"JSON.parse(require('node:fs').readFileSync('package.json','utf8')); JSON.parse(require('node:fs').readFileSync('.codex-plugin/plugin.json','utf8')); JSON.parse(require('node:fs').readFileSync('hooks/hooks.json','utf8')); JSON.parse(require('node:fs').readFileSync('scripts/release-integrity-0.2.2.json','utf8'))\" && npm test",
|
|
45
45
|
"test": "node --test",
|
|
46
46
|
"prepack": "npm run check"
|
|
47
47
|
},
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { join, relative } from "node:path";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import { createLifecycleLock } from "./cairn-lifecycle-lock.mjs";
|
|
8
|
+
import { assertNoSymlinkComponents } from "./cairn-safe-fs.mjs";
|
|
9
|
+
import {
|
|
10
|
+
AUTO_UPGRADE_EXIT, UPDATE_PACKAGE_NAME, autoUpgradeSetting, emptyUpdateState, noticeRecord, readInstallManifest,
|
|
11
|
+
readJsonFile, readPendingNotice, readUpdateState, removeUpdateState, updatePaths, writeFileAtomic, writeJsonAtomic,
|
|
12
|
+
} from "./cairn-update-state.mjs";
|
|
13
|
+
|
|
14
|
+
// The detached auto-upgrade runner (`run`, started by the SessionStart hook) and the `cairn auto-upgrade` CLI.
|
|
15
|
+
// The runner is the old version and the package it installs is the new one. Only three things are fixed between
|
|
16
|
+
// versions: the `cairn-lifecycle.mjs upgrade --auto` call, `version` in CAIRN_HOME/install/lifecycle.json, and the
|
|
17
|
+
// meaning of the lifecycle exit code (0 done, AUTO_UPGRADE_EXIT.manual a Claude Code step left to the user, anything
|
|
18
|
+
// else not confirmed).
|
|
19
|
+
export const AUTO_UPGRADE_USAGE = "Usage: cairn auto-upgrade status|on|off|run";
|
|
20
|
+
const VIEW_TIMEOUT_MS = 30_000;
|
|
21
|
+
const INSTALL_TIMEOUT_MS = 120_000;
|
|
22
|
+
const LIFECYCLE_TIMEOUT_MS = 300_000;
|
|
23
|
+
const LOG_LIMIT_BYTES = 64 * 1024;
|
|
24
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
25
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
26
|
+
const REGISTRY_VERSION = /^\d+\.\d+\.\d+$/;
|
|
27
|
+
const REGISTRY_VERSION_MAX_LENGTH = 32;
|
|
28
|
+
const LOCK_BUSY = "Timed out waiting for Cairn lifecycle lock";
|
|
29
|
+
|
|
30
|
+
class UsageError extends Error {}
|
|
31
|
+
|
|
32
|
+
if (isCliEntry()) {
|
|
33
|
+
try {
|
|
34
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error(`Cairn auto-upgrade error: ${error instanceof Error ? error.message : String(error)}`);
|
|
37
|
+
process.exitCode = error instanceof UsageError ? 2 : 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Exit codes follow the root CLI: 0 success or help, 1 an execution error or a failed run, 2 a usage error.
|
|
42
|
+
export async function main(args, env = process.env) {
|
|
43
|
+
const [command, ...rest] = args;
|
|
44
|
+
if (command === undefined || command === "help" || command === "--help") {
|
|
45
|
+
console.log(AUTO_UPGRADE_USAGE);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
if (!["status", "on", "off", "run"].includes(command)) throw new UsageError(`unknown command: ${command}`);
|
|
49
|
+
if (rest.length > 0) throw new UsageError(`auto-upgrade ${command} does not accept arguments: ${rest.join(" ")}`);
|
|
50
|
+
if (command === "on" || command === "off") setAutoUpgrade(env, command === "on");
|
|
51
|
+
if (command !== "run") {
|
|
52
|
+
printJson(statusReport(env));
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
const report = await runAutoUpgrade(env);
|
|
56
|
+
printJson(report);
|
|
57
|
+
return ["failed", "check-failed", "manual"].includes(report.result?.kind) ? 1 : 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function statusReport(env = process.env) {
|
|
61
|
+
const paths = updatePaths(env);
|
|
62
|
+
const setting = autoUpgradeSetting(env, paths);
|
|
63
|
+
const manifest = readInstallManifest(paths);
|
|
64
|
+
const state = readUpdateState(paths);
|
|
65
|
+
return {
|
|
66
|
+
enabled: setting.enabled,
|
|
67
|
+
source: setting.source,
|
|
68
|
+
installed: manifest,
|
|
69
|
+
lastCheckedAt: state?.lastCheckedAt ?? null,
|
|
70
|
+
latestSeen: state?.latestSeen ?? null,
|
|
71
|
+
nextAttemptAt: state?.nextAttemptAt ?? null,
|
|
72
|
+
consecutiveFailures: state?.consecutiveFailures ?? 0,
|
|
73
|
+
failedVersion: state?.failedVersion ?? null,
|
|
74
|
+
lastResult: state?.lastResult ?? null,
|
|
75
|
+
pendingNotice: readPendingNotice(paths),
|
|
76
|
+
config: paths.config,
|
|
77
|
+
statePath: paths.state,
|
|
78
|
+
log: paths.log,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Other config.json keys are kept. A config.json that is not a readable JSON object is left as it is.
|
|
83
|
+
function setAutoUpgrade(env, enabled) {
|
|
84
|
+
const paths = updatePaths(env);
|
|
85
|
+
const read = readJsonFile(paths.config);
|
|
86
|
+
const object = read.status === "ok" && read.value && typeof read.value === "object" && !Array.isArray(read.value);
|
|
87
|
+
if (read.status !== "missing" && !object) throw new Error(`${paths.config} is not a readable JSON object; fix or remove it`);
|
|
88
|
+
writeJsonAtomic(paths.config, { ...(object ? read.value : {}), autoUpgrade: enabled });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function runAutoUpgrade(env = process.env) {
|
|
92
|
+
const paths = updatePaths(env);
|
|
93
|
+
if (!autoUpgradeSetting(env, paths).enabled) return { ran: false, reason: "disabled" };
|
|
94
|
+
if (!readInstallManifest(paths)) return { ran: false, reason: "not-installed" };
|
|
95
|
+
await assertNoSymlinkComponents(paths.home, paths.stage);
|
|
96
|
+
await mkdir(paths.root, { recursive: true });
|
|
97
|
+
// A held update.lock means another runner is working: exit without waiting. A lock left by a dead process is
|
|
98
|
+
// reclaimed by the lifecycle lock rules.
|
|
99
|
+
let report;
|
|
100
|
+
try {
|
|
101
|
+
report = await createLifecycleLock({ lifecycleLockPath: paths.lock })
|
|
102
|
+
.withLifecycleLock(() => runLocked(env, paths), { timeoutMs: 1, retryMs: 1 });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (String(error?.message).startsWith(LOCK_BUSY)) return { ran: false, reason: "locked" };
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
// An uninstall finished while this runner worked: its update state goes too, as uninstall would have removed it.
|
|
108
|
+
if (report.reason === "not-installed" && readJsonFile(paths.manifest).status === "missing") await removeUpdateState(env);
|
|
109
|
+
return report;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function runLocked(env, paths) {
|
|
113
|
+
// Read again under the lock: a lifecycle command or another runner may have changed the installation.
|
|
114
|
+
if (!autoUpgradeSetting(env, paths).enabled) return { ran: false, reason: "disabled" };
|
|
115
|
+
const manifest = readInstallManifest(paths);
|
|
116
|
+
if (!manifest) return { ran: false, reason: "not-installed" };
|
|
117
|
+
const now = new Date();
|
|
118
|
+
const previous = { ...emptyUpdateState(), ...(readUpdateState(paths) ?? {}) };
|
|
119
|
+
if (manifest.origin === "checkout") {
|
|
120
|
+
const state = { ...previous, lastCheckedAt: now.toISOString(), lastResult: result("skipped", now, manifest.version, null, "checkout-origin installation") };
|
|
121
|
+
writeJsonAtomic(paths.state, state);
|
|
122
|
+
return { ran: false, reason: "checkout", result: state.lastResult };
|
|
123
|
+
}
|
|
124
|
+
const log = [];
|
|
125
|
+
let state;
|
|
126
|
+
try {
|
|
127
|
+
const lookup = runNpm(["view", `${UPDATE_PACKAGE_NAME}@latest`, "version", "--json"], { cwd: paths.root, env, timeout: VIEW_TIMEOUT_MS });
|
|
128
|
+
log.push(logEntry("npm view", lookup));
|
|
129
|
+
const latest = registryVersion(lookup);
|
|
130
|
+
state = latest.error
|
|
131
|
+
? checkFailure(previous, { now, from: manifest.version, to: null, detail: latest.error })
|
|
132
|
+
: isNewer(latest.version, manifest.version)
|
|
133
|
+
? await upgradeTo(latest.version, { env, paths, manifest, previous, now, log })
|
|
134
|
+
: settled(previous, { now, kind: "current", from: manifest.version, to: latest.version });
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// An unexpected error (a file system failure around the stage) is recorded like a lookup failure: no notice, and
|
|
137
|
+
// a backoff so the next sessions do not retry it at once.
|
|
138
|
+
const detail = `unexpected error: ${error instanceof Error ? error.message : String(error)}`;
|
|
139
|
+
log.push(detail);
|
|
140
|
+
state = checkFailure(previous, { now, from: manifest.version, to: null, detail });
|
|
141
|
+
}
|
|
142
|
+
if (state.uninstalled) return { ran: false, reason: "not-installed" };
|
|
143
|
+
try {
|
|
144
|
+
writeJsonAtomic(paths.state, state);
|
|
145
|
+
return { ran: true, result: state.lastResult };
|
|
146
|
+
} finally {
|
|
147
|
+
writeLog(paths.log, now, log);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function upgradeTo(target, { env, paths, manifest, previous, now, log }) {
|
|
152
|
+
const from = manifest.version;
|
|
153
|
+
await rm(paths.stage, { recursive: true, force: true });
|
|
154
|
+
await assertNoSymlinkComponents(paths.home, paths.stage);
|
|
155
|
+
await mkdir(paths.stage, { recursive: true });
|
|
156
|
+
// npm installs into the nearest package.json; without this one it would climb to CAIRN_HOME or the home directory.
|
|
157
|
+
await writeFile(join(paths.stage, "package.json"), `${JSON.stringify({ name: "cairn-auto-upgrade-stage", private: true }, null, 2)}\n`);
|
|
158
|
+
try {
|
|
159
|
+
// The stage is the cwd, so no path is passed to npm (npm.cmd splits paths with spaces on Windows).
|
|
160
|
+
const install = runNpm(["install", "--ignore-scripts", "--no-audit", "--no-fund", "--no-save", `${UPDATE_PACKAGE_NAME}@${target}`], {
|
|
161
|
+
cwd: paths.stage, env, timeout: INSTALL_TIMEOUT_MS,
|
|
162
|
+
});
|
|
163
|
+
log.push(logEntry("npm install", install));
|
|
164
|
+
if (install.error || install.status !== 0) {
|
|
165
|
+
return checkFailure(previous, { now, from, to: target, detail: `npm install failed: ${lastLine(install)}` });
|
|
166
|
+
}
|
|
167
|
+
const problem = await verifyStagedPackage(paths.stage, target);
|
|
168
|
+
if (problem) return upgradeFailure(previous, { paths, now, from, to: target, detail: `verification failed: ${problem}` });
|
|
169
|
+
const lifecycle = spawnSync(process.execPath, [join(paths.stage, "node_modules", UPDATE_PACKAGE_NAME, "scripts", "cairn-lifecycle.mjs"), "upgrade", "--auto"], {
|
|
170
|
+
cwd: paths.root, env, encoding: "utf8", timeout: LIFECYCLE_TIMEOUT_MS, windowsHide: true, maxBuffer: 16 * 1024 * 1024,
|
|
171
|
+
});
|
|
172
|
+
log.push(logEntry("cairn-lifecycle upgrade --auto", lifecycle));
|
|
173
|
+
// An uninstall finished while the package was staged (`upgrade --auto` then installs nothing): there is nothing
|
|
174
|
+
// left to upgrade or to report.
|
|
175
|
+
if (readJsonFile(paths.manifest).status === "missing") return { uninstalled: true };
|
|
176
|
+
const installed = readInstallManifest(paths);
|
|
177
|
+
// The manifest decides: the files moved to the target version or they did not.
|
|
178
|
+
if (installed?.version !== target) {
|
|
179
|
+
return upgradeFailure(previous, { paths, now, from, to: target, detail: `upgrade exited ${lifecycle.status ?? lifecycle.error?.code}: ${lastLine(lifecycle)}` });
|
|
180
|
+
}
|
|
181
|
+
if (lifecycle.status === 0 || lifecycle.status === AUTO_UPGRADE_EXIT.manual) {
|
|
182
|
+
const kind = lifecycle.status === 0 ? "updated" : "manual";
|
|
183
|
+
writeJsonAtomic(paths.notice, noticeRecord(kind, from, target, now.toISOString()));
|
|
184
|
+
return settled(previous, { now, kind, from, to: target, detail: kind === "manual" ? lastLine(lifecycle) : null });
|
|
185
|
+
}
|
|
186
|
+
// The files moved but the run failed after its commit: reported once like a failed upgrade. The next check finds
|
|
187
|
+
// the target version installed, so the upgrade is not retried.
|
|
188
|
+
return upgradeFailure(previous, { paths, now, from, to: target, detail: `upgrade exited ${lifecycle.status ?? lifecycle.error?.code} after its commit: ${lastLine(lifecycle)}` });
|
|
189
|
+
} finally {
|
|
190
|
+
// A stage that cannot be removed (a held file, a changed permission) must not hide the result; the next run
|
|
191
|
+
// removes it before staging again.
|
|
192
|
+
try { await rm(paths.stage, { recursive: true, force: true }); } catch (error) { log.push(`stage removal failed: ${error.message}`); }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function settled(previous, { now, kind, from, to, detail = null }) {
|
|
197
|
+
return {
|
|
198
|
+
...previous, lastCheckedAt: now.toISOString(), latestSeen: to, consecutiveFailures: 0, nextAttemptAt: null,
|
|
199
|
+
failedVersion: null, lastResult: result(kind, now, from, to, detail),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Lookup and install failures (offline, no npm, a registry error) back off 1h, 2h, … up to 24h without a notice.
|
|
204
|
+
function checkFailure(previous, { now, from, to, detail }) {
|
|
205
|
+
const failures = previous.lastResult?.kind === "check-failed" ? failureCount(previous) + 1 : 1;
|
|
206
|
+
return {
|
|
207
|
+
...previous, latestSeen: to ?? previous.latestSeen, consecutiveFailures: failures,
|
|
208
|
+
nextAttemptAt: new Date(now.getTime() + Math.min(HOUR_MS * 2 ** (failures - 1), DAY_MS)).toISOString(),
|
|
209
|
+
lastResult: result("check-failed", now, from, to, detail),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// A version that failed verification or the upgrade backs off 24h, 48h, … up to 7 days, with one notice per version.
|
|
214
|
+
// A newer release starts over.
|
|
215
|
+
function upgradeFailure(previous, { paths, now, from, to, detail }) {
|
|
216
|
+
const sameVersion = previous.failedVersion === to;
|
|
217
|
+
const failures = sameVersion && previous.lastResult?.kind === "failed" ? failureCount(previous) + 1 : 1;
|
|
218
|
+
if (!sameVersion) writeJsonAtomic(paths.notice, noticeRecord("failed", from, to, now.toISOString()));
|
|
219
|
+
return {
|
|
220
|
+
...previous, lastCheckedAt: now.toISOString(), latestSeen: to, consecutiveFailures: failures, failedVersion: to,
|
|
221
|
+
nextAttemptAt: new Date(now.getTime() + Math.min(DAY_MS * 2 ** (failures - 1), 7 * DAY_MS)).toISOString(),
|
|
222
|
+
lastResult: result("failed", now, from, to, detail),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function failureCount(state) {
|
|
227
|
+
return Number.isSafeInteger(state.consecutiveFailures) && state.consecutiveFailures > 0 ? state.consecutiveFailures : 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function result(kind, now, from, to, detail) {
|
|
231
|
+
return { kind, at: now.toISOString(), from, to, detail };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Only an exact x.y.z release of at most 32 characters is accepted; npm 12 may wrap it in an object.
|
|
235
|
+
function registryVersion(lookup) {
|
|
236
|
+
if (lookup.error || lookup.status !== 0) return { error: `npm view failed: ${lastLine(lookup)}` };
|
|
237
|
+
let value;
|
|
238
|
+
try { value = JSON.parse(lookup.stdout); } catch { return { error: "npm view did not return JSON" }; }
|
|
239
|
+
if (value && typeof value === "object" && !Array.isArray(value)) value = value.version;
|
|
240
|
+
if (typeof value !== "string" || value.length > REGISTRY_VERSION_MAX_LENGTH || !REGISTRY_VERSION.test(value)) {
|
|
241
|
+
return { error: `npm view returned an invalid version: ${String(JSON.stringify(value)).slice(0, 80)}` };
|
|
242
|
+
}
|
|
243
|
+
return { version: value };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Strictly newer by major.minor.patch, or the same core release of an installed prerelease. Never a downgrade.
|
|
247
|
+
function isNewer(latest, installed) {
|
|
248
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(-.+)?$/.exec(installed);
|
|
249
|
+
if (!match) return false;
|
|
250
|
+
const next = latest.split(".").map(BigInt);
|
|
251
|
+
const current = match.slice(1, 4).map(BigInt);
|
|
252
|
+
for (let index = 0; index < 3; index += 1) if (next[index] !== current[index]) return next[index] > current[index];
|
|
253
|
+
return Boolean(match[4]);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// The staged package must be exactly cairn-ai@<target> with no dependencies, symlinks, or special files.
|
|
257
|
+
async function verifyStagedPackage(stage, target) {
|
|
258
|
+
const modules = join(stage, "node_modules");
|
|
259
|
+
const packageRoot = join(modules, UPDATE_PACKAGE_NAME);
|
|
260
|
+
try {
|
|
261
|
+
for (const entry of (await readdir(modules)).sort()) {
|
|
262
|
+
if (!entry.startsWith(".") && entry !== UPDATE_PACKAGE_NAME) return `unexpected package in node_modules: ${entry}`;
|
|
263
|
+
}
|
|
264
|
+
const special = await firstSpecialEntry(packageRoot);
|
|
265
|
+
if (special) return `not a regular file or directory: ${relative(stage, special)}`;
|
|
266
|
+
const manifest = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
|
|
267
|
+
if (manifest?.name !== UPDATE_PACKAGE_NAME || manifest.version !== target) {
|
|
268
|
+
return `package.json names ${manifest?.name}@${manifest?.version}, expected ${UPDATE_PACKAGE_NAME}@${target}`;
|
|
269
|
+
}
|
|
270
|
+
const plugin = JSON.parse(await readFile(join(packageRoot, ".codex-plugin", "plugin.json"), "utf8"));
|
|
271
|
+
if (plugin?.name !== "cairn" || plugin.version !== target) {
|
|
272
|
+
return `.codex-plugin/plugin.json names ${plugin?.name}@${plugin?.version}, expected cairn@${target}`;
|
|
273
|
+
}
|
|
274
|
+
if (!(await lstat(join(packageRoot, "scripts", "cairn-lifecycle.mjs"))).isFile()) return "scripts/cairn-lifecycle.mjs is not a regular file";
|
|
275
|
+
return null;
|
|
276
|
+
} catch (error) {
|
|
277
|
+
return error instanceof Error ? error.message : String(error);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function firstSpecialEntry(path) {
|
|
282
|
+
const info = await lstat(path);
|
|
283
|
+
if (info.isFile()) return null;
|
|
284
|
+
if (!info.isDirectory()) return path;
|
|
285
|
+
for (const entry of (await readdir(path)).sort()) {
|
|
286
|
+
const found = await firstSpecialEntry(join(path, entry));
|
|
287
|
+
if (found) return found;
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// CAIRN_NPM_CLI follows the CAIRN_CLAUDE_CLI rule: a .mjs/.js stand-in runs through this Node binary, anything else
|
|
293
|
+
// is run directly (through the shell on Windows for npm.cmd).
|
|
294
|
+
function runNpm(args, { cwd, env, timeout }) {
|
|
295
|
+
const cli = env.CAIRN_NPM_CLI ?? "npm";
|
|
296
|
+
const options = { cwd, env, encoding: "utf8", timeout, windowsHide: true, maxBuffer: 16 * 1024 * 1024 };
|
|
297
|
+
if (/\.m?js$/.test(cli)) return spawnSync(process.execPath, [cli, ...args], options);
|
|
298
|
+
return spawnSync(cli, args, { ...options, shell: process.platform === "win32" });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function logEntry(label, run) {
|
|
302
|
+
return `$ ${label} (exit ${run.status ?? run.error?.code ?? run.signal ?? "unknown"})\n${run.stdout ?? ""}${run.stderr ?? ""}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// The last run's npm and lifecycle output, keeping the end when it exceeds 64 KiB.
|
|
306
|
+
function writeLog(path, now, entries) {
|
|
307
|
+
try {
|
|
308
|
+
let text = `${now.toISOString()}\n${entries.join("\n")}\n`;
|
|
309
|
+
const bytes = Buffer.from(text, "utf8");
|
|
310
|
+
if (bytes.length > LOG_LIMIT_BYTES) text = bytes.subarray(bytes.length - LOG_LIMIT_BYTES).toString("utf8");
|
|
311
|
+
while (Buffer.byteLength(text, "utf8") > LOG_LIMIT_BYTES) text = text.slice(1);
|
|
312
|
+
writeFileAtomic(path, text);
|
|
313
|
+
} catch {
|
|
314
|
+
// The log is diagnostic only.
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function lastLine(run) {
|
|
319
|
+
const text = [run.stderr, run.stdout].map((value) => String(value ?? "").trim()).find(Boolean) ?? run.error?.message ?? `exit ${run.status}`;
|
|
320
|
+
return text.split("\n").filter((line) => line.trim()).at(-1)?.trim().slice(0, 300) ?? "";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function printJson(value) {
|
|
324
|
+
console.log(JSON.stringify(value, null, 2));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isCliEntry() {
|
|
328
|
+
if (!process.argv[1]) return false;
|
|
329
|
+
try {
|
|
330
|
+
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
|
331
|
+
} catch {
|
|
332
|
+
return import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
333
|
+
}
|
|
334
|
+
}
|