Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
# Changelog

## Unreleased

## 6.2.4 (2026-07-24)

### New Features

* `pos-cli dns` — new command group for migrating an instance's custom domains and DNS records between Partner Portal deployments (e.g. from `partners.platformos.com` to a private-stack portal). `dns export` snapshots all domains/records to a versioned JSON backup (bulk via `--instances-file`, sharing one backup directory per run, one file per instance); `dns migrate <sourceEnv> <targetEnv>` copies them portal-to-portal (dry-run support, always writes a backup first, source portal is read-only) and prints per-domain cutover instructions (registrar nameservers for `domain-full`, SSL validation records + CNAME/A targets for `domain-external`); `dns import --file` applies a saved export; `dns status` shows each domain's provisioning status and pending cutover steps; `dns compare` verifies parity between the two sides and exits non-zero on real differences. Bulk cohort support via `--instances-file`/`--mapping-file`, with a per-instance summary table (applied/blocked/error counts).
* Safety: the plan is displayed and must be confirmed interactively before anything is applied — the prompt names the actual instance being written to — with `--yes` to skip in scripts/CI; `partners.platformos.com` is protected as read-only and can only be a migration source, so an accidental `migrate <target> <source>` argument swap fails immediately (`--unsafe-allow-protected-target` to override); `--json` runs refuse a blind interactive confirmation (pass `--yes` or `--dry-run` instead); exit codes are unified and documented across single and bulk modes (0/1/2/3).
* `dns compare` filters out expected cross-stack noise (data centers, nameservers, MX case, TXT chunking, and records whose name one DNS provider stores fully-qualified and another stores short) while still catching real differences (status, setup type, record intent, and any field an export had to strip for exceeding the size limit); `--drop-value` excludes records a migration intentionally dropped; `--domain` scopes both single-pair and bulk (`--mapping-file`) runs; `--raw` compares byte-for-byte instead.
* Network failures during any `dns` command get the same friendly, host-naming error messages the rest of pos-cli uses.

### Fixes

* `pos-cli-mcp` no longer crashes the entire server when a single tool hits a fatal error. The MCP server runs all tools in one process, so a fatal `logger.Error` — which calls `process.exit(1)` — used to tear down the whole server and every tool it exposed, forcing a reconnect. Under the MCP server, fatal errors now throw instead of exiting, so the per-request handler returns a clean error and the server stays up. Standalone CLI behavior is unchanged (it still exits on fatal errors). The most common trigger — a tool called with an unresolvable environment — now returns a caught error instead of killing the server.


## 6.2.3 (2026-07-23)

### Fixes

Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,62 @@ Example:

This command manually runs a specific migration script that updates an admin password in the staging environment.

### DNS (migrating domains between Partner Portals)

The `dns` commands move an instance's custom domains and DNS records between two Partner Portal deployments (for example from `partners.platformos.com` to a private-stack portal). They talk to both portals' `/api/domains` API using the tokens stored in your `.pos` environments (each environment remembers its own `partner_portal_url`), so both the source and the target environment must be registered with `pos-cli env add` — or you can pass `--source-*`/`--target-*` flags (`-portal-url`, `-token`, `-email`, `-instance-uuid`) instead.

Only your own records are migrated: platform-managed DNS (load-balancer targets, SSL validation entries, the www-redirect record) is re-created by the target portal automatically.

#### Export

Save all domains and DNS records of an instance to a versioned JSON file — a backup/audit artifact that `import` and `compare` also accept as input:

pos-cli dns export [environment]
pos-cli dns export [environment] -o backup.json

#### Migrate

Export from the source portal, transform, apply to the target portal, and print per-domain cutover instructions (which nameservers or verification records to set where). The source portal is only ever read, and a backup export file is always written before anything is applied:

pos-cli dns migrate [sourceEnv] [targetEnv] --dry-run # review the plan first
pos-cli dns migrate [sourceEnv] [targetEnv]

After the plan is displayed you are asked to confirm before anything is applied — pass `--yes` to skip the prompt (required in scripts/CI). `partners.platformos.com` is protected as read-only: it can only ever be a migration *source*, so an accidental argument swap (`migrate target source`) fails immediately instead of writing to the legacy production portal.

Domains end up in `ownership_verification_pending` until you complete the printed cutover steps (repoint nameservers at your registrar for `domain-full`, or add the verification records and repoint your CNAME/A for `domain-external`). That state is expected before cutover, not an error.

Useful options: `--domain <name>` (repeatable) migrates selected domains only; `--confirm-destructive` is required when an update would delete many records already managed on the target.

#### Status

Show each provisioned domain's status and any pending cutover steps (in case they were missed after a migrate):

pos-cli dns status [environment]
pos-cli dns status [environment] --domain example.com

#### Import

Apply a previously exported file to a portal (same transform and safety rules as `migrate`):

pos-cli dns import [environment] --file backup.json --dry-run
pos-cli dns import [environment] --file backup.json

#### Compare

Verify DNS parity between the two sides — exits non-zero when a domain differs in a way that matters (status, setup type, record intent). Expected cross-stack differences (data centers, nameservers, MX case, TXT chunking, and records whose name one DNS provider stores fully-qualified and another stores short, e.g. SRV records moving from Route53 to Cloudflare) are filtered out; `--raw` compares byte-for-byte instead:

pos-cli dns compare [sourceEnv] [targetEnv]
pos-cli dns compare [sourceEnv] [targetEnv] --ignore-status # before cutover
pos-cli dns compare --source-file before.json --target-file after.json # offline

If the migration dropped records with `--drop-value`, pass the same patterns to `compare` so those records are excluded from the parity check instead of reporting as missing forever.

The intended sequence is: `migrate` → complete the cutover steps → refresh validation from the target portal → `compare` comes back clean.

#### Exit codes

`import` and `migrate`: `0` success, `1` apply errors, `2` transform errors, `3` only destructive-blocked domains remain. `compare`: `0` parity, `1` any CRITICAL or missing domain. Dry-runs exit `0` (or `2` when the plan contains transform errors).

### Data

#### Export
Expand Down
15 changes: 15 additions & 0 deletions backlog/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
project_name: "pos-cli"
default_status: "To Do"
statuses: ["To Do", "In Progress", "Done"]
labels: []
date_format: yyyy-mm-dd
max_column_width: 20
auto_open_browser: true
default_port: 6420
remote_operations: true
auto_commit: false
filesystem_only: false
bypass_git_hooks: false
check_active_branches: true
active_branch_days: 30
task_prefix: "task"
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
id: TASK-1
title: >-
pos-cli dns: portal-to-portal DNS migration command group (AWS/PPDNS -> OCI
private stacks)
status: Done
assignee: []
created_date: '2026-07-23 09:12'
updated_date: '2026-07-23 14:48'
labels:
- dns
dependencies: []
priority: high
ordinal: 1000
---

## Description

<!-- SECTION:DESCRIPTION:BEGIN -->
Umbrella task. Customers migrating instances from the legacy AWS shared stack (partners.platformos.com, frozen/read-only) to OCI private-stack portals need DNS records migrated. Build pos-cli dns export/import/migrate/compare doing API-to-API migration via both portals' /api/domains (contracts verified compatible; .pos Doorkeeper tokens authenticate). extra_dns_records contains ONLY customer records - no platform-record filtering needed; transform = validation/normalization + www-redirect handling. After import, print per-domain cutover instructions (domain-full: NS repoint; domain-external: DCV records + CNAME/A retarget). Full design: partner-portal repo plan /home/godot/.claude/plans/ppdns-is-still-running-wondrous-hellman.md
<!-- SECTION:DESCRIPTION:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 R1 pre-check done: authenticated GET /api/domains?version=2 against deployed partners.platformos.com confirms v2 attrs (or v1 fallback implemented)
- [x] #2 All child tasks completed; npm run test:unit green
- [x] #3 Manual smoke: export from partners.platformos.com, dry-run + live migrate onto ps-pos01 test instance, compare --ignore-status clean
<!-- AC:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->
Final validation 2026-07-23 after TASK-50/51 (+81058 follow-up, commit 0e68866b) deployed to ps-pos01: migrate aws->ps succeeded end-to-end against the CF-restored zone (APPLIED, last_operation_status 'apply completed', reparking_domain = expected pre-NS-cutover, cutover instructions printed curt/mina.ns.cloudflare.com); zone has 18/18 unique records (adoption, no duplicates); compare aws ps --ignore-status: Critical 0, only advisory = status ready vs reparking_domain (expected until registrar NS cutover). 83 unit tests green. Live-details compare now normalizes CF read-side noise (TXT quoting, hostname case).
<!-- SECTION:NOTES:END -->
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
id: TASK-1.1
title: 'DnsPortalClient: per-portal API client with typed errors'
status: Done
assignee: []
created_date: '2026-07-23 09:12'
updated_date: '2026-07-23 18:27'
labels:
- dns
dependencies: []
parent_task_id: TASK-1
priority: high
ordinal: 2000
---

## Description

<!-- SECTION:DESCRIPTION:BEGIN -->
lib/dns/portalClient.js wrapping lib/apiRequest.js with explicit {baseUrl, token, readOnly}. Methods: static authenticate (POST /api/authenticate), listInstances, searchInstances({domain}), listDomains(uuid,{version:2}), getDomain, upsertDomain, refreshDomain. Typed errors: PortalAuthError (401), PortalAccessError (instance WRITE missing - message names portal+uuid+fix), DestructiveChangeError (422 body prefix 'Destructive DNS change blocked'). readOnly:true makes POST methods throw (old-stack safety invariant). Do NOT touch lib/portal.js.
<!-- SECTION:DESCRIPTION:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 nock tests: Bearer header, version=2 query, 401->PortalAuthError, 422 destructive->DestructiveChangeError, readOnly POST throws
<!-- AC:END -->
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
id: TASK-1.10
title: >-
dns migrate: bulk --backup <dir> overwrites all cohort backups when the
directory does not exist
status: Done
assignee: []
created_date: '2026-07-23 19:05'
updated_date: '2026-07-24 09:55'
labels:
- code-review
- dns
dependencies: []
references:
- bin/pos-cli-dns-migrate.js
- bin/pos-cli-dns-export.js
parent_task_id: TASK-1
priority: high
ordinal: 12000
---

## Description

<!-- SECTION:DESCRIPTION:BEGIN -->
Found in code review of the dns-migration branch.

In bulk cohort mode (--mapping-file/--instances-file), `backupPathFor` (bin/pos-cli-dns-migrate.js:23-28) only treats the --backup value as a directory if it already exists on disk. When the operator passes `--backup exports/` and that directory does not exist yet, every pair resolves to the same literal file path and each instance's source export overwrites the previous one — a cohort migration finishes with one backup instead of N, silently. The backup is the rollback artifact of the migration tool, so silent loss matters.

`dns export` already handles this correctly: in bulk mode it unconditionally treats -o as a directory and creates it (bin/pos-cli-dns-export.js:51-52). Migrate's bulk mode should behave the same way.
<!-- SECTION:DESCRIPTION:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 In bulk mode, --backup <path> produces one backup file per source instance whether or not <path> existed beforehand
- [x] #2 Single-pair behavior (default filename, explicit file path, existing directory) is unchanged
- [x] #3 A test covers bulk backup with a not-yet-existing directory
<!-- AC:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->
Fix validated, but bulk mode itself was PARKED 2026-07-24 on the dns-migration-bulk branch (not in the current release). The fix ships with bulk whenever that branch lands; single-mode backupPathFor behavior is unaffected.
<!-- SECTION:NOTES:END -->
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
id: TASK-1.11
title: >-
dns compare: support --drop-value so migrations that dropped records can
verify clean
status: Done
assignee: []
created_date: '2026-07-23 19:05'
updated_date: '2026-07-23 19:20'
labels:
- code-review
- dns
dependencies: []
references:
- bin/pos-cli-dns-compare.js
- lib/dns/compare.js
- lib/dns/transform.js
parent_task_id: TASK-1
priority: medium
ordinal: 13000
---

## Description

<!-- SECTION:DESCRIPTION:BEGIN -->
Found in code review of the dns-migration branch.

`dns migrate` and `dns import` accept a repeatable `--drop-value <regex>` option to intentionally drop records during the transform, but `dns compare` runs the transform without drop patterns (lib/dns/compare.js:57 calls transformDomain with no dropValuePatterns; bin/pos-cli-dns-compare.js has no such option). Any record dropped at migration time is therefore reported as CRITICAL "missing on target" forever, and compare exits 1.

The documented workflow (README dns section: "migrate → complete the cutover steps → compare comes back clean") can never succeed for domains migrated with --drop-value, which also breaks CI usage of compare's exit code.
<!-- SECTION:DESCRIPTION:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 dns compare accepts the same repeatable --drop-value option as migrate/import and excludes matching source records from the intent comparison
- [x] #2 A migration performed with --drop-value X followed by compare with --drop-value X reports OK for the affected domains
- [x] #3 README compare section documents the option
- [x] #4 Unit tests cover drop-value filtering in compare
<!-- AC:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->
compareInstance accepts dropValuePatterns, applied symmetrically in intentFor (same path as the www-redirect drop). --drop-value wired into both compare modes (single + --mapping-file bulk). README compare section documents it. Test: CRITICAL without pattern, OK with the migration's pattern.
<!-- SECTION:NOTES:END -->
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
id: TASK-1.12
title: >-
dns import/migrate: --json with interactive confirmation prompts without
showing the plan
status: Done
assignee: []
created_date: '2026-07-23 19:05'
updated_date: '2026-07-23 19:20'
labels:
- code-review
- dns
dependencies: []
references:
- bin/pos-cli-dns-import.js
- bin/pos-cli-dns-migrate.js
- lib/dns/guard.js
parent_task_id: TASK-1
priority: medium
ordinal: 14000
---

## Description

<!-- SECTION:DESCRIPTION:BEGIN -->
Found in code review of the dns-migration branch.

Plan rendering is gated on `!params.json` (bin/pos-cli-dns-import.js:69-72, bin/pos-cli-dns-migrate.js:67-69), but the confirmApply gate still prompts when running interactively without --yes (import.js:84, migrate.js:188). An interactive run with --json therefore asks "Apply these DNS changes to <target>?" without ever having displayed what would be applied — a blind confirmation in a tool whose safety model is plan-then-confirm.

Two reasonable resolutions, pick one: emit the plan as JSON before prompting, or refuse --json without --yes/--dry-run with an actionable message (mirroring the existing non-TTY refusal in lib/dns/guard.js confirmApply).
<!-- SECTION:DESCRIPTION:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 An interactive --json run is never asked for confirmation without the plan having been output first (or the run is refused with an actionable message)
- [x] #2 Non-interactive, --yes, and --dry-run behavior is unchanged
- [x] #3 Tests cover the json + interactive confirmation path
<!-- AC:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->
Chose the refusal resolution: confirmApply({json}) throws 'With --json there is no plan review to confirm — pass --yes to apply, or --dry-run to preview the plan as JSON' before any prompt. Non-interactive/--yes/--dry-run paths unchanged (guard tests cover all branches).
<!-- SECTION:NOTES:END -->
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
id: TASK-1.13
title: 'dns: unify and document exit codes across import/migrate single and bulk modes'
status: Done
assignee: []
created_date: '2026-07-23 19:05'
updated_date: '2026-07-23 19:20'
labels:
- code-review
- dns
dependencies: []
references:
- bin/pos-cli-dns-migrate.js
- bin/pos-cli-dns-import.js
- README.md
parent_task_id: TASK-1
priority: low
ordinal: 15000
---

## Description

<!-- SECTION:DESCRIPTION:BEGIN -->
Found in code review of the dns-migration branch.

The dns commands have a deliberate exit-code contract for scripts/CI — 0 success, 1 apply/compare failures, 2 transform errors, 3 blocked-destructive — but it is applied inconsistently and documented nowhere:

- Single-pair migrate exits 2 on transform errors (bin/pos-cli-dns-migrate.js:191), and import does the same, but bulk migrate collapses the same condition to 1 (bin/pos-cli-dns-migrate.js:253, `anyTransformErrors ? 1 : ...`).
- The README dns section only mentions "exit 1 on CRITICAL" for compare; the 0/1/2/3 contract for import/migrate is undocumented.

Scripts driving cohort migrations need the same condition to yield the same code in both modes.
<!-- SECTION:DESCRIPTION:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The same failure condition produces the same exit code in single-pair and bulk migrate modes
- [x] #2 Exit codes for import, migrate, and compare are documented in the README dns section
- [x] #3 Tests assert exit codes for the transform-error and blocked-destructive paths
<!-- AC:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->
exitCodeFor + exitCodeForOutcomes in lib/dns/cliHelpers.js; bulk migrate now maps pair-level transform errors to 2 like single mode (1 outranks 2 outranks 3 when mixed). README dns section documents 0/1/2/3 for import/migrate and 0/1 for compare. Contract tests in cliHelpers.test.js.
<!-- SECTION:NOTES:END -->
Loading
Loading