@piagent/platform 1.1.4 → 1.1.6

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/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  This file records release-facing changes for Pi Agent Platform. Copy the relevant version block into GitHub Releases when publishing a tag.
4
4
 
5
+ ## v1.1.6 - 2026-07-27
6
+
7
+ Monorepo fixes found auditing how the platform scopes itself to a project. Every case was reproduced against a real directory tree before it was changed.
8
+
9
+ ### Fixed
10
+
11
+ - `be-readonly-fe` keeps the backend read-only in a monorepo. Its `readOnlyPaths` and `shellProtectedPaths` were anchored at the repository root, so `backend/**` and `apps/api/**` covered a backend sitting at the top of the tree and nothing else. Choosing the profile on a repository whose backend lives in `packages/api`, `apps/backend` or `services/payments` produced a profile that announced the backend was read-only while leaving every file in it writable — the one guarantee the profile exists to make. The backend directories under `apps/`, `packages/` and all of `services/*` are covered now.
12
+
13
+ They are enumerated rather than matched with `**/api/**`, which would have swept in `packages/web/src/api/` — the frontend's own HTTP client — and frozen the half of the repository the profile is meant to leave writable.
14
+
15
+ - The frontend verify command finds a frontend that is not at `frontend/` or `apps/web/`. It fell through to the repository root, where a workspace root has no `type-check` script, so verification reported success having run nothing. `apps/frontend`, `packages/web`, `packages/frontend` and `client` are looked at too.
16
+
17
+ - `apps/backend` is read as a backend. `apps/frontend` counted as a frontend marker while only `apps/api` and `apps/server` counted as backend ones, so a repository that named both halves symmetrically was detected as frontend-only and offered `web-frontend`. `client/` and `server/` at the root were not markers either.
18
+
19
+ - Frontend and backend are detected inside declared workspaces. Nothing read the `workspaces` field or `pnpm-workspace.yaml`, and nothing looked under `packages/`, so a pnpm monorepo fell through every marker to `generic`. Each declared package is now read through its own manifest and layout, which is what identifies `packages/storefront` and `packages/gateway` as the two halves of a fullstack repository. Only a trailing wildcard is expanded, absolute and `..` patterns are refused, and at most 64 packages are read.
20
+
21
+ ### Changed
22
+
23
+ - Profile detection lives in `packages/piagent-core/extensions/project-shape.js`. `piagent_profile_options` and `scripts/init-project.sh` each carried their own copy of the rules and had drifted; the `apps/backend` gap above was present in both, and a fix to either would have left the other wrong. The file is covered by the capability lock, because steering which profile a project is offered is a way to choose what the guard ends up enforcing.
24
+
25
+ ## v1.1.5 - 2026-07-27
26
+
27
+ ### Added
28
+
29
+ - `piagent-update` moves a machine from one release to the next in one command. A release is three independently versioned things — the Pi host, the npm-global terminal helper, and the Pi package — and they have to move in that order, because `piagent-install` refuses to run against a host that does not match the version its own package.json pins and does not install one itself. Doing it by hand is three commands and one ordering rule that fails late when it is got wrong.
30
+
31
+ Every version the run needs is resolved from the registry before anything is installed, including the host version taken from the *target* helper's published metadata rather than the running one, so `--check` and `--dry-run` describe exactly what a real run would do. After the helper replaces itself the run confirms that the version on disk is the one it asked for, and stops before touching the Pi package if it is not. Running it from a git checkout is refused rather than replacing that checkout's commands with a published build.
32
+
33
+ `--check`, `--dry-run`, `--version X.Y.Z`, `--force`, `--project <path>`, `--no-host`, `--no-package`, and pass-through to `piagent-install` after `--`.
34
+
35
+ ### Fixed
36
+
37
+ - `docs/release-install-policy.md`, `docs/team-onboarding.md` and `docs/command-reference-vietnamese.md` described the manual update sequence without saying why its order is mandatory. `piagent-install` fails on a host mismatch; the auto-install of a matching host lives in `setup.sh` and does not apply to that flow.
38
+
5
39
  ## v1.1.4 - 2026-07-27
6
40
 
7
41
  ### Fixed
package/README.md CHANGED
@@ -283,7 +283,7 @@ This repository intentionally excludes:
283
283
 
284
284
  ## Maturity
285
285
 
286
- The current package version is read from package metadata and release tags. Personal machines may follow the unpinned package source when accepting ongoing updates; production/team quickstarts and committed project settings should pin an explicit tag such as `v1.1.4` or a reviewed commit.
286
+ The current package version is read from package metadata and release tags. Personal machines may follow the unpinned package source when accepting ongoing updates; production/team quickstarts and committed project settings should pin an explicit tag such as `v1.1.6` or a reviewed commit.
287
287
 
288
288
  Ready for:
289
289
 
@@ -32,7 +32,11 @@
32
32
  "api/**",
33
33
  "apps/api/**",
34
34
  "apps/server/**",
35
- "services/backend/**",
35
+ "apps/backend/**",
36
+ "packages/api/**",
37
+ "packages/server/**",
38
+ "packages/backend/**",
39
+ "services/*/**",
36
40
  "**/migrations/**"
37
41
  ],
38
42
  "shellProtectedPaths": [
@@ -47,7 +51,11 @@
47
51
  "api/**",
48
52
  "apps/api/**",
49
53
  "apps/server/**",
50
- "services/backend/**",
54
+ "apps/backend/**",
55
+ "packages/api/**",
56
+ "packages/server/**",
57
+ "packages/backend/**",
58
+ "services/*/**",
51
59
  "**/migrations/**",
52
60
  "**/node_modules/**",
53
61
  "**/dist/**",
@@ -85,10 +93,10 @@
85
93
  "test -s README.md"
86
94
  ],
87
95
  "frontendSource": [
88
- "if test -f frontend/package.json; then cd frontend && npm run type-check --if-present && npm run lint --if-present && npm test --if-present; elif test -f apps/web/package.json; then cd apps/web && npm run type-check --if-present && npm run lint --if-present && npm test --if-present; elif test -f package.json; then npm run type-check --if-present && npm run lint --if-present && npm test --if-present; else echo 'No frontend verify command configured; update .pi/piagent-profile.json'; fi"
96
+ "if test -f frontend/package.json; then cd frontend; elif test -f apps/web/package.json; then cd apps/web; elif test -f apps/frontend/package.json; then cd apps/frontend; elif test -f packages/web/package.json; then cd packages/web; elif test -f packages/frontend/package.json; then cd packages/frontend; elif test -f client/package.json; then cd client; elif test -f package.json; then :; else echo 'No frontend verify command configured; update .pi/piagent-profile.json'; exit 0; fi; npm run type-check --if-present && npm run lint --if-present && npm test --if-present"
89
97
  ],
90
98
  "frontendRuntime": [
91
- "if test -f frontend/package.json; then cd frontend && npm run test:e2e --if-present; elif test -f apps/web/package.json; then cd apps/web && npm run test:e2e --if-present; elif test -f package.json; then npm run test:e2e --if-present; else echo 'No frontend runtime verify command configured; update .pi/piagent-profile.json'; fi"
99
+ "if test -f frontend/package.json; then cd frontend; elif test -f apps/web/package.json; then cd apps/web; elif test -f apps/frontend/package.json; then cd apps/frontend; elif test -f packages/web/package.json; then cd packages/web; elif test -f packages/frontend/package.json; then cd packages/frontend; elif test -f client/package.json; then cd client; elif test -f package.json; then :; else echo 'No frontend runtime verify command configured; update .pi/piagent-profile.json'; exit 0; fi; npm run test:e2e --if-present"
92
100
  ]
93
101
  },
94
102
  "mcpCapabilities": [
@@ -419,10 +419,13 @@ Các lệnh này chạy ngoài Pi.
419
419
  | Command | Dùng khi nào |
420
420
  |---|---|
421
421
  | `npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.82.0` | Cài Pi CLI tương thích với release hiện tại. |
422
- | `npm install -g --ignore-scripts @piagent/platform@1.1.4` | Cài terminal helper `piagent-*` từ release tag hiện tại. |
423
- | `pi install git:github.com/Vt-mmm/piagent@v1.1.4` | Cài pinned release khi cần reproducible team setup. |
422
+ | `npm install -g --ignore-scripts @piagent/platform@1.1.6` | Cài terminal helper `piagent-*` từ release tag hiện tại. |
423
+ | `pi install git:github.com/Vt-mmm/piagent@v1.1.6` | Cài pinned release khi cần reproducible team setup. |
424
424
  | `pi install git:github.com/Vt-mmm/piagent` | Cài latest package platform cho máy cá nhân/sandbox. |
425
- | Cài exact Pi host của release, rồi `npm install -g --ignore-scripts @piagent/platform@X.Y.Z` và `piagent-install --stable` | Full update: đồng bộ host, npm-global helper và Pi package. Mỗi release pin một Pi host chính xác; lấy đúng version của release đang cài trong [release/install policy](release-install-policy.md). |
425
+ | `piagent-update --check` | Báo version hiện tại vs version sẽ lên cho cả ba thành phần; không cài gì. |
426
+ | `piagent-update` | Full update một lệnh: Pi host → npm-global helper → Pi package, đúng thứ tự release yêu cầu. Thêm `--project <path>` để chạy doctor sau. |
427
+ | `piagent-update --version X.Y.Z` | Update tới đúng một release thay vì latest; dùng cho rollout theo lô hoặc rollback. |
428
+ | Cài exact Pi host của release, rồi `npm install -g --ignore-scripts @piagent/platform@X.Y.Z` và `piagent-install --stable` | Sequence thủ công tương đương. Thứ tự bắt buộc: `piagent-install` fail nếu Pi host lệch bản pin và không tự cài host. Version pin của từng release nằm trong [release/install policy](release-install-policy.md). |
426
429
  | Cài exact Pi host ghi trong release cũ, rồi helper `vPREVIOUS` và `piagent-install --stable` | Full rollback; đánh giá dependency risk của host cũ trước khi hạ version. |
427
430
  | `piagent-install --stable --dry-run` | Preview Pi package matching với helper hiện tại; stable resolve tag → commit SHA. |
428
431
  | `piagent-install --stable` | Cài Pi package matching với helper hiện tại bằng resolved commit SHA. |
@@ -14,9 +14,9 @@ Repo này phải chạy được cho nhiều project/domain khác nhau. Vì vậ
14
14
 
15
15
  | Use case | Package source |
16
16
  |---|---|
17
- | Team pinned release | `git:github.com/Vt-mmm/piagent@v1.1.4` |
17
+ | Team pinned release | `git:github.com/Vt-mmm/piagent@v1.1.6` |
18
18
  | Personal/sandbox dev | `git:github.com/Vt-mmm/piagent` |
19
- | Registry distribution | `npm:@piagent/platform@1.1.4` |
19
+ | Registry distribution | `npm:@piagent/platform@1.1.6` |
20
20
  | Enterprise npm (fork under another scope) | `npm:@your-scope/platform@x.y.z` |
21
21
  | Local platform dev | `/path/to/piagent` |
22
22
 
@@ -26,7 +26,7 @@ Latest tiện cho máy cá nhân muốn nhận cập nhật nhanh. Pin tag/commi
26
26
 
27
27
  | Channel | Command | Target | Policy |
28
28
  |---|---|---|---|
29
- | `stable` | `bash scripts/install-global.sh --stable` | Helper release tag resolved to commit SHA, currently `v1.1.4` | Install the Pi package matching the helper. |
29
+ | `stable` | `bash scripts/install-global.sh --stable` | Helper release tag resolved to commit SHA, currently `v1.1.6` | Install the Pi package matching the helper. |
30
30
  | `exact` | `bash scripts/install-global.sh --version vX.Y.Z --resolve-tag` | Requested tag resolved to commit SHA | Pi-package-only rollout, rollback, and incident recovery. |
31
31
  | `dev` | `bash scripts/install-global.sh --dev` | Moving Git source | Personal/sandbox only; do not commit into project settings. |
32
32
  | `local` | `bash scripts/install-global.sh --local` | Current checkout path | Platform development only. |
@@ -53,7 +53,7 @@ Root `package.json` có `pi` manifest trỏ tới:
53
53
  Do đó team có thể:
54
54
 
55
55
  ```bash
56
- pi install git:github.com/Vt-mmm/piagent@v1.1.4
56
+ pi install git:github.com/Vt-mmm/piagent@v1.1.6
57
57
  ```
58
58
 
59
59
  Không cần biết internal folder `packages/piagent-core`. Source không pin chỉ dành cho personal/sandbox như bảng channel ở trên.
@@ -65,12 +65,12 @@ Team nên install global package một lần:
65
65
  ```bash
66
66
  node --version # >= 22.19.0
67
67
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.82.0
68
- npm install -g --ignore-scripts @piagent/platform@1.1.4
68
+ npm install -g --ignore-scripts @piagent/platform@1.1.6
69
69
  piagent-install --stable --dry-run
70
70
  piagent-install --stable
71
71
  ```
72
72
 
73
- `pi install git:github.com/Vt-mmm/piagent@v1.1.4` vẫn hợp lệ nếu chỉ cần cài Pi package. Lệnh đó không tự tạo các binary terminal `piagent-*`; muốn có helper global thì dùng `npm install -g --ignore-scripts @piagent/platform@1.1.4`.
73
+ `pi install git:github.com/Vt-mmm/piagent@v1.1.6` vẫn hợp lệ nếu chỉ cần cài Pi package. Lệnh đó không tự tạo các binary terminal `piagent-*`; muốn có helper global thì dùng `npm install -g --ignore-scripts @piagent/platform@1.1.6`.
74
74
 
75
75
  Support matrix hiện tại: macOS Apple Silicon + Bash và Linux x64 + Bash đã được verify; macOS Intel + Bash và Linux ARM64 + Bash là supported target cần chạy smoke trước rollout; native Windows chưa phải target rollout team; WSL2 experimental. Chi tiết và version runtime nằm trong [release/install policy](release-install-policy.md).
76
76
 
@@ -103,7 +103,7 @@ Nếu muốn commit sẵn `.pi/piagent-profile.json` vào repo hoặc bootstrap
103
103
  ```bash
104
104
  bash scripts/setup.sh /path/to/project \
105
105
  --profile be-readonly-fe \
106
- --package-source git:github.com/Vt-mmm/piagent@v1.1.4 \
106
+ --package-source git:github.com/Vt-mmm/piagent@v1.1.6 \
107
107
  --mcp-preset core \
108
108
  --subagents-preset safe
109
109
  ```
@@ -18,12 +18,12 @@ Support matrix release hiện tại: macOS Apple Silicon + Bash và Linux x64 +
18
18
  ```bash
19
19
  node --version # >= 22.19.0
20
20
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.82.0
21
- npm install -g --ignore-scripts @piagent/platform@1.1.4
21
+ npm install -g --ignore-scripts @piagent/platform@1.1.6
22
22
  piagent-install --stable --dry-run
23
23
  piagent-install --stable
24
24
  ```
25
25
 
26
- Khi seed `.pi/settings.json` cho team/repo cần audit lặp lại, giữ package source dạng pinned tag như `git:github.com/Vt-mmm/piagent@v1.1.4`. Máy cá nhân có thể dùng `git:github.com/Vt-mmm/piagent` để theo latest.
26
+ Khi seed `.pi/settings.json` cho team/repo cần audit lặp lại, giữ package source dạng pinned tag như `git:github.com/Vt-mmm/piagent@v1.1.6`. Máy cá nhân có thể dùng `git:github.com/Vt-mmm/piagent` để theo latest.
27
27
 
28
28
  Nếu đang ở source checkout của platform, dùng helper theo channel để preview trước khi đổi:
29
29
 
@@ -99,7 +99,7 @@ install package once
99
99
 
100
100
  ```bash
101
101
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.82.0
102
- npm install -g --ignore-scripts @piagent/platform@1.1.4
102
+ npm install -g --ignore-scripts @piagent/platform@1.1.6
103
103
  piagent-install --stable --dry-run
104
104
  piagent-install --stable
105
105
  ```
@@ -156,6 +156,18 @@ Trong Pi:
156
156
 
157
157
  Chọn provider/account như OpenAI/Codex hoặc Anthropic/Claude. Credential nằm trong Pi user dir, không nằm trong repo và không được commit.
158
158
 
159
+ Browser login của OpenAI có hai chỗ trông như treo. Cả hai là lỗi Pi host, không phải platform, và còn nguyên ở `0.82.0` lẫn `0.82.1`.
160
+
161
+ Paste xong callback URL mà màn hình đứng im: **bấm Enter**. Ô nhập callback chỉ hiện hint `to cancel`, không hiện `to submit`, nên nó đang chờ submit chứ không đơ.
162
+
163
+ Đã Enter rồi mà terminal khoá luôn, Esc không thoát được: Pi lưu credential xong mới refresh model catalog từ `pi.dev`, và request đó không có timeout. Firewall hoặc proxy giữ connection im lặng thì `/login` chờ mãi. Credential thực tế đã lưu rồi, nên mở tab mới vẫn dùng được. Login bằng đường này để tránh hẳn:
164
+
165
+ ```bash
166
+ pi --offline
167
+ ```
168
+
169
+ Rồi `/login` → browser → paste → Enter. `--offline` chỉ chặn bước refresh catalog gây treo, OAuth vẫn chạy. Xong thì thoát và mở `pi` bình thường.
170
+
159
171
  ### Chọn model và thinking
160
172
 
161
173
  Trong Pi:
@@ -269,7 +281,7 @@ Không phải daily default. Dùng khi muốn tạo sẵn `.pi` files cho projec
269
281
  bash /path/to/piagent/scripts/setup.sh /path/to/project \
270
282
  --project-only \
271
283
  --profile auto \
272
- --package-source git:github.com/Vt-mmm/piagent@v1.1.4 \
284
+ --package-source git:github.com/Vt-mmm/piagent@v1.1.6 \
273
285
  --mcp-preset core \
274
286
  --subagents-preset safe
275
287
  ```
@@ -842,8 +854,8 @@ Watchdog là optional adversarial reviewer ở cuối turn, không bật mặc
842
854
 
843
855
  | Command | Dùng để |
844
856
  |---|---|
845
- | `npm install -g --ignore-scripts @piagent/platform@1.1.4` | Cài terminal helper `piagent-*` từ release tag hiện tại. |
846
- | `pi install git:github.com/Vt-mmm/piagent@v1.1.4` | Install pinned release cho reproducible team setup. |
857
+ | `npm install -g --ignore-scripts @piagent/platform@1.1.6` | Cài terminal helper `piagent-*` từ release tag hiện tại. |
858
+ | `pi install git:github.com/Vt-mmm/piagent@v1.1.6` | Install pinned release cho reproducible team setup. |
847
859
  | `pi install git:github.com/Vt-mmm/piagent` | Install latest platform package cho máy cá nhân/sandbox. |
848
860
  | Cài exact Pi host của release, rồi `npm install -g --ignore-scripts @piagent/platform@X.Y.Z` và `piagent-install --stable` | Full update: đồng bộ host, terminal helper và Pi package. Mỗi release pin một Pi host chính xác; lấy đúng version của release đang cài trong [release/install policy](release-install-policy.md). |
849
861
  | Cài exact Pi host ghi trong release cũ, rồi helper `vPREVIOUS` và `piagent-install --stable` | Full rollback; đánh giá lại dependency findings của host cũ trước khi hạ version. |
@@ -940,7 +952,7 @@ Mở lại Pi session sau khi install.
940
952
  Cài lại terminal helper đúng release rồi kiểm tra `PATH`:
941
953
 
942
954
  ```bash
943
- npm install -g --ignore-scripts @piagent/platform@1.1.4
955
+ npm install -g --ignore-scripts @piagent/platform@1.1.6
944
956
  command -v piagent-install
945
957
  ```
946
958
 
@@ -68,6 +68,22 @@ Path policy fields have distinct meanings:
68
68
  - `readOnlyPaths`: allow `read`/`grep`/`find`/`ls`, but block `write`/`edit` and shell access.
69
69
  - `shellProtectedPaths`: block shell access only. Do not rely on this field to block write/edit; `profile-doctor` and `team-doctor` warn when a path is present only here.
70
70
 
71
+ Glob trong các field này neo từ project root, nên `backend/**` chỉ khớp backend nằm ở root. Với monorepo, phải liệt kê đúng vị trí thật (`packages/api/**`, `services/*/**`). Không dùng `**/api/**`: nó khớp luôn `packages/web/src/api/` — thư mục HTTP client của frontend — và biến chính phần được phép sửa thành read-only.
72
+
73
+ ## Monorepo và workspace
74
+
75
+ Một project = một thư mục mở `pi`. Guard đọc profile tại `<cwd>/.pi/piagent-profile.json` và không đi ngược lên tìm, nên mở `pi` ở thư mục cha chứa nhiều project sẽ bỏ qua profile của từng project con và chạy unprofiled.
76
+
77
+ Detect FE/BE đọc theo thứ tự:
78
+
79
+ 1. Tên thư mục ở root: `frontend`, `web`, `client`, `ui` cho FE; `backend`, `server`, `api` cho BE.
80
+ 2. Cùng các tên đó ở một cấp dưới `apps/`, `packages/`, `services/`.
81
+ 3. Package khai báo trong `workspaces` (package.json, cả dạng mảng lẫn `{ "packages": [...] }`) hoặc `pnpm-workspace.yaml` — mỗi package đọc dependency và config của chính nó, nên `packages/storefront` + `packages/gateway` vẫn ra `fullstack` dù tên không gợi ý gì.
82
+
83
+ Chỉ wildcard ở segment cuối được mở rộng (`packages/*`, `apps/**`); pattern tuyệt đối, pattern có `..`, và pattern loại trừ `!` bị bỏ qua. Số package đọc tối đa 64.
84
+
85
+ Rule nằm ở `packages/piagent-core/extensions/project-shape.js`. Cả `piagent_profile_options` lẫn `scripts/init-project.sh` cùng gọi file này; trước đây mỗi bên giữ một bản riêng và đã lệch nhau.
86
+
71
87
  ## Built-in adapters
72
88
 
73
89
  | Profile | Dùng cho | Verify mặc định |
@@ -84,6 +100,11 @@ Path policy fields have distinct meanings:
84
100
  | `mobile` | React Native/Flutter | npm test / flutter test nếu tool có |
85
101
  | `docs` | Docs portal/manual | markdown diff check + test nếu project có |
86
102
 
103
+ `be-readonly-fe` giữ read-only cho backend ở root (`backend`, `server`, `api`), ở một cấp dưới `apps/` và `packages/` (`api`, `server`, `backend`), toàn bộ `services/*`, và mọi `**/migrations/**`. Profile built-in là điểm khởi đầu, không phải danh sách đầy đủ — sửa `readOnlyPaths` **và** `shellProtectedPaths` trong `.pi/piagent-profile.json` cho đúng repo:
104
+
105
+ - backend đặt tên khác (`apps/gateway`, `packages/core-service`): thêm path vào cả hai field.
106
+ - frontend nằm trong `services/` (ví dụ `services/web`): `services/*/**` sẽ khoá nhầm nó. Thay pattern đó bằng danh sách service backend cụ thể. Triệu chứng là một write bị chặn kèm lý do trỏ tới `readOnlyPaths`, không phải lỗi im lặng.
107
+
87
108
  ## Runtime profile selection
88
109
 
89
110
  Default UX không bắt buộc chạy bash để set profile. Sau global install, vào project chạy:
@@ -47,6 +47,8 @@ pi install /path/to/piagent
47
47
  - `1.1.2`: đóng TOCTOU của document reader, hash evidence tính trên text đã redact, publish chờ đủ verify matrix.
48
48
  - `1.1.3`: command mang secret không còn được dùng làm verify evidence, document reader từ chối FIFO thay vì treo, redact secret ngắn, redirect phải về đúng trang được kiểm.
49
49
  - `1.1.4`: nhận diện secret dạng CLI option (`--token`, `--password`, `--api-key`) và Authorization header ở mọi vị trí argument.
50
+ - `1.1.5`: thêm `piagent-update` — một lệnh update cả Pi host, terminal helper và Pi package đúng thứ tự.
51
+ - `1.1.6`: `be-readonly-fe` giữ được backend read-only trong monorepo, detect FE/BE đọc cả workspace, và rule detect gộp về một chỗ.
50
52
 
51
53
  ## Không publish
52
54
 
@@ -10,7 +10,7 @@ This file is the canonical install, update, rollback, and release checklist. Oth
10
10
 
11
11
  All supported environments require Node.js `>=22.19.0` and Pi Coding Agent `0.82.0`. The Pi host is installed as a Node CLI; Pi Agent Platform still defines its own release matrix because the terminal helpers and shell policy rely on Bash/POSIX behavior.
12
12
 
13
- | Surface | Status for v1.1.4 | Rollout guidance |
13
+ | Surface | Status for v1.1.6 | Rollout guidance |
14
14
  |---|---|---|
15
15
  | macOS Apple Silicon (`darwin/arm64`) + Bash | Verified for this release. | Safe default for team rollout after normal project smoke tests. |
16
16
  | Linux x64 + Bash | Verified in GitHub Actions. | Safe default for CI/server usage after normal project smoke tests. |
@@ -33,7 +33,7 @@ These three components are versioned independently. A full install, update, or r
33
33
 
34
34
  | Channel | Source shape | Mutability | Use when |
35
35
  |---|---|---:|---|
36
- | `stable` | `git:github.com/Vt-mmm/piagent@<resolved-commit-sha>` from `v1.1.4` | Fixed after resolution | Default for team rollout. |
36
+ | `stable` | `git:github.com/Vt-mmm/piagent@<resolved-commit-sha>` from `v1.1.6` | Fixed after resolution | Default for team rollout. |
37
37
  | `exact` | Tag, reviewed commit, or a tag resolved with `--resolve-tag` | Fixed when using a commit SHA | Pi-package-only roll forward, rollback, or reproduction. |
38
38
  | `dev` | `git:github.com/Vt-mmm/piagent` | Moving | Personal machine or sandbox only. |
39
39
  | `local` | `/path/to/piagent` | Local workspace | Platform development and dry-run validation. |
@@ -53,7 +53,7 @@ Use this flow when the team needs both terminal commands and the Pi package:
53
53
  ```bash
54
54
  node --version # >= 22.19.0
55
55
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.82.0
56
- npm install -g --ignore-scripts @piagent/platform@1.1.4
56
+ npm install -g --ignore-scripts @piagent/platform@1.1.6
57
57
  piagent-install --stable --dry-run
58
58
  piagent-install --stable
59
59
  ```
@@ -61,8 +61,8 @@ piagent-install --stable
61
61
  The stable preview and apply output includes:
62
62
 
63
63
  ```text
64
- currentRelease: v1.1.4 (helper package version)
65
- tag: v1.1.4
64
+ currentRelease: v1.1.6 (helper package version)
65
+ tag: v1.1.6
66
66
  resolvedCommit: <40-char-sha>
67
67
  source: git:github.com/Vt-mmm/piagent@<40-char-sha>
68
68
  ```
@@ -79,14 +79,26 @@ bash scripts/install-global.sh --stable
79
79
  Use this only when terminal commands are not needed:
80
80
 
81
81
  ```bash
82
- pi install git:github.com/Vt-mmm/piagent@v1.1.4
82
+ pi install git:github.com/Vt-mmm/piagent@v1.1.6
83
83
  ```
84
84
 
85
85
  Direct `pi install` does not create `piagent-*` commands on `PATH`.
86
86
 
87
87
  ## Full platform update
88
88
 
89
- Update the exact supported Pi host first, then the npm-global helper, then let that target helper resolve and install its matching stable Pi package:
89
+ `piagent-update` does the whole sequence in one command:
90
+
91
+ ```bash
92
+ piagent-update --check
93
+ piagent-update --dry-run
94
+ piagent-update --project /path/to/project
95
+ ```
96
+
97
+ It resolves the target helper version and, from that version's published metadata, the exact Pi host that version pins — both before installing anything, so `--check` and `--dry-run` describe a real run. It then installs the host, replaces the helper, verifies that the version now on disk is the one it asked for, and only then runs the new helper's installer for the Pi package. `--version X.Y.Z` targets an exact release instead of latest, `--no-host` and `--no-package` narrow the run, and anything after `--` is passed to `piagent-install`.
98
+
99
+ Running it from a git checkout is refused rather than replacing that checkout's commands with a published build; update a checkout with `git pull` and `install-global.sh --local`.
100
+
101
+ The same sequence by hand, which must keep this order because `piagent-install` fails against a host that does not match the version it pins and does not install one itself:
90
102
 
91
103
  ```bash
92
104
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.82.0
@@ -21,7 +21,7 @@ This document covers the packaged guard, project profiles, capability locks, ins
21
21
  - The operator, operating-system account, Pi host, and explicitly trusted project repository are trusted to execute code.
22
22
  - Project-local Pi resources are loaded only after Pi project trust or an explicit operator override.
23
23
  - A human remains available to approve destructive and external-provider actions.
24
- - Supported release verification covers macOS Apple Silicon + Bash, Linux x64 + Bash, Node.js `>=22.19.0`, and the exact Pi host version declared by the release. macOS Intel + Bash and Linux ARM64 + Bash are supported targets that need local smoke verification before broad rollout. Native Windows is not a team-rollout target for v1.1.4, and WSL2 is experimental/unverified.
24
+ - Supported release verification covers macOS Apple Silicon + Bash, Linux x64 + Bash, Node.js `>=22.19.0`, and the exact Pi host version declared by the release. macOS Intel + Bash and Linux ARM64 + Bash are supported targets that need local smoke verification before broad rollout. Native Windows is not a team-rollout target for v1.1.6, and WSL2 is experimental/unverified.
25
25
  - Registry, GitHub, model-provider, MCP-provider, and Vercel controls are external dependencies. Their account security and platform guarantees are not replaced by this repository.
26
26
 
27
27
  ## Threat actors and failure modes
@@ -9,7 +9,7 @@ Một thành viên mới không cần biết local path của maintainer. Luồn
9
9
  ```bash
10
10
  node --version # >= 22.19.0
11
11
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.82.0
12
- npm install -g --ignore-scripts @piagent/platform@1.1.4
12
+ npm install -g --ignore-scripts @piagent/platform@1.1.6
13
13
  piagent-install --stable
14
14
  cd /path/to/project
15
15
  pi
@@ -42,7 +42,7 @@ Mặc định team dùng stable helper:
42
42
  ```bash
43
43
  node --version # >= 22.19.0
44
44
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.82.0
45
- npm install -g --ignore-scripts @piagent/platform@1.1.4
45
+ npm install -g --ignore-scripts @piagent/platform@1.1.6
46
46
  piagent-install --stable --dry-run
47
47
  piagent-install --stable
48
48
  ```
@@ -63,7 +63,16 @@ pi install git:github.com/Vt-mmm/piagent
63
63
 
64
64
  ## Update và rollback cho team
65
65
 
66
- Full update phải đồng bộ exact Pi host trước, sau đó terminal helper và Pi package matching:
66
+ Một lệnh, đúng thứ tự, cho cả ba thành phần:
67
+
68
+ ```bash
69
+ piagent-update --check
70
+ piagent-update --project /path/to/project
71
+ ```
72
+
73
+ `--check` chỉ báo version, không đụng gì. Lên đúng một bản cụ thể thì `piagent-update --version 1.1.6`.
74
+
75
+ Sequence thủ công vẫn dùng được, và bắt buộc theo thứ tự này — `piagent-install` **fail** nếu Pi host lệch bản pin, nó không tự nâng host:
67
76
 
68
77
  ```bash
69
78
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.82.0
@@ -108,6 +117,8 @@ pi
108
117
 
109
118
  Credential nằm trong user Pi dir, không commit.
110
119
 
120
+ Nếu browser login của OpenAI trông như treo, xem [login provider](operator-manual-vietnamese.md#login-provider). Tóm tắt: paste callback URL xong phải bấm **Enter** (màn hình không nhắc), và nếu sau Enter terminal khoá luôn thì login bằng `pi --offline` một lần. Cả hai là lỗi Pi host `0.82.0`/`0.82.1`, không phải platform.
121
+
111
122
  ## Bước 3 — chọn model và onboard project
112
123
 
113
124
  Sau login, chọn provider/model intended cho project understanding bằng native Pi selector. OpenAI Codex và Claude/Anthropic đều là supported option.
@@ -798,7 +798,7 @@
798
798
  <span>Pi Agent Platform</span>
799
799
  </a>
800
800
  <div class="top-actions">
801
- <span class="pill"><span class="pill-dot"></span>v1.1.4 docs</span>
801
+ <span class="pill"><span class="pill-dot"></span>v1.1.6 docs</span>
802
802
  <a class="button social" href="https://github.com/Vt-mmm/piagent" target="_blank" rel="noopener noreferrer" aria-label="Mở GitHub repository">
803
803
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
804
804
  <path d="M7 17 17 7M9 7h8v8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
@@ -934,7 +934,7 @@
934
934
  <article class="step">
935
935
  <div>
936
936
  <h3>Cài — hai lệnh</h3>
937
- <p>Cần Node.js 22.19.0 trở lên. Không cần cài Pi Coding Agent trước: <span class="inline-code">piagent-setup</span> tự cài đúng bản <span class="inline-code">0.82.0</span> mà release <span class="inline-code">v1.1.4</span> đang pin. Chạy nó ngay trong thư mục project — nó cài Pi package, khởi tạo <span class="inline-code">.pi/</span>, cài MCP baseline cùng subagents, rồi chạy doctor. Thêm <span class="inline-code">--no-mcp</span>, <span class="inline-code">--no-subagents</span>, <span class="inline-code">--no-herdr</span> hoặc <span class="inline-code">--global-only</span> để bỏ bớt.</p>
937
+ <p>Cần Node.js 22.19.0 trở lên. Không cần cài Pi Coding Agent trước: <span class="inline-code">piagent-setup</span> tự cài đúng bản <span class="inline-code">0.82.0</span> mà release <span class="inline-code">v1.1.6</span> đang pin. Chạy nó ngay trong thư mục project — nó cài Pi package, khởi tạo <span class="inline-code">.pi/</span>, cài MCP baseline cùng subagents, rồi chạy doctor. Thêm <span class="inline-code">--no-mcp</span>, <span class="inline-code">--no-subagents</span>, <span class="inline-code">--no-herdr</span> hoặc <span class="inline-code">--global-only</span> để bỏ bớt.</p>
938
938
  <div class="table-wrap compact-table">
939
939
  <table>
940
940
  <thead>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/platform",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -47,6 +47,7 @@
47
47
  "bin": {
48
48
  "piagent-setup": "scripts/piagent-cli.mjs",
49
49
  "piagent-install": "scripts/piagent-cli.mjs",
50
+ "piagent-update": "scripts/piagent-cli.mjs",
50
51
  "piagent-uninstall": "scripts/piagent-cli.mjs",
51
52
  "piagent-init": "scripts/piagent-cli.mjs",
52
53
  "piagent-doctor": "scripts/piagent-cli.mjs",
@@ -795,7 +795,11 @@ function buildCoreIntegrity(root) {
795
795
  "packages/piagent-core/extensions/guard-shell-analysis.ts",
796
796
  "packages/piagent-core/extensions/policy-core.js",
797
797
  "packages/piagent-core/extensions/redaction-core.js",
798
- "packages/piagent-core/extensions/runtime-evidence.js"
798
+ "packages/piagent-core/extensions/runtime-evidence.js",
799
+ // project-shape.js picks the profile a project is offered, and the profile is
800
+ // the document that carries protectedPaths and readOnlyPaths. Steering the
801
+ // recommendation is a way to choose what the guard will end up enforcing.
802
+ "packages/piagent-core/extensions/project-shape.js"
799
803
  ];
800
804
  return files.map((relativePath) => {
801
805
  const resolved = resolveRegularFile(root, relativePath, MAX_ARTIFACT_BYTES);
@@ -39,6 +39,7 @@ import {
39
39
  redactForStorage,
40
40
  redactSensitiveText
41
41
  } from "./redaction-core.js";
42
+ import { detectProfileName } from "./project-shape.js";
42
43
  import {
43
44
  resolveCapabilityProfileDocument,
44
45
  verifyCapabilityLock,
@@ -2636,75 +2637,6 @@ function readAdapterProfiles(extensionDir: string): Array<{ name: string; profil
2636
2637
  .sort((a, b) => a.name.localeCompare(b.name));
2637
2638
  }
2638
2639
 
2639
- function packageHasAny(cwd: string, pattern: RegExp): boolean {
2640
- try {
2641
- return pattern.test(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
2642
- } catch {
2643
- return false;
2644
- }
2645
- }
2646
-
2647
- function detectProfileName(cwd: string, intent?: string): { name: string; reason: string } {
2648
- const normalizedIntent = intent?.trim().toLowerCase();
2649
- if (normalizedIntent === "be-readonly-fe") return { name: "be-readonly-fe", reason: "User intent says backend should be read-only while frontend is the write target." };
2650
- if (normalizedIntent === "frontend-only") return { name: "web-frontend", reason: "User intent says frontend-only." };
2651
- if (normalizedIntent === "backend-only") return { name: "backend-api", reason: "User intent says backend-only." };
2652
- if (normalizedIntent === "docs") return { name: "docs", reason: "User intent says docs/docs-only." };
2653
-
2654
- const hasPackage = fs.existsSync(path.join(cwd, "package.json"));
2655
- let frontend = false;
2656
- let backend = false;
2657
- let data = false;
2658
- let mobile = false;
2659
- let infra = false;
2660
- let docs = false;
2661
-
2662
- if (fs.existsSync(path.join(cwd, "pubspec.yaml")) || (fs.existsSync(path.join(cwd, "android")) && fs.existsSync(path.join(cwd, "ios")))) mobile = true;
2663
- if (fs.existsSync(path.join(cwd, "dbt_project.yml")) || fs.existsSync(path.join(cwd, "dvc.yaml")) || fs.existsSync(path.join(cwd, "notebooks")) || fs.existsSync(path.join(cwd, "data"))) data = true;
2664
- if (fs.existsSync(path.join(cwd, "Dockerfile")) || fs.existsSync(path.join(cwd, "docker-compose.yml")) || fs.existsSync(path.join(cwd, "compose.yml")) || fs.existsSync(path.join(cwd, "compose.yaml")) || fs.existsSync(path.join(cwd, "terraform")) || fs.existsSync(path.join(cwd, "infra")) || fs.existsSync(path.join(cwd, "k8s")) || fs.existsSync(path.join(cwd, "helm"))) infra = true;
2665
- if (fs.existsSync(path.join(cwd, "docs")) || fs.existsSync(path.join(cwd, "mkdocs.yml")) || fs.existsSync(path.join(cwd, "mint.json")) || fs.existsSync(path.join(cwd, "docusaurus.config.js"))) docs = true;
2666
-
2667
- if (hasPackage) {
2668
- if (fs.existsSync(path.join(cwd, "frontend")) || fs.existsSync(path.join(cwd, "apps/web")) || fs.existsSync(path.join(cwd, "apps/frontend"))) frontend = true;
2669
- if (fs.existsSync(path.join(cwd, "backend")) || fs.existsSync(path.join(cwd, "apps/api")) || fs.existsSync(path.join(cwd, "apps/server"))) backend = true;
2670
- if (packageHasAny(cwd, /"(next|react|vite|vue|svelte|astro|@angular\/core|remix)"/i)
2671
- || fs.existsSync(path.join(cwd, "next.config.js"))
2672
- || fs.existsSync(path.join(cwd, "next.config.mjs"))
2673
- || fs.existsSync(path.join(cwd, "next.config.ts"))
2674
- || fs.existsSync(path.join(cwd, "vite.config.js"))
2675
- || fs.existsSync(path.join(cwd, "vite.config.ts"))
2676
- || fs.existsSync(path.join(cwd, "src/app"))
2677
- || fs.existsSync(path.join(cwd, "pages"))
2678
- || fs.existsSync(path.join(cwd, "public"))) frontend = true;
2679
- if (packageHasAny(cwd, /"(@nestjs|express|fastify|hono|koa|apollo-server|graphql-yoga|prisma|typeorm|sequelize|drizzle-orm)"/i)
2680
- || fs.existsSync(path.join(cwd, "nest-cli.json"))
2681
- || fs.existsSync(path.join(cwd, "prisma"))
2682
- || fs.existsSync(path.join(cwd, "src/server"))
2683
- || fs.existsSync(path.join(cwd, "src/api"))) backend = true;
2684
- }
2685
-
2686
- if (fs.existsSync(path.join(cwd, "pom.xml")) || fs.existsSync(path.join(cwd, "build.gradle")) || fs.existsSync(path.join(cwd, "build.gradle.kts")) || fs.existsSync(path.join(cwd, "src/main/java")) || fs.existsSync(path.join(cwd, "src/main/kotlin"))) backend = true;
2687
-
2688
- if (fs.existsSync(path.join(cwd, "pyproject.toml"))) {
2689
- try {
2690
- if (/(fastapi|flask|django|litestar|starlite)/i.test(fs.readFileSync(path.join(cwd, "pyproject.toml"), "utf8"))) backend = true;
2691
- } catch {
2692
- // ignore unreadable pyproject for recommendation
2693
- }
2694
- }
2695
-
2696
- if (mobile) return { name: "mobile", reason: "Mobile markers found." };
2697
- if (frontend && backend) return { name: "fullstack", reason: "Frontend and backend markers both found. Pick be-readonly-fe instead if backend must be read-only." };
2698
- if (frontend) return { name: "web-frontend", reason: "Frontend framework markers found." };
2699
- if (backend) return { name: "backend-api", reason: "Backend/API markers found." };
2700
- if (data) return { name: "data", reason: "Data/ETL markers found." };
2701
- if (fs.existsSync(path.join(cwd, "pyproject.toml"))) return { name: "python", reason: "Python pyproject.toml found." };
2702
- if (hasPackage && fs.existsSync(path.join(cwd, "tsconfig.json"))) return { name: "node-typescript", reason: "Node TypeScript markers found." };
2703
- if (infra) return { name: "devops", reason: "Infrastructure markers found." };
2704
- if (docs) return { name: "docs", reason: "Documentation markers found." };
2705
- return { name: "generic", reason: "No stronger project markers found." };
2706
- }
2707
-
2708
2640
  function profileDescription(name: string): string {
2709
2641
  const descriptions: Record<string, string> = {
2710
2642
  generic: "Safe baseline for unknown repos.",
@@ -0,0 +1,285 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ // The shape of a repository, in the only terms the profiles care about: is there
5
+ // a frontend, is there a backend, and what else is sitting at the root. Both the
6
+ // runtime recommendation (`piagent_profile_options`) and the shell initialiser
7
+ // (`scripts/init-project.sh`) read it from here. They used to carry separate
8
+ // copies of the same rules, which is how `apps/backend` ended up being invisible
9
+ // to one of them for several releases.
10
+
11
+ // Directories that name a side of the stack outright. These are matched at the
12
+ // repository root and one level under each workspace root, never at arbitrary
13
+ // depth: `src/api` inside a frontend package is an HTTP client, not a backend.
14
+ const FRONTEND_DIR_NAMES = ["frontend", "web", "client", "ui"];
15
+ const BACKEND_DIR_NAMES = ["backend", "server", "api"];
16
+ const WORKSPACE_ROOTS = ["apps", "packages", "services"];
17
+
18
+ const FRONTEND_DEPENDENCIES = /"(next|react|vite|vue|svelte|astro|@angular\/core|remix)"/i;
19
+ const BACKEND_DEPENDENCIES = /"(@nestjs|express|fastify|hono|koa|apollo-server|graphql-yoga|prisma|typeorm|sequelize|drizzle-orm)"/i;
20
+ const PYTHON_BACKEND_FRAMEWORKS = /(fastapi|flask|django|litestar|starlite)/i;
21
+
22
+ const FRONTEND_MARKER_FILES = [
23
+ "next.config.js",
24
+ "next.config.mjs",
25
+ "next.config.ts",
26
+ "vite.config.js",
27
+ "vite.config.ts",
28
+ "src/app",
29
+ "pages",
30
+ "public"
31
+ ];
32
+ const NODE_BACKEND_MARKER_FILES = ["nest-cli.json", "prisma", "src/server", "src/api"];
33
+ // Markers that identify a backend on their own, with no manifest to lean on.
34
+ const NATIVE_BACKEND_MARKER_FILES = [
35
+ "pom.xml",
36
+ "build.gradle",
37
+ "build.gradle.kts",
38
+ "src/main/java",
39
+ "src/main/kotlin"
40
+ ];
41
+
42
+ // A workspace file can declare any number of packages. Reading a bounded number
43
+ // of them keeps a repository with hundreds of packages from turning profile
44
+ // recommendation into a directory walk.
45
+ const MAX_WORKSPACE_PACKAGES = 64;
46
+
47
+ function exists(dir, relative) {
48
+ try {
49
+ return fs.existsSync(path.join(dir, relative));
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ // The stack-naming markers are directories. A file called `api` says nothing
56
+ // about a repository having a backend in it.
57
+ function isDirectory(dir, relative) {
58
+ try {
59
+ return fs.statSync(path.join(dir, relative)).isDirectory();
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ function readFile(dir, relative) {
66
+ try {
67
+ return fs.readFileSync(path.join(dir, relative), "utf8");
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ function readJson(dir, relative) {
74
+ const text = readFile(dir, relative);
75
+ if (text === undefined) return undefined;
76
+ try {
77
+ return JSON.parse(text);
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ }
82
+
83
+ function stripQuotes(value) {
84
+ return value.trim().replace(/^['"]/, "").replace(/['"]$/, "").trim();
85
+ }
86
+
87
+ // npm and yarn put the globs in package.json, pnpm puts them in its own file.
88
+ // Both are read: a repository is free to carry either, and some carry both.
89
+ function readWorkspacePatterns(cwd) {
90
+ const patterns = [];
91
+
92
+ const declared = readJson(cwd, "package.json")?.workspaces;
93
+ if (Array.isArray(declared)) {
94
+ patterns.push(...declared);
95
+ } else if (Array.isArray(declared?.packages)) {
96
+ patterns.push(...declared.packages);
97
+ }
98
+
99
+ patterns.push(...readPnpmWorkspacePatterns(cwd));
100
+ return patterns.filter((pattern) => typeof pattern === "string");
101
+ }
102
+
103
+ // A three-line YAML reader rather than a YAML dependency: this package ships
104
+ // with no runtime dependencies, and the shape being read is a single list of
105
+ // strings under one key.
106
+ function readPnpmWorkspacePatterns(cwd) {
107
+ const text = readFile(cwd, "pnpm-workspace.yaml") ?? readFile(cwd, "pnpm-workspace.yml");
108
+ if (text === undefined) return [];
109
+
110
+ const patterns = [];
111
+ let insidePackages = false;
112
+ for (const rawLine of text.split(/\r?\n/)) {
113
+ const line = rawLine.replace(/#.*$/, "");
114
+ if (/^packages\s*:/.test(line)) {
115
+ insidePackages = true;
116
+ continue;
117
+ }
118
+ if (!insidePackages) continue;
119
+
120
+ const item = line.match(/^\s+-\s*(.+?)\s*$/);
121
+ if (item) {
122
+ patterns.push(stripQuotes(item[1]));
123
+ continue;
124
+ }
125
+ // A blank line stays inside the list; anything else starts the next key.
126
+ if (line.trim().length > 0) break;
127
+ }
128
+ return patterns;
129
+ }
130
+
131
+ function expandWorkspacePattern(cwd, pattern) {
132
+ const normalized = pattern.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
133
+ // `!` excludes rather than includes, and neither an absolute path nor one that
134
+ // climbs out of the repository is a workspace of this repository.
135
+ if (!normalized || normalized.startsWith("!") || normalized.startsWith("/")) return [];
136
+
137
+ const segments = normalized.split("/").filter((segment) => segment && segment !== ".");
138
+ if (segments.length === 0 || segments.includes("..")) return [];
139
+
140
+ const wildcardAt = segments.findIndex((segment) => segment.includes("*"));
141
+ if (wildcardAt === -1) return [segments.join("/")];
142
+ // Only a trailing wildcard is expanded. `packages/*` and `apps/**` are what
143
+ // workspace files actually declare; anything deeper is left to the directory
144
+ // and dependency markers instead of growing a glob engine in here.
145
+ if (wildcardAt !== segments.length - 1) return [];
146
+
147
+ const base = segments.slice(0, wildcardAt).join("/");
148
+ let entries;
149
+ try {
150
+ entries = fs.readdirSync(base ? path.join(cwd, base) : cwd, { withFileTypes: true });
151
+ } catch {
152
+ return [];
153
+ }
154
+ return entries
155
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
156
+ .map((entry) => (base ? `${base}/${entry.name}` : entry.name));
157
+ }
158
+
159
+ export function readWorkspacePackageDirs(cwd) {
160
+ const found = [];
161
+ const seen = new Set();
162
+ for (const pattern of readWorkspacePatterns(cwd)) {
163
+ for (const relative of expandWorkspacePattern(cwd, pattern)) {
164
+ if (seen.has(relative)) continue;
165
+ if (!exists(cwd, relative)) continue;
166
+ seen.add(relative);
167
+ found.push(relative);
168
+ if (found.length >= MAX_WORKSPACE_PACKAGES) return found;
169
+ }
170
+ }
171
+ return found;
172
+ }
173
+
174
+ // What one directory says about itself, from its manifest and its own layout.
175
+ // Applied to the repository root and to every declared workspace package, so a
176
+ // pnpm monorepo whose packages are named `web` and `api` is read the same way a
177
+ // single-package repository is.
178
+ function directorySignals(dir) {
179
+ const manifest = readFile(dir, "package.json");
180
+ let frontend = false;
181
+ let backend = false;
182
+
183
+ if (manifest !== undefined) {
184
+ frontend = FRONTEND_DEPENDENCIES.test(manifest);
185
+ backend = BACKEND_DEPENDENCIES.test(manifest);
186
+ // Node layout markers only mean something next to a manifest. `public/` and
187
+ // `pages/` are ordinary directory names in repositories that have nothing to
188
+ // do with a web framework.
189
+ if (!frontend) frontend = FRONTEND_MARKER_FILES.some((marker) => exists(dir, marker));
190
+ if (!backend) backend = NODE_BACKEND_MARKER_FILES.some((marker) => exists(dir, marker));
191
+ }
192
+
193
+ if (!backend) backend = NATIVE_BACKEND_MARKER_FILES.some((marker) => exists(dir, marker));
194
+
195
+ const pyproject = readFile(dir, "pyproject.toml");
196
+ if (pyproject !== undefined && PYTHON_BACKEND_FRAMEWORKS.test(pyproject)) backend = true;
197
+
198
+ return { frontend, backend };
199
+ }
200
+
201
+ function shapeFromDirName(name) {
202
+ const normalized = name.toLowerCase();
203
+ if (FRONTEND_DIR_NAMES.includes(normalized)) return "frontend";
204
+ if (BACKEND_DIR_NAMES.includes(normalized)) return "backend";
205
+ return undefined;
206
+ }
207
+
208
+ export function detectProjectShape(cwd) {
209
+ const hasPackage = exists(cwd, "package.json");
210
+ const shape = {
211
+ hasPackage,
212
+ frontend: false,
213
+ backend: false,
214
+ data: false,
215
+ mobile: false,
216
+ infra: false,
217
+ docs: false,
218
+ workspacePackages: []
219
+ };
220
+
221
+ if (exists(cwd, "pubspec.yaml") || (exists(cwd, "android") && exists(cwd, "ios"))) shape.mobile = true;
222
+ if (exists(cwd, "dbt_project.yml") || exists(cwd, "dvc.yaml") || exists(cwd, "notebooks") || exists(cwd, "data")) {
223
+ shape.data = true;
224
+ }
225
+ if (exists(cwd, "Dockerfile") || exists(cwd, "docker-compose.yml") || exists(cwd, "compose.yml")
226
+ || exists(cwd, "compose.yaml") || exists(cwd, "terraform") || exists(cwd, "infra")
227
+ || exists(cwd, "k8s") || exists(cwd, "helm")) {
228
+ shape.infra = true;
229
+ }
230
+ if (exists(cwd, "docs") || exists(cwd, "mkdocs.yml") || exists(cwd, "mint.json") || exists(cwd, "docusaurus.config.js")) {
231
+ shape.docs = true;
232
+ }
233
+
234
+ // Directory names are read whether or not the root carries a package.json.
235
+ // A Django and React repository has `frontend/` and `backend/` and no root
236
+ // manifest at all; gating this on package.json used to hide both sides of it.
237
+ for (const name of [...FRONTEND_DIR_NAMES, ...BACKEND_DIR_NAMES]) {
238
+ const side = shapeFromDirName(name);
239
+ if (isDirectory(cwd, name)) shape[side] = true;
240
+ for (const root of WORKSPACE_ROOTS) {
241
+ if (isDirectory(cwd, `${root}/${name}`)) shape[side] = true;
242
+ }
243
+ }
244
+
245
+ const rootSignals = directorySignals(cwd);
246
+ if (rootSignals.frontend) shape.frontend = true;
247
+ if (rootSignals.backend) shape.backend = true;
248
+
249
+ shape.workspacePackages = readWorkspacePackageDirs(cwd);
250
+ for (const relative of shape.workspacePackages) {
251
+ const side = shapeFromDirName(path.posix.basename(relative));
252
+ if (side) shape[side] = true;
253
+
254
+ const signals = directorySignals(path.join(cwd, relative));
255
+ if (signals.frontend) shape.frontend = true;
256
+ if (signals.backend) shape.backend = true;
257
+ }
258
+
259
+ return shape;
260
+ }
261
+
262
+ export function detectProfileName(cwd, intent) {
263
+ const normalizedIntent = typeof intent === "string" ? intent.trim().toLowerCase() : "";
264
+ if (normalizedIntent === "be-readonly-fe") {
265
+ return { name: "be-readonly-fe", reason: "User intent says backend should be read-only while frontend is the write target." };
266
+ }
267
+ if (normalizedIntent === "frontend-only") return { name: "web-frontend", reason: "User intent says frontend-only." };
268
+ if (normalizedIntent === "backend-only") return { name: "backend-api", reason: "User intent says backend-only." };
269
+ if (normalizedIntent === "docs") return { name: "docs", reason: "User intent says docs/docs-only." };
270
+
271
+ const shape = detectProjectShape(cwd);
272
+
273
+ if (shape.mobile) return { name: "mobile", reason: "Mobile markers found." };
274
+ if (shape.frontend && shape.backend) {
275
+ return { name: "fullstack", reason: "Frontend and backend markers both found. Pick be-readonly-fe instead if backend must be read-only." };
276
+ }
277
+ if (shape.frontend) return { name: "web-frontend", reason: "Frontend framework markers found." };
278
+ if (shape.backend) return { name: "backend-api", reason: "Backend/API markers found." };
279
+ if (shape.data) return { name: "data", reason: "Data/ETL markers found." };
280
+ if (exists(cwd, "pyproject.toml")) return { name: "python", reason: "Python pyproject.toml found." };
281
+ if (shape.hasPackage && exists(cwd, "tsconfig.json")) return { name: "node-typescript", reason: "Node TypeScript markers found." };
282
+ if (shape.infra) return { name: "devops", reason: "Infrastructure markers found." };
283
+ if (shape.docs) return { name: "docs", reason: "Documentation markers found." };
284
+ return { name: "generic", reason: "No stronger project markers found." };
285
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -102,97 +102,20 @@ fi
102
102
 
103
103
  PROJECT_PATH="$(cd "$PROJECT_PATH" && pwd)"
104
104
 
105
- package_has_any() {
106
- local project="$1"
107
- local pattern="$2"
108
- [[ -f "$project/package.json" ]] && grep -Eiq "$pattern" "$project/package.json"
109
- }
110
-
105
+ # Profile detection lives in packages/piagent-core/extensions/project-shape.js so
106
+ # that this script and the runtime `piagent_profile_options` tool recommend the
107
+ # same profile. They used to hold separate copies of the rules and drifted apart.
111
108
  detect_profile() {
112
109
  local project="$1"
113
- local has_package=false
114
- local frontend=false
115
- local backend=false
116
- local data=false
117
- local mobile=false
118
- local devops=false
119
- local infra=false
120
- local docs=false
121
-
122
- [[ -f "$project/package.json" ]] && has_package=true
123
-
124
- if [[ -f "$project/pubspec.yaml" ]] || { [[ -d "$project/android" ]] && [[ -d "$project/ios" ]]; }; then
125
- mobile=true
126
- fi
127
-
128
- if [[ -f "$project/dbt_project.yml" ]] || [[ -f "$project/dvc.yaml" ]] || [[ -d "$project/notebooks" ]] || [[ -d "$project/data" ]]; then
129
- data=true
130
- fi
131
-
132
- if [[ -f "$project/Dockerfile" ]] || [[ -f "$project/docker-compose.yml" ]] || [[ -f "$project/compose.yml" ]] || [[ -f "$project/compose.yaml" ]] || [[ -d "$project/terraform" ]] || [[ -d "$project/infra" ]] || [[ -d "$project/k8s" ]] || [[ -d "$project/helm" ]]; then
133
- infra=true
134
- devops=true
135
- elif [[ -d "$project/.github/workflows" ]]; then
136
- devops=true
137
- fi
138
-
139
- if [[ -d "$project/docs" ]] || [[ -f "$project/mkdocs.yml" ]] || [[ -f "$project/mint.json" ]] || [[ -f "$project/docusaurus.config.js" ]]; then
140
- docs=true
141
- fi
142
-
143
- if [[ "$has_package" == true ]]; then
144
- if [[ -d "$project/frontend" ]] || [[ -d "$project/apps/web" ]] || [[ -d "$project/apps/frontend" ]]; then
145
- frontend=true
146
- fi
147
-
148
- if [[ -d "$project/backend" ]] || [[ -d "$project/apps/api" ]] || [[ -d "$project/apps/server" ]]; then
149
- backend=true
150
- fi
151
-
152
- if package_has_any "$project" '"(next|react|vite|vue|svelte|astro|@angular/core|remix)"' \
153
- || [[ -f "$project/next.config.js" ]] || [[ -f "$project/next.config.mjs" ]] || [[ -f "$project/next.config.ts" ]] \
154
- || [[ -f "$project/vite.config.js" ]] || [[ -f "$project/vite.config.ts" ]] \
155
- || [[ -d "$project/src/app" ]] || [[ -d "$project/pages" ]] || [[ -d "$project/public" ]]; then
156
- frontend=true
157
- fi
158
-
159
- if package_has_any "$project" '"(@nestjs|express|fastify|hono|koa|apollo-server|graphql-yoga|prisma|typeorm|sequelize|drizzle-orm)"' \
160
- || [[ -f "$project/nest-cli.json" ]] || [[ -d "$project/prisma" ]] || [[ -d "$project/src/server" ]] || [[ -d "$project/src/api" ]]; then
161
- backend=true
162
- fi
163
- fi
164
-
165
- if [[ -f "$project/pom.xml" ]] || [[ -f "$project/build.gradle" ]] || [[ -f "$project/build.gradle.kts" ]] || [[ -d "$project/src/main/java" ]] || [[ -d "$project/src/main/kotlin" ]]; then
166
- backend=true
167
- fi
168
-
169
- if [[ -f "$project/pyproject.toml" ]] && grep -Eiq "(fastapi|flask|django|litestar|starlite)" "$project/pyproject.toml"; then
170
- backend=true
171
- fi
172
-
173
- if [[ "$mobile" == true ]]; then
174
- printf '%s\n' "mobile"
175
- elif [[ "$frontend" == true && "$backend" == true ]]; then
176
- printf '%s\n' "fullstack"
177
- elif [[ "$frontend" == true ]]; then
178
- printf '%s\n' "web-frontend"
179
- elif [[ "$backend" == true ]]; then
180
- printf '%s\n' "backend-api"
181
- elif [[ "$data" == true ]]; then
182
- printf '%s\n' "data"
183
- elif [[ -f "$project/pyproject.toml" ]]; then
184
- printf '%s\n' "python"
185
- elif [[ "$has_package" == true && -f "$project/tsconfig.json" ]]; then
186
- printf '%s\n' "node-typescript"
187
- elif [[ "$infra" == true ]]; then
188
- printf '%s\n' "devops"
189
- elif [[ "$docs" == true ]]; then
190
- printf '%s\n' "docs"
191
- elif [[ "$devops" == true ]]; then
192
- printf '%s\n' "devops"
193
- else
194
- printf '%s\n' "generic"
195
- fi
110
+ node --input-type=module - "$PLATFORM_ROOT" "$project" <<'NODE'
111
+ import path from "node:path";
112
+ import { pathToFileURL } from "node:url";
113
+
114
+ const [platformRoot, project] = process.argv.slice(2);
115
+ const modulePath = path.join(platformRoot, "packages/piagent-core/extensions/project-shape.js");
116
+ const { detectProfileName } = await import(pathToFileURL(modulePath).href);
117
+ process.stdout.write(`${detectProfileName(project).name}\n`);
118
+ NODE
196
119
  }
197
120
 
198
121
  resolve_profile_path() {
@@ -15,7 +15,7 @@ Package source examples:
15
15
  # Installs the stable tag after resolving it to a commit SHA.
16
16
 
17
17
  # Pin a specific release:
18
- scripts/install-global.sh --version v1.1.4 --resolve-tag
18
+ scripts/install-global.sh --version v1.1.6 --resolve-tag
19
19
 
20
20
  # Preview the planned install/update without changing user config:
21
21
  scripts/install-global.sh --stable --dry-run
@@ -127,7 +127,7 @@ detect_runtime_surface() {
127
127
  printf 'native-windows/%s (not release-gated; terminal helpers require Bash semantics)' "$arch_name"
128
128
  ;;
129
129
  *)
130
- printf '%s/%s (outside v1.1.4 release matrix)' "$os_name" "$arch_name"
130
+ printf '%s/%s (outside v1.1.6 release matrix)' "$os_name" "$arch_name"
131
131
  ;;
132
132
  esac
133
133
  }
@@ -135,7 +135,7 @@ detect_runtime_surface() {
135
135
  warn_runtime_surface_if_needed() {
136
136
  local surface="$1"
137
137
  case "$surface" in
138
- *"supported target"*|*"experimental"*|*"not release-gated"*|*"outside v1.1.4 release matrix"*)
138
+ *"supported target"*|*"experimental"*|*"not release-gated"*|*"outside v1.1.6 release matrix"*)
139
139
  echo "WARN: runtime surface is $surface." >&2
140
140
  echo "WARN: For team rollout, run piagent-doctor plus the project smoke/verify suite on this machine before relying on it." >&2
141
141
  ;;
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  const scriptByCommand = {
7
7
  "piagent-setup": "scripts/setup.sh",
8
8
  "piagent-install": "scripts/install-global.sh",
9
+ "piagent-update": "scripts/update-global.mjs",
9
10
  "piagent-uninstall": "scripts/uninstall-global.sh",
10
11
  "piagent-init": "scripts/init-project.sh",
11
12
  "piagent-doctor": "scripts/team-doctor.sh",
@@ -137,7 +137,7 @@ function runtimeMatrixStatus() {
137
137
  surface: `${platform}/${arch}`,
138
138
  status: "outside-release-matrix",
139
139
  teamRolloutReady: false,
140
- note: "This runtime is outside the documented v1.1.4 support matrix."
140
+ note: "This runtime is outside the documented v1.1.6 support matrix."
141
141
  };
142
142
  }
143
143
 
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ // One command that moves a machine from one release to the next.
3
+ //
4
+ // A release is three independently versioned things: the Pi host, this npm-global
5
+ // terminal helper, and the Pi package the helper installs. They have to move in
6
+ // that order, because `piagent-install` refuses to run against a host that does
7
+ // not match the version its own package.json pins. Doing it by hand means three
8
+ // commands and one ordering rule, and getting the order wrong fails late.
9
+ //
10
+ // The awkward part is that the helper is the program running this file, so it has
11
+ // to replace itself. Rather than re-exec a half-replaced process, every version
12
+ // this run needs is resolved from the registry *before* anything is installed:
13
+ // the target helper version and, from that version's metadata, the host version it
14
+ // will demand. The plan is therefore complete and printable before the first
15
+ // mutation, `--dry-run` shows exactly what a real run would do, and after the
16
+ // helper is replaced the run only has to confirm that what landed on disk is what
17
+ // it asked for.
18
+
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { spawnSync } from "node:child_process";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const HELPER_PACKAGE = "@piagent/platform";
25
+ const HOST_PACKAGE = "@earendil-works/pi-coding-agent";
26
+ const EXIT_USAGE = 2;
27
+
28
+ const platformRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
29
+
30
+ function usage() {
31
+ return `Usage:
32
+ piagent-update [options]
33
+
34
+ Updates the Pi host, this terminal helper, and the Pi package together, in the
35
+ order a release requires.
36
+
37
+ Options:
38
+ --check Report current and available versions, change nothing
39
+ --dry-run Print every command that would run, change nothing
40
+ --version <x.y.z> Update to an exact helper version instead of latest
41
+ --force Reinstall even when already on the target version
42
+ --project <path> Run the team doctor against this project afterwards
43
+ --no-host Do not touch the Pi host
44
+ --no-package Do not reinstall the Pi package
45
+ -h, --help Show this help
46
+
47
+ Anything after -- is passed through to piagent-install, for example:
48
+ piagent-update -- --no-mcp
49
+ `;
50
+ }
51
+
52
+ function fail(message, code = 1) {
53
+ console.error(`FAIL: ${message}`);
54
+ process.exit(code);
55
+ }
56
+
57
+ function readJson(file) {
58
+ try {
59
+ return JSON.parse(fs.readFileSync(file, "utf8"));
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ function isReleaseVersion(value) {
66
+ return typeof value === "string" && /^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$/.test(value);
67
+ }
68
+
69
+ // "v1.2.3" and "1.2.3" both name the same release everywhere else in this
70
+ // repository, so accept either and normalise once.
71
+ function normalizeVersion(value) {
72
+ return typeof value === "string" && value.startsWith("v") ? value.slice(1) : value;
73
+ }
74
+
75
+ function parseArguments(argv) {
76
+ const options = {
77
+ check: false,
78
+ dryRun: false,
79
+ force: false,
80
+ version: undefined,
81
+ project: undefined,
82
+ host: true,
83
+ package: true,
84
+ passthrough: []
85
+ };
86
+
87
+ for (let index = 0; index < argv.length; index += 1) {
88
+ const argument = argv[index];
89
+ if (argument === "--") {
90
+ options.passthrough = argv.slice(index + 1);
91
+ break;
92
+ }
93
+ switch (argument) {
94
+ case "-h":
95
+ case "--help":
96
+ process.stdout.write(usage());
97
+ process.exit(0);
98
+ break;
99
+ case "--check":
100
+ options.check = true;
101
+ break;
102
+ case "--dry-run":
103
+ options.dryRun = true;
104
+ break;
105
+ case "--force":
106
+ options.force = true;
107
+ break;
108
+ case "--no-host":
109
+ options.host = false;
110
+ break;
111
+ case "--no-package":
112
+ options.package = false;
113
+ break;
114
+ case "--version": {
115
+ const value = normalizeVersion(argv[index += 1]);
116
+ if (!isReleaseVersion(value)) fail(`--version needs an exact release such as 1.1.4, got ${argv[index] ?? "nothing"}`, EXIT_USAGE);
117
+ options.version = value;
118
+ break;
119
+ }
120
+ case "--project": {
121
+ const value = argv[index += 1];
122
+ if (!value) fail("--project needs a path", EXIT_USAGE);
123
+ options.project = value;
124
+ break;
125
+ }
126
+ default:
127
+ fail(`unknown option: ${argument}\n\n${usage()}`, EXIT_USAGE);
128
+ }
129
+ }
130
+
131
+ return options;
132
+ }
133
+
134
+ function run(command, args, { capture = false } = {}) {
135
+ const result = spawnSync(command, args, {
136
+ encoding: "utf8",
137
+ stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit"
138
+ });
139
+ if (result.error) return { ok: false, stdout: "", stderr: String(result.error.message ?? result.error) };
140
+ return { ok: result.status === 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
141
+ }
142
+
143
+ function npmView(specifier, field) {
144
+ const result = run("npm", ["view", specifier, field, "--json"], { capture: true });
145
+ if (!result.ok) return undefined;
146
+ try {
147
+ return JSON.parse(result.stdout.trim());
148
+ } catch {
149
+ return undefined;
150
+ }
151
+ }
152
+
153
+ function installedHostVersion() {
154
+ const result = run("pi", ["--version"], { capture: true });
155
+ if (!result.ok) return undefined;
156
+ const match = /([0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?)/.exec(result.stdout);
157
+ return match ? match[1] : undefined;
158
+ }
159
+
160
+ // The helper installs itself from the registry, so a run started from a git
161
+ // checkout would replace the checkout's own commands with a published build and
162
+ // leave the operator somewhere they did not ask to be.
163
+ function runningFromGlobalInstall() {
164
+ return platformRoot.split(path.sep).includes("node_modules");
165
+ }
166
+
167
+ function describeStep(dryRun, command, args) {
168
+ console.log(`${dryRun ? "DRY RUN:" : "run:"} ${command} ${args.join(" ")}`);
169
+ }
170
+
171
+ function main() {
172
+ const options = parseArguments(process.argv.slice(2));
173
+
174
+ const manifest = readJson(path.join(platformRoot, "package.json"));
175
+ if (!manifest?.version) fail(`cannot read the helper package at ${platformRoot}`);
176
+ const currentHelper = manifest.version;
177
+ const currentHost = installedHostVersion();
178
+
179
+ const targetHelper = options.version ?? npmView(HELPER_PACKAGE, "version");
180
+ if (!isReleaseVersion(targetHelper)) {
181
+ fail(`cannot resolve a release for ${HELPER_PACKAGE}. Check network access to the npm registry, or pass --version.`);
182
+ }
183
+
184
+ // The host requirement belongs to the version being installed, not the one
185
+ // running, so it is read from the target's published metadata.
186
+ const targetPeers = npmView(`${HELPER_PACKAGE}@${targetHelper}`, "peerDependencies");
187
+ const targetHost = targetPeers?.[HOST_PACKAGE];
188
+ if (!isReleaseVersion(targetHost)) {
189
+ fail(`${HELPER_PACKAGE}@${targetHelper} does not pin an exact ${HOST_PACKAGE} version, so the host cannot be updated safely.`);
190
+ }
191
+
192
+ const helperNeedsChange = options.force || currentHelper !== targetHelper;
193
+ const hostNeedsChange = options.host && (options.force || currentHost !== targetHost);
194
+
195
+ console.log("Pi Agent Platform update");
196
+ console.log(` helper: ${currentHelper} -> ${targetHelper}${helperNeedsChange ? "" : " (already current)"}`);
197
+ console.log(` host: ${currentHost ?? "not installed"} -> ${targetHost}${hostNeedsChange ? "" : " (already current)"}`);
198
+ console.log(` package: reinstalled from v${targetHelper} as a resolved commit${options.package ? "" : " (skipped)"}`);
199
+
200
+ if (options.check) {
201
+ const pending = [helperNeedsChange && "helper", hostNeedsChange && "host"].filter(Boolean);
202
+ console.log(pending.length ? `\nUpdate available: ${pending.join(", ")}.` : "\nEverything is on the target release.");
203
+ return;
204
+ }
205
+
206
+ if (!helperNeedsChange && !hostNeedsChange && !options.package) {
207
+ console.log("\nNothing to do.");
208
+ return;
209
+ }
210
+
211
+ if (!options.dryRun && !runningFromGlobalInstall()) {
212
+ fail(`this command updates the npm-global helper, but it is running from ${platformRoot}.\n`
213
+ + "From a checkout, update with git and install from it:\n"
214
+ + ` git -C ${platformRoot} pull\n`
215
+ + ` bash ${path.join(platformRoot, "scripts/install-global.sh")} --local`);
216
+ }
217
+
218
+ // Host first: piagent-install refuses to run against a host that does not match
219
+ // the version its package.json pins, so a helper installed ahead of the host
220
+ // would fail on its very next step.
221
+ if (hostNeedsChange) {
222
+ const args = ["install", "-g", "--ignore-scripts", `${HOST_PACKAGE}@${targetHost}`];
223
+ describeStep(options.dryRun, "npm", args);
224
+ if (!options.dryRun && !run("npm", args).ok) fail(`could not install ${HOST_PACKAGE}@${targetHost}`);
225
+ }
226
+
227
+ if (helperNeedsChange) {
228
+ const args = ["install", "-g", "--ignore-scripts", `${HELPER_PACKAGE}@${targetHelper}`];
229
+ describeStep(options.dryRun, "npm", args);
230
+ if (!options.dryRun) {
231
+ if (!run("npm", args).ok) fail(`could not install ${HELPER_PACKAGE}@${targetHelper}`);
232
+ // The helper has just replaced itself on disk. Everything after this point
233
+ // runs the newly installed scripts, so confirm they are the ones asked for
234
+ // rather than trusting that npm did what it said.
235
+ const landed = readJson(path.join(platformRoot, "package.json"))?.version;
236
+ if (landed !== targetHelper) {
237
+ fail(`installed ${HELPER_PACKAGE}@${targetHelper} but ${platformRoot} reports ${landed ?? "nothing"}. `
238
+ + "The Pi package was left untouched; rerun once the helper is correct.");
239
+ }
240
+ }
241
+ }
242
+
243
+ if (options.package) {
244
+ const installer = path.join(platformRoot, "scripts/install-global.sh");
245
+ if (!options.dryRun && !fs.existsSync(installer)) {
246
+ fail(`the updated helper has no installer at ${installer}`);
247
+ }
248
+ const args = [installer, "--stable", ...options.passthrough];
249
+ describeStep(options.dryRun, "bash", args);
250
+ if (!options.dryRun && !run("bash", args).ok) fail("the Pi package install did not complete");
251
+ }
252
+
253
+ if (options.project) {
254
+ const doctor = path.join(platformRoot, "scripts/team-doctor.sh");
255
+ const args = [doctor, options.project, "--strict-share"];
256
+ describeStep(options.dryRun, "bash", args);
257
+ if (!options.dryRun && !run("bash", args).ok) fail("the team doctor reported a problem");
258
+ }
259
+
260
+ console.log(options.dryRun
261
+ ? `\nDRY RUN: nothing was installed. Rerun without --dry-run to apply v${targetHelper}.`
262
+ : `\nPASS: updated to v${targetHelper}.`);
263
+ }
264
+
265
+ main();