diff --git a/.gitignore b/.gitignore index c3f26f3..deb7836 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ coverage .env .DS_Store npm-debug.log* +*.tgz diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..beb04cc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +## 0.1.5 - 2026-07-12 + +### Added + +- Site Health checks, configurable alerts, and health/deployment overviews. +- SEO file and IndexNow management. +- Export cancellation, migration logs, release history, and static rollback workflows. +- Agency report PDFs, client shares, and weekly/monthly schedules. +- Mount-path management, environment custom domains, and safe custom-domain transfers. + +### Changed + +- Migration archives now use Static Studio's authenticated presigned multipart upload protocol. The CLI no longer receives storage credentials or depends on the AWS SDK. +- Site creation now uses Studio-generated credentials and the hardened Lagos queue contract. +- Domain, SSL, environment, redirect, user, and team operations now send the authorization context required by Lagos. +- Site listings include team-access sites while keeping embedded site metadata scoped to the authenticated user. +- WordPress roles are limited to `administrator`, `editor`, `author`, and `contributor`. + +### Fixed + +- CDN cache clears now authorize and purge by the site's pull zone. +- Domain mutations now require a deployed site, protect managed hostnames, and surface partial WordPress or edge-rule failures. +- Team removal now cleans up the matching site metadata record. +- Custom PageSpeed targets are rejected because Studio now tests the stored canonical site URL. diff --git a/README.md b/README.md index e4781e0..dffaf35 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Command-line interface for Static Studio hosting workflows. The package is intended for humans and coding agents such as Codex or Claude Code that need a narrow, scriptable interface to the Static Studio platform. +This documentation covers release `0.1.5`. + ## Install ```bash @@ -31,7 +33,7 @@ Personal Access Token login for CI or agents: static-studio login --token "$STATIC_STUDIO_ACCESS_TOKEN" ``` -Generate Personal Access Tokens in the Static Studio app under **Account -> Access Token**. They require an active paid subscription; trial accounts can see the CLI option in the app, but cannot issue or exchange tokens. +Generate Personal Access Tokens in the Static Studio app under **Account -> Access Token**. Token issuance and exchange require an active subscription with quota greater than zero. Trial status alone does not categorically exclude an account; the subscription and quota checks are authoritative. You can also skip local config entirely: @@ -52,18 +54,55 @@ static-studio sites get static-studio sites basic-auth static-studio sites magic-login static-studio sites debug-log --tail 200 -static-studio sites create --name Demo --subdomain demo123 +static-studio sites create --name Demo --subdomain demo123 --mount-path /blog static-studio sites create --name Import --migration-file ./site-studio-backup.zip static-studio sites push full static-studio sites push changes static-studio sites redeploy --migration-file ./site-studio-backup.zip +static-studio sites cancel-export --yes +static-studio sites migration-log +static-studio sites releases +static-studio sites rollback-status +static-studio sites rollback-snapshots +static-studio sites rollback --yes static-studio sites delete --yes static-studio domains list static-studio domains add example.com static-studio domains primary example.com +static-studio domains mount example.com /docs +static-studio domains issue-ssl example.com +static-studio domains transfer example.com --yes static-studio domains remove example.com +static-studio health overview +static-studio health run --checks uptime ssl sitemap + +static-studio alerts status +static-studio alerts save --enabled on --recipients alerts@example.com +static-studio alerts test alerts@example.com + +static-studio seo list +static-studio seo save robots.txt --file ./robots.txt +static-studio seo check-sitemaps + +static-studio indexnow status +static-studio indexnow enable +static-studio indexnow auto-submit on +static-studio indexnow submit + +static-studio reports list +static-studio reports get +static-studio reports generate-pdf +static-studio reports pdf-url +static-studio reports shares +static-studio reports share --recipient client@example.com --password-env REPORT_SHARE_PASSWORD +static-studio reports revoke-share --yes +static-studio reports schedules +static-studio reports schedule monthly --name "Monthly report" +static-studio reports disable-schedule +static-studio reports delete-schedule --yes + static-studio backups list --refresh static-studio backups create static-studio backups restore --backup-id @@ -97,6 +136,10 @@ static-studio environments enable static-studio environments create staging static-studio environments delete staging --yes static-studio environments disable --yes +static-studio environments domain-list staging +static-studio environments domain-connect staging staging.example.com +static-studio environments domain-ssl staging staging.example.com +static-studio environments domain-remove staging --yes static-studio tags list static-studio tags create Client --color '#3858E9' @@ -106,6 +149,14 @@ static-studio tags remove static-studio ssh add --key-file ~/.ssh/id_ed25519.pub ``` +New sites use managed Studio URLs and server-generated WordPress, Basic Auth, and secret credentials. Migration archives are uploaded with presigned multipart requests in 25 MiB parts, up to the 10 GB limit. + +The `--url` option on `performance run` and `performance get` is deprecated. If supplied for compatibility, it must resolve to the site's stored URL. + +WordPress role options support `administrator`, `editor`, `author`, and `contributor`; `subscriber` is not supported. + +Reports and configurable Site Health alerts require the corresponding Agency entitlements. Lagos enforces these entitlements and site access for every operation. + ## Documentation Detailed CLI documentation lives in [`docs/`](docs/README.md): @@ -134,8 +185,13 @@ This package is scoped and intended to be public on npm. Before publishing, make ```bash npm login -npm version patch +npm ci +npm run typecheck +npm test +npm run build +npm audit --audit-level=high +npm pack --dry-run npm publish --access public ``` -Use `npm pack --dry-run` to inspect exactly which files will be published. +The package version and changelog must already be updated before this checklist. Use `npm version patch` when starting the next release, not after a version has been prepared. diff --git a/docs/README.md b/docs/README.md index 4760021..c8b2197 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,12 +2,14 @@ This directory contains the detailed reference for the `static-studio` command line interface. +These pages document release `0.1.5`. + ## Documents - [Authentication and configuration](authentication-and-configuration.md) explains install requirements, login methods, profiles, environment variables, output modes, and error behavior. - [Command reference](command-reference.md) lists every command, argument, option, default, and important behavior exposed by the CLI. -- [Input formats and limits](input-formats-and-limits.md) documents accepted file formats, validation rules, and safety limits for uploads, bulk redirects, team invites, logs, IDs, tags, and environments. -- [Workflows](workflows.md) provides task-oriented examples for common hosting operations. +- [Input formats and limits](input-formats-and-limits.md) documents accepted file formats, validation rules, and safety limits for uploads, SEO files, alerts, reports, bulk operations, logs, IDs, tags, and environments. +- [Workflows](workflows.md) provides task-oriented examples for hosting, deployment recovery, domains, Site Health, alerts, SEO, IndexNow, and reports. ## Executable Names @@ -34,6 +36,6 @@ For non-interactive environments, provide a Personal Access Token: STATIC_STUDIO_ACCESS_TOKEN=... static-studio --json sites list ``` -Personal Access Tokens are generated in the Static Studio app under **Account -> Access Token** and require an active paid subscription. +Personal Access Tokens are generated in the Static Studio app under **Account -> Access Token**. Issuance and exchange require an active subscription with quota greater than zero; trial status alone is not a categorical exclusion. Use `--json` whenever another program or agent will parse the output. diff --git a/docs/authentication-and-configuration.md b/docs/authentication-and-configuration.md index d42c4bf..5acfaa1 100644 --- a/docs/authentication-and-configuration.md +++ b/docs/authentication-and-configuration.md @@ -51,7 +51,7 @@ Personal Access Token login: static-studio login --token "$STATIC_STUDIO_ACCESS_TOKEN" ``` -Generate Personal Access Tokens in the Static Studio app under **Account -> Access Token**. They require an active paid subscription; free trial accounts can open the CLI token screen, but the token field and regeneration action remain locked until the account subscribes. +Generate Personal Access Tokens in the Static Studio app under **Account -> Access Token**. Issuing and exchanging a token requires a subscription whose status is `active` and whose quota is greater than zero. Trial status is not checked as a separate categorical exclusion, so an eligible trial account can use a token. The CLI exchanges a Personal Access Token through the platform `access-token` Edge Function for a short-lived Supabase access token. Personal Access Tokens do not have refresh tokens, and the exchange response intentionally does not include one. diff --git a/docs/command-reference.md b/docs/command-reference.md index 0b6ff96..221f1cd 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1,6 +1,6 @@ # Command Reference -All commands support the global `--json` and `--profile ` options. The `sss` binary is an alias for `static-studio`. +All commands support the global `--json` and `--profile ` options. The `sss` binary is an alias for `static-studio`. This reference covers release `0.1.5`. ## Root Commands @@ -12,7 +12,7 @@ Sign in with email OTP, store a Personal Access Token, or store a Supabase sessi static-studio login [--email ] [--otp ] [--token ] [--refresh-token ] [--create-user] ``` -If `--token` is present, token login is used. Personal Access Tokens are generated in the Static Studio app under **Account -> Access Token**, require an active paid subscription, and should be passed without `--refresh-token`. `--refresh-token` is only for Supabase session access tokens. Otherwise the CLI starts email OTP login. +If `--token` is present, token login is used. Personal Access Tokens are generated in the Static Studio app under **Account -> Access Token**. Issuance and exchange require an active subscription with quota greater than zero; trial status alone is not a categorical exclusion. Pass a Personal Access Token without `--refresh-token`, which is only for Supabase session access tokens. Otherwise the CLI starts email OTP login. ### `logout` @@ -124,14 +124,16 @@ static-studio sites create [options] | `--name ` | generated | Site display name. | | `--subdomain ` | generated | System-domain subdomain. | | `--tld ` | `onstatic.studio` | System-domain TLD. | -| `--url ` | derived | Public site URL. | -| `--admin-url ` | derived | WordPress admin URL. | +| `--url ` | derived | Managed Studio public URL. Custom domains are not accepted during creation. | +| `--mount-path ` | none | Serve the static site below this public URL path, for example `/blog`. | | `--bedrock` | off | Create a Bedrock WordPress site. | -| `--php-version ` | `8.3` | PHP version for the queued site. | +| `--php-version ` | `8.3` | PHP version: `7.4`, `8.0`, `8.1`, `8.2`, `8.3`, or `8.4`. | | `--migration-file ` | none | Upload a migration archive before queueing the site. | | `--allow-any-zip-name` | off | Skip Static Studio backup ZIP filename validation. | -If `--migration-file` is supplied, the CLI uploads the file before queueing the site and prints upload metadata with the created site, queue result, and generated credentials. +The public URL must be an HTTPS managed Studio hostname. Connect a custom domain after provisioning with `domains add` or `domains primary`. The former `--admin-url` option has been removed: the platform derives the admin URL and cryptographically generates the WordPress, Basic Auth, and secret credentials on the server. The command retrieves and prints those server-generated credentials. + +If `--migration-file` is supplied, the CLI uploads the file before queueing the site and prints upload metadata with the created site, queue result, and credentials. Uploads use presigned multipart requests with 25 MiB parts and support archives up to 10 GB. ### `sites update` @@ -195,6 +197,62 @@ Clear the CDN cache for a site. static-studio sites clear-cache ``` +### `sites cancel-export` + +Cancel the site's currently active static export. + +```bash +static-studio sites cancel-export [--yes] +``` + +The site must currently be exporting. The command prompts unless `--yes` is supplied. Rollback exports cannot be cancelled through this command. + +### `sites migration-log` + +Show the site's migration log. + +```bash +static-studio sites migration-log +``` + +Plain output prints the log content. JSON output also includes its line count. + +### `sites releases` + +List recent production releases, newest first. + +```bash +static-studio sites releases [--limit ] +``` + +`--limit` defaults to `10` and accepts 1 to 100. + +### `sites rollback-status` + +Show rollback availability, state, and currently available snapshots. + +```bash +static-studio sites rollback-status +``` + +### `sites rollback-snapshots` + +Print only the available rollback snapshots. + +```bash +static-studio sites rollback-snapshots +``` + +### `sites rollback` + +Restore a static export snapshot. + +```bash +static-studio sites rollback [--yes] +``` + +The snapshot must still be in the platform's current rollback set. The command prompts unless `--yes` is supplied. + ## Domains ### `domains list` @@ -231,14 +289,36 @@ Remove a custom domain. static-studio domains remove ``` +### `domains mount` + +Set or clear the public mount path and start an export. + +```bash +static-studio domains mount [path] +``` + +For example, pass `/docs` to publish below that path. Omit `path` (or pass `/`) to clear the mount path. The command makes `` the stored public domain, updates routing, and starts an export. + ### `domains issue-ssl` Request SSL issuance for a domain. ```bash -static-studio domains issue-ssl +static-studio domains issue-ssl +``` + +The site ID is required so the platform can verify domain ownership. For compatibility with the former single-domain syntax, `static-studio domains issue-ssl --site-id ` is also accepted. + +### `domains transfer` + +Move a custom domain from one site to another. + +```bash +static-studio domains transfer [--yes] ``` +The authenticated user must be an administrator of both sites. The command confirms the move unless `--yes` is supplied; the platform updates the domain registry, site URLs, WordPress configuration, and routing for the affected sites. + ## Account ### `account usage` @@ -254,6 +334,268 @@ static-studio account usage [--account-id ] [--include-subscription] | `--account-id ` | Account owner ID. Defaults to the authenticated user. | | `--include-subscription` | Include the latest subscription row. | +## Site Health + +### `health overview` + +Show the latest deploy manifest and diff, current health snapshot, open issues, recent check results, and recent releases. + +```bash +static-studio health overview [--issue-limit ] [--result-limit ] [--release-limit ] +``` + +The defaults are 20 issues, 50 results, and 20 releases. Each limit accepts 1 to 100. + +### `health run` + +Run health checks for one or more sites. + +```bash +static-studio health run [--checks ] +``` + +At most 25 sites can be checked at once. Without `--checks`, all checks run. Supported check names are: + +```text +uptime, response_time, ssl, dns, cdn, static_freshness, +robots, sitemap, indexability, ai_readiness +``` + +## Site Health Alerts + +Configurable alerts are available only to workspaces with the Lagos Agency alert entitlement. Lagos also requires manage access to the site. + +### `alerts status` + +Show alert settings, current rule conditions, open incidents, and recent delivery events. + +```bash +static-studio alerts status +``` + +### `alerts save` + +Save or pause alert settings. + +```bash +static-studio alerts save --enabled [options] +``` + +| Option | Default | Description | +| --- | --- | --- | +| `--enabled ` | required | Turn the alert configuration on or off. | +| `--recipients ` | none | Alert recipient addresses, normalized and deduplicated; maximum 10. | +| `--uptime ` | on | Turn downtime alerts on or off. | +| `--uptime-threshold ` | `10` | Alert after 5 to 1440 minutes of downtime. | +| `--performance ` | on | Turn PageSpeed alerts on or off. | +| `--performance-threshold ` | `70` | Alert below an integer score from 1 to 100. | +| `--seo-critical ` | on | Turn critical SEO alerts on or off. | +| `--notify-on-recovery ` | on | Send recovery notices after an incident clears. | + +Toggle states accept `on`/`off`, `true`/`false`, `yes`/`no`, or `1`/`0`. When alerts are enabled, at least one recipient and one rule are required. Thresholds are validated even when the configuration is paused. + +### `alerts test` + +Send a test alert to one or more recipients. + +```bash +static-studio alerts test +``` + +Addresses are normalized and deduplicated. At least one and at most 10 unique valid recipients are required. + +## SEO Files + +SEO file commands manage root-level files in the site's static storage. Paths cannot contain subdirectories, traversal segments, query strings, or fragments. + +### `seo list` + +```bash +static-studio seo list +``` + +List managed SEO files, including their content and last push state. + +### `seo save` + +```bash +static-studio seo save (--content | --file ) [--kind ] [--mime-type ] +``` + +Create or update a file such as `robots.txt`, `llms.txt`, `sitemap.xml`, or a verification file. Provide exactly one content source. UTF-8 content may be at most 1 MiB; the platform infers file kind and MIME type when their options are omitted. + +### `seo delete` + +```bash +static-studio seo delete [--yes] +``` + +Delete the file from static storage and mark its managed record deleted. The command prompts unless `--yes` is supplied. + +### `seo apply` + +```bash +static-studio seo apply +``` + +Push all active managed SEO files to static storage. + +### `seo check-sitemaps` + +```bash +static-studio seo check-sitemaps +``` + +Check `sitemap.xml` and `sitemap_index.xml` at the site's public URL and record the sitemap health result. + +## IndexNow + +### `indexnow status` + +```bash +static-studio indexnow status +``` + +Show the sanitized IndexNow setting, latest production release, eligible URL count, and any skip reason. + +### `indexnow enable` + +```bash +static-studio indexnow enable +``` + +Enable IndexNow and create or refresh its public verification key file. + +### `indexnow disable` + +```bash +static-studio indexnow disable +``` + +Disable IndexNow for the site. + +### `indexnow auto-submit` + +```bash +static-studio indexnow auto-submit +``` + +Set automatic post-release submission. `state` accepts `on` or `off`; `true`/`false`, `yes`/`no`, and `1`/`0` are also accepted. + +### `indexnow submit` + +```bash +static-studio indexnow submit [--release-id ] +``` + +Submit the selected production release, or the latest release when `--release-id` is omitted. Enable IndexNow first so the site has a verification key. + +## Agency Reports + +Report history, PDFs, client links, and schedules require the applicable Lagos Agency entitlements. Lagos also enforces site read or manage access for each action. Report, share, and schedule IDs are UUIDs. + +### `reports list` + +List stored reports for a site, newest first. + +```bash +static-studio reports list [--limit ] +``` + +`--limit` defaults to `20` and accepts 1 to 200. + +### `reports get` + +Show one stored report. + +```bash +static-studio reports get +``` + +### `reports generate-pdf` + +Generate and store a PDF for an existing report. + +```bash +static-studio reports generate-pdf +``` + +This generates the PDF artifact; it does not create a new report record. + +### `reports pdf-url` + +Create a short-lived signed URL for a stored report PDF. + +```bash +static-studio reports pdf-url +``` + +The URL is valid for 10 minutes. Plain output prints only the URL; JSON output returns `{ "url": "..." }`. + +### `reports shares` + +List client share links for a report. + +```bash +static-studio reports shares +``` + +### `reports share` + +Create a client share link. + +```bash +static-studio reports share [--recipient ] [--password-env ] [--expires-at ] +``` + +`--expires-at` must be a future date and time. To password-protect a link, set the secret through your environment or secret manager, then name that variable with `--password-env`. The variable name must be a valid shell-style environment name and its value must be nonempty. There is deliberately no plaintext `--password` option, which keeps the password out of command arguments and process listings. + +### `reports revoke-share` + +Revoke a client share link. + +```bash +static-studio reports revoke-share [--yes] +``` + +The command prompts unless `--yes` is supplied. + +### `reports schedules` + +List report schedules, optionally scoped to one site. + +```bash +static-studio reports schedules [siteId] +``` + +### `reports schedule` + +Create or update a weekly or monthly schedule. + +```bash +static-studio reports schedule [--schedule-id ] [--name ] [--auto-share ] [--recipient ] +``` + +`cadence` must be `weekly` or `monthly`. Provide 1 to 100 site IDs; duplicates are removed, and multiple sites create a portfolio schedule within one entitled workspace. Use `--schedule-id` to update an existing schedule. Names may be at most 200 characters. `--auto-share` accepts `on`/`off`, `true`/`false`, `yes`/`no`, or `1`/`0`; when it is on, `--recipient` is required. + +### `reports disable-schedule` + +Disable a schedule without deleting it. + +```bash +static-studio reports disable-schedule +``` + +### `reports delete-schedule` + +Delete a schedule. + +```bash +static-studio reports delete-schedule [--yes] +``` + +The command prompts unless `--yes` is supplied. + ## Performance ### `performance run` @@ -267,7 +609,7 @@ static-studio performance run [--force] [--url ] | Option | Description | | --- | --- | | `--force` | Ignore cached PageSpeed reports. | -| `--url ` | Test a URL instead of the stored site URL. Must use HTTP or HTTPS. | +| `--url ` | Deprecated compatibility option. If supplied, it must normalize to the site's stored URL. | ### `performance stats` @@ -287,6 +629,8 @@ Run a performance test and fetch CDN statistics in one command. static-studio performance get [--force] [--url ] ``` +As with `performance run`, `--url` is deprecated and cannot select a different target. + ### `performance reports` List cached PageSpeed reports. @@ -355,6 +699,46 @@ static-studio environments disable [--yes] Prompts for confirmation unless `--yes` is supplied. +### `environments domain-list` + +List the managed Studio hostname and any custom hostname attached to an environment. + +```bash +static-studio environments domain-list +``` + +`` may identify the environment by ID, name, slug, or title. The parent site must be fully deployed before any environment domain command can run. + +### `environments domain-connect` + +Connect or replace the environment's custom domain. + +```bash +static-studio environments domain-connect +``` + +An environment can have one custom domain. Connecting a different hostname removes the existing custom hostname first, preserves the managed Studio hostname, updates WordPress and edge routing, and returns any non-fatal synchronization warnings. + +### `environments domain-remove` + +Remove all custom hostnames from an environment. + +```bash +static-studio environments domain-remove [--yes] +``` + +The command prompts unless `--yes` is supplied. It restores the managed Studio hostname as the WordPress and routing fallback; if no custom hostname exists, it reports the environment unchanged. + +### `environments domain-ssl` + +Request SSL for a hostname already attached to the environment. + +```bash +static-studio environments domain-ssl +``` + +The hostname must already appear in `environments domain-list`. SSL is requested only for that hostname, without an automatic `www` secondary hostname. + ## Tags ### `tags list` @@ -547,6 +931,8 @@ See [Input formats and limits](input-formats-and-limits.md#bulk-redirect-files) ## Users +WordPress role options support `administrator`, `editor`, `author`, and `contributor`. The `subscriber` role is not supported. + ### `users list` List users on a site. diff --git a/docs/input-formats-and-limits.md b/docs/input-formats-and-limits.md index a8ae967..5994307 100644 --- a/docs/input-formats-and-limits.md +++ b/docs/input-formats-and-limits.md @@ -23,18 +23,61 @@ static-studio tags assign site_123 tag-abc Emails are trimmed, lowercased, and validated. Email lists are deduplicated after normalization. +Site Health alerts accept at most 10 unique recipients. An enabled alert configuration requires at least one recipient; `alerts test` always requires at least one. + WordPress role options accept: ```text -administrator, editor, author, contributor, subscriber +administrator, editor, author, contributor ``` -`users invite` and `users add` default to `administrator`. Team invite commands default to `editor`. +`subscriber` is not supported. `users invite` and `users add` default to `administrator`. Team invite commands default to `editor`. + +## Alert Settings + +Alert site IDs must be positive safe integers. `alerts save --enabled on` requires at least one of the uptime, performance, or critical SEO rules to be on. + +| Setting | Allowed value | +| --- | --- | +| Uptime threshold | Integer from 5 to 1440 minutes | +| Performance threshold | Integer from 1 to 100 | +| Recipients | 1 to 10 unique valid emails when enabled | + +Alert toggles accept `on`/`off`, `true`/`false`, `yes`/`no`, and `1`/`0`. Configurable alerts require the Lagos Agency alert entitlement. + +## Report Inputs + +Report site IDs must be positive safe integers. Report, share, and schedule record IDs must be valid UUIDs. `reports list --limit` accepts 1 to 200 rows. + +Report share rules: + +- `--recipient` must be a valid email when supplied. +- `--expires-at` must be a valid future date and time. +- Passwords may be at most 256 characters and are read only from the environment variable named by `--password-env`; no plaintext password option is accepted. +- Environment variable names must start with a letter or underscore and contain only letters, numbers, and underscores. The selected variable must be set and nonempty. + +Report schedule rules: + +- Cadence must be `weekly` or `monthly`. +- Each schedule accepts 1 to 100 site IDs; duplicates are removed. +- Schedule names may be at most 200 characters. +- `--auto-share on` requires a valid `--recipient`. +- Toggle values use the same accepted forms as alert toggles. + +Report features require the applicable Lagos Agency entitlements. ## Site Search `sites list --search ` accepts up to 120 characters. Control characters, commas, and parentheses are rejected because the value is embedded in a Supabase filter expression. +## Site Creation and Mount Paths + +New sites must use an HTTPS managed Studio hostname. `--subdomain` and `--tld` derive that URL; `--url` may only provide an equivalent managed Studio URL. Connect custom domains after creation. The platform derives the WordPress admin URL and generates all WordPress, Basic Auth, and secret credentials server-side, so `--admin-url` is no longer accepted. + +Supported `--php-version` values are `7.4`, `8.0`, `8.1`, `8.2`, `8.3`, and `8.4`. + +`sites create --mount-path` and `domains mount` normalize `/` to no mount path. Other values must be safe URL paths such as `/blog` without `..`, a query, or a fragment. + ## Migration Uploads Migration uploads are accepted by: @@ -50,7 +93,7 @@ Accepted archive extensions: - `.tar.gz` - `.tgz` -ZIP files must include `studio-backup-` in the filename unless `--allow-any-zip-name` is supplied. The maximum upload size is 10 GiB. +ZIP files must include `studio-backup-` in the filename unless `--allow-any-zip-name` is supplied. The maximum upload size is 10 GB. Uploads are stored in the `site_migrations` bucket with a key derived from the site subdomain: @@ -59,7 +102,19 @@ public/site-migration-.zip public/site-migration-.tar.gz ``` -The CLI requests temporary upload credentials from Static Studio, uploads with multipart S3-compatible upload, and includes upload metadata in the command result. +The CLI initializes a presigned multipart upload through Static Studio. It reads and uploads 25 MiB parts with two concurrent workers, requests signed URLs in batches of 10, retries a failed part up to five times, and completes the upload with the returned ETags. An interrupted upload is aborted when possible. Raw storage credentials are never returned to the CLI. Upload metadata is included in the command result. + +## SEO Files + +`seo save` accepts exactly one of `--content ` or `--file `. Content is measured as UTF-8 and must be 1 MiB or smaller. + +Managed SEO paths are root-level filenames such as: + +```text +robots.txt, llms.txt, sitemap.xml, google123.html +``` + +Leading slashes are removed. Subdirectories, `..`, `?`, and `#` are rejected. ## Bulk Redirect Files @@ -171,12 +226,22 @@ The name must start with a letter or number. The CLI creates a slug by lowercasi `production` is reserved and cannot be used as a child environment name or deleted as a child environment. +Environment domain commands accept an environment ID, name, slug, or title. The parent site must have status `deployed`. Custom domains must be hostnames without credentials, ports, paths, queries, or fragments, and managed Studio hostnames cannot be used as custom domains. Each environment can have one custom hostname; connecting another replaces it. + ## Numeric Limits | Context | Allowed range | | --- | --- | | `sites list --page` | 1 to 10000 | | `sites list --page-size` | 1 to 100 | +| `sites releases --limit` | 1 to 100 | +| `health overview` row limits | 1 to 100 each | +| `health run` site IDs | 1 to 25 sites | +| `alerts` recipients | 1 to 10 unique emails when required | +| `alerts --uptime-threshold` | 5 to 1440 minutes | +| `alerts --performance-threshold` | 1 to 100 | +| `reports list --limit` | 1 to 200 | +| `reports schedule` site IDs | 1 to 100 sites | | `performance reports --limit` | 1 to 100 | | `logs get --tail` / `sites debug-log --tail` | 1 to 10000 | | `team invite --max-emails` / `team bulk-invite --max-emails` | 1 to 100 | @@ -187,6 +252,13 @@ The name must start with a letter or number. The CLI creates a slug by lowercasi The following commands prompt unless `--yes` is supplied: - `sites delete` +- `sites cancel-export` +- `sites rollback` +- `domains transfer` +- `seo delete` +- `reports revoke-share` +- `reports delete-schedule` +- `environments domain-remove` - `environments delete` - `environments disable` - `tags delete` diff --git a/docs/workflows.md b/docs/workflows.md index ba73188..da89610 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -17,7 +17,7 @@ CI or agent usage: STATIC_STUDIO_ACCESS_TOKEN=... static-studio --json sites list ``` -Generate the token in Static Studio under **Account -> Access Token**. Personal Access Tokens are available for paid subscriptions and are passed without a refresh token. +Generate the token in Static Studio under **Account -> Access Token**. Token issuance and exchange require an active subscription with quota greater than zero; trial status alone does not exclude an otherwise eligible account. Pass a Personal Access Token without a refresh token. Multiple accounts: @@ -42,6 +42,7 @@ static-studio sites create \ --name "Demo" \ --subdomain demo123 \ --tld onstatic.studio \ + --mount-path /blog \ --php-version 8.3 ``` @@ -54,7 +55,7 @@ static-studio sites create \ --migration-file ./studio-backup-imported-site.zip ``` -The create response includes the site record, queue result, generated WordPress credentials, generated Basic Auth credentials, and optional upload metadata. +New sites must use a managed Studio URL. Add a custom domain after provisioning. The create response includes the site record, queue result, server-generated WordPress and Basic Auth credentials, and optional upload metadata. Migration files use presigned multipart upload in 25 MiB parts and may be up to 10 GB. ## Operate a Site @@ -80,6 +81,8 @@ Recover or maintain: static-studio sites retry static-studio sites changes static-studio sites clear-cache +static-studio sites cancel-export --yes +static-studio sites migration-log static-studio sites delete --yes ``` @@ -89,18 +92,38 @@ Redeploy from a replacement migration archive: static-studio sites redeploy --migration-file ./studio-backup-site.zip ``` +Inspect releases and roll back the static site: + +```bash +static-studio sites releases --limit 10 +static-studio sites rollback-status +static-studio sites rollback-snapshots +static-studio sites rollback --yes +``` + +Use a snapshot returned by the status or snapshots command. Rollback changes the static export; it does not restore WordPress content. + ## Domains and SSL ```bash static-studio domains list static-studio domains add example.com static-studio domains primary example.com -static-studio domains issue-ssl example.com +static-studio domains mount example.com /docs +static-studio domains issue-ssl example.com static-studio domains remove old.example.com ``` Adding or changing the primary domain starts an export. +Move a domain between two sites when you administer both: + +```bash +static-studio domains transfer example.com --yes +``` + +The legacy SSL syntax remains available as `static-studio domains issue-ssl example.com --site-id `. + ## Backups ```bash @@ -171,6 +194,106 @@ static-studio team remove --yes Team invite commands add existing Static Studio users to the account team, grant access to owned sites, and optionally send site invites for missing users. +Supported WordPress roles are `administrator`, `editor`, `author`, and `contributor`; `subscriber` is not accepted. + +## Site Health + +Inspect persisted health and deployment state: + +```bash +static-studio health overview +``` + +Run every check, or select checks for up to 25 sites: + +```bash +static-studio health run +static-studio health run --checks uptime ssl sitemap indexability +``` + +Check names are `uptime`, `response_time`, `ssl`, `dns`, `cdn`, `static_freshness`, `robots`, `sitemap`, `indexability`, and `ai_readiness`. + +Configure and test Agency Site Health alerts: + +```bash +static-studio alerts status +static-studio alerts save \ + --enabled on \ + --recipients ops@example.com owner@example.com \ + --uptime on \ + --uptime-threshold 10 \ + --performance on \ + --performance-threshold 70 \ + --seo-critical on \ + --notify-on-recovery on +static-studio alerts test ops@example.com +``` + +Enabled alerts require at least one of the three rules and 1 to 10 recipients. These commands require the Lagos Agency alert entitlement and site manage access. + +## SEO Files and IndexNow + +Manage root-level SEO files and verify sitemaps: + +```bash +static-studio seo list +static-studio seo save robots.txt --file ./robots.txt +static-studio seo save llms.txt --content "# Example" +static-studio seo apply +static-studio seo check-sitemaps +static-studio seo delete llms.txt --yes +``` + +Enable IndexNow, configure automatic submissions, or submit the latest release manually: + +```bash +static-studio indexnow status +static-studio indexnow enable +static-studio indexnow auto-submit on +static-studio indexnow submit +static-studio indexnow submit --release-id +static-studio indexnow disable +``` + +## Agency Reports + +Inspect stored reports and generate PDF artifacts: + +```bash +static-studio reports list --limit 20 +static-studio reports get +static-studio reports generate-pdf +static-studio reports pdf-url +``` + +Create and manage a client share link: + +```bash +# Configure REPORT_SHARE_PASSWORD through your shell or secret manager first. +static-studio reports share \ + --recipient client@example.com \ + --password-env REPORT_SHARE_PASSWORD \ + --expires-at 2099-12-31T23:59:59Z +static-studio reports shares +static-studio reports revoke-share --yes +``` + +The CLI never accepts the share password as a plaintext argument. + +Create a schedule for one site or an Agency portfolio: + +```bash +static-studio reports schedules [siteId] +static-studio reports schedule monthly --name "Monthly client report" +static-studio reports schedule weekly \ + --auto-share on \ + --recipient client@example.com +static-studio reports disable-schedule +static-studio reports delete-schedule --yes +``` + +Schedules accept `weekly` or `monthly` and at most 100 site IDs. PDF, sharing, history, and schedule operations require the corresponding Lagos Agency entitlements. + ## Performance Run a PageSpeed/global TTFB test: @@ -188,9 +311,11 @@ static-studio performance stats Fetch both: ```bash -static-studio performance get --url https://example.com/ +static-studio performance get --force ``` +The deprecated `--url` option is accepted only when it normalizes to the site's stored URL; it can no longer select a different performance target. + List cached reports: ```bash @@ -231,6 +356,17 @@ static-studio environments disable --yes Disabling environments deletes child environments and removes the add-on flag. +Manage a child environment's custom domain after the parent site is fully deployed: + +```bash +static-studio environments domain-list staging +static-studio environments domain-connect staging staging.example.com +static-studio environments domain-ssl staging staging.example.com +static-studio environments domain-remove staging --yes +``` + +Each environment keeps its managed Studio hostname and can have one custom hostname. Connecting another replaces the current custom hostname. Removing it restores the managed hostname as the WordPress and edge-routing fallback. + ## Tags ```bash diff --git a/package-lock.json b/package-lock.json index d566adf..b245c34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,14 @@ { "name": "@simply-static/static-studio", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@simply-static/static-studio", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "dependencies": { - "@aws-sdk/client-s3": "^3.637.0", - "@aws-sdk/lib-storage": "^3.637.0", "@supabase/supabase-js": "^2.46.2", "commander": "^12.1.0" }, @@ -29,456 +27,6 @@ "node": ">=20" } }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/crc32c": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", - "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", - "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/checksums": { - "version": "3.1000.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.5.tgz", - "integrity": "sha512-zOXUUnilC6lgCsQtp77p/QNPmRlTES9Xi6tlDwbR6kfC/kz5PCzZckgHWm5z+8DskdwuMAbFDq61x3zr10GEEQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@aws-crypto/crc32c": "5.2.0", - "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/client-s3": { - "version": "3.1068.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1068.0.tgz", - "integrity": "sha512-lFgaIpxZvloNbJvQ337YPdMXhzI2zJdDw13nATVGnkAGNoNPx4ksD84AQAcuW75hsaaMaIuNmXU9sSx6+FTirA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha1-browser": "5.2.0", - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/credential-provider-node": "^3.972.55", - "@aws-sdk/middleware-flexible-checksums": "^3.974.30", - "@aws-sdk/middleware-sdk-s3": "^3.972.51", - "@aws-sdk/signature-v4-multi-region": "^3.996.34", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.974.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.20.tgz", - "integrity": "sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.12", - "@aws-sdk/xml-builder": "^3.972.29", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.6", - "@smithy/signature-v4": "^5.4.6", - "@smithy/types": "^4.14.3", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.46", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.46.tgz", - "integrity": "sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.48", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.48.tgz", - "integrity": "sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.53", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.53.tgz", - "integrity": "sha512-ZfdhIOR41q8TcWEnUac+gCOb+O2LBWdHLmjedXpXz4IEFW2ppNuFcm6p0sMTavpM+zD5TYfpH5Gp7guRyqSgsQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/credential-provider-env": "^3.972.46", - "@aws-sdk/credential-provider-http": "^3.972.48", - "@aws-sdk/credential-provider-login": "^3.972.52", - "@aws-sdk/credential-provider-process": "^3.972.46", - "@aws-sdk/credential-provider-sso": "^3.972.52", - "@aws-sdk/credential-provider-web-identity": "^3.972.52", - "@aws-sdk/nested-clients": "^3.997.20", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/credential-provider-imds": "^4.3.7", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.52", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.52.tgz", - "integrity": "sha512-9hu2oR0qH7Fst5Tzdx+UWxm+w5zCXtErTLtOOW5hwwQc170CLwOeniRxyFY6s9mHfGEfC5zFukNBdKBwJR8mhQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.20", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.55", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.55.tgz", - "integrity": "sha512-zMGLa/dhESVqmCD7mmIFFKSwSFrJGScvCXcjvBZEVOOMauFS5JRQvLTMukFpMEFWiV6dTAlsen2ATDBulLPtbg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.46", - "@aws-sdk/credential-provider-http": "^3.972.48", - "@aws-sdk/credential-provider-ini": "^3.972.53", - "@aws-sdk/credential-provider-process": "^3.972.46", - "@aws-sdk/credential-provider-sso": "^3.972.52", - "@aws-sdk/credential-provider-web-identity": "^3.972.52", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/credential-provider-imds": "^4.3.7", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.46", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.46.tgz", - "integrity": "sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.52", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.52.tgz", - "integrity": "sha512-nb2/n4o/HQf+FVpVbZe9vCTFngmuDoIsltMgLAtjixaKzvzhB4J8WSDFyWgnErgLHk55ctWH+I4PU+LIHhyffg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.20", - "@aws-sdk/token-providers": "3.1066.0", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.52", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.52.tgz", - "integrity": "sha512-lKj6aRSGbqLmpYmM24bY7a1Xmfcq2vkE3hv8CSPYfc1yCu0BPu/XEJ1L4Fm61MsU6ULLNSG8UGsffNoFUBjESA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.20", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/lib-storage": { - "version": "3.1068.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1068.0.tgz", - "integrity": "sha512-BGUS3EXFe+y87odXsC5enyBv4z/QT3EDydv+iTe9hCUr57ndAXCfJbvcm3f+s3aHS5FUDL3WozaBAcrsNeVhyQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", - "buffer": "5.6.0", - "events": "3.3.0", - "stream-browserify": "3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-s3": "^3.1068.0" - } - }, - "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.974.30", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.30.tgz", - "integrity": "sha512-OaIhub+3yTgfFWPzKO8OzOZFIMUoJaiS5v67y3spQg7SoULGoMx4jKVBbE+uhnzkiZXQ+rEDS0RqrK4/aD1yJw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/checksums": "^3.1000.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.51", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.51.tgz", - "integrity": "sha512-keQgcIUTcHL0Qn7guhsuLaxQU36r9norCrxgaPH4DNCwon4TPtXdI/UdYuycl9vj3Dlwc3YR1dfL3U+6iIwJ6w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/signature-v4-multi-region": "^3.996.34", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.20.tgz", - "integrity": "sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/signature-v4-multi-region": "^3.996.34", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.34.tgz", - "integrity": "sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.12", - "@smithy/signature-v4": "^5.4.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1066.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1066.0.tgz", - "integrity": "sha512-UqEUJq7dqa44hneLDUcX7UJy95cg8YqEWyakRpvIPnrNS3Mq+UlQHgCDGu5pvwAPtlIW4qcYbvW6reG6++FyvA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.20", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.973.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.12.tgz", - "integrity": "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.7.tgz", - "integrity": "sha512-M0D6oIpohdNHjc7udzTHEQyot0+0iuA36jc2I9Hps+f/GtKi2HO/pyijQnCnNcwZqLB5+rtn81z3eZK/GyjAmA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.29.tgz", - "integrity": "sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.3", - "fast-xml-parser": "5.7.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1013,18 +561,6 @@ "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@nodable/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -1649,126 +1185,6 @@ "win32" ] }, - "node_modules/@smithy/core": { - "version": "3.24.7", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.7.tgz", - "integrity": "sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.3.9", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.9.tgz", - "integrity": "sha512-ZlfJ/4Fa3jYb+3eaohPfG9utX9HmdhFNcFtpoGAhUhdynAOmGXtmigbi7eEiONKM+ykHw8RwKuDEb85Lx7t7fA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.7", - "@smithy/types": "^4.14.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.4.7", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.7.tgz", - "integrity": "sha512-NslaM2ir0N2hisDmzXLstPaVINZheh8SokyOC++kzFPloZucL2R7Y7bS57mSzx/1Fc/fqmn7twjkeezTTrV0EA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.7", - "@smithy/types": "^4.14.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.8.tgz", - "integrity": "sha512-f+DbsWUwSbtMu1a/j8Y93KiU1SRg9nyzfjereqn1BJ33QOTUXxdlYvVXMhAYl1vuR1Kmna5aIJe09KSIfyFNYw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.7", - "@smithy/types": "^4.14.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.4.7", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.7.tgz", - "integrity": "sha512-LwQZazFayImv+IOm0S0enoLeUJwmAlhGC5O6YCcLWezyu08dF46GOxPOq35OpBIHkgd7OvNvBStIFwVNyrvoBw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.7", - "@smithy/types": "^4.14.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.14.4", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.4.tgz", - "integrity": "sha512-B2S9+UGm1+/pHkcx3ZoLVX1a+pmSk8rqxRR+ZsNqZaJ5q9FWX9AFGQVM4qG5+OBeQUZVy99HY8HqW8gK/wgXzQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2039,18 +1455,6 @@ "dev": true, "license": "MIT" }, - "node_modules/anynum": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz", - "integrity": "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2061,42 +1465,6 @@ "node": ">=12" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT" - }, - "node_modules/buffer": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", - "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", - "license": "MIT", - "dependencies": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4" - } - }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -2269,15 +1637,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2288,43 +1647,6 @@ "node": ">=12.0.0" } }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", - "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.7", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2379,32 +1701,6 @@ "node": ">=20.0.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -2791,21 +2087,6 @@ "node": ">=12.20.0" } }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2927,20 +2208,6 @@ } } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3044,26 +2311,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3105,40 +2352,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stream-browserify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", - "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", - "license": "MIT", - "dependencies": { - "inherits": "~2.0.4", - "readable-stream": "^3.5.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strnum": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz", - "integrity": "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "anynum": "^1.0.0" - } - }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3359,12 +2572,6 @@ "dev": true, "license": "MIT" }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", @@ -3559,21 +2766,6 @@ "engines": { "node": ">=8" } - }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } } } } diff --git a/package.json b/package.json index 3231f10..c18a3e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@simply-static/static-studio", - "version": "0.1.4", + "version": "0.1.5", "description": "Command-line interface for Static Studio hosting workflows.", "license": "MIT", "type": "module", @@ -32,6 +32,7 @@ "files": [ "dist", "docs", + "CHANGELOG.md", "README.md" ], "publishConfig": { @@ -49,8 +50,6 @@ "prepublishOnly": "npm run typecheck && npm test" }, "dependencies": { - "@aws-sdk/client-s3": "^3.637.0", - "@aws-sdk/lib-storage": "^3.637.0", "@supabase/supabase-js": "^2.46.2", "commander": "^12.1.0" }, diff --git a/src/alerts.test.ts b/src/alerts.test.ts new file mode 100644 index 0000000..bef9410 --- /dev/null +++ b/src/alerts.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_SITE_ALERT_RECIPIENTS, + getSiteAlerts, + saveSiteAlerts, + sendTestSiteAlert, +} from "./alerts.js"; +import { createSupabaseMock } from "./test-utils.js"; + +const statusResult = { + configured: false, + setting: { + site_id: 12, + enabled: false, + uptime_enabled: true, + uptime_threshold_minutes: 10, + performance_enabled: true, + performance_threshold: 70, + seo_critical_enabled: true, + notify_on_recovery: true, + recipients: [], + }, + conditions: {}, + incidents: [], + events: [], + future_field: { supported: true }, +}; + +describe("getSiteAlerts", () => { + it("uses the numeric site-alerts status contract and preserves unknown fields", async () => { + const { supabase, functionCalls } = createSupabaseMock({}, statusResult); + + const result = await getSiteAlerts(supabase, "12"); + + expect(result).toEqual(statusResult); + expect(result.future_field).toEqual({ supported: true }); + expect(functionCalls).toEqual([ + { + name: "site-alerts", + body: { action: "status", site_id: 12 }, + }, + ]); + }); + + it.each([0, -1, 1.5, "site-1", Number.MAX_SAFE_INTEGER + 1])( + "rejects invalid Lagos site IDs (%s) before invoking the function", + async (siteId) => { + const { supabase, functionCalls } = createSupabaseMock({}); + + await expect(getSiteAlerts(supabase, siteId)).rejects.toThrow( + "siteId must be an integer between 1", + ); + expect(functionCalls).toEqual([]); + }, + ); +}); + +describe("saveSiteAlerts", () => { + it("normalizes recipients and sends a complete snake_case payload with Lagos defaults", async () => { + const { supabase, functionCalls } = createSupabaseMock({}, { + setting: statusResult.setting, + evaluation: null, + incidents: [], + events: [], + }); + + await saveSiteAlerts(supabase, 12, { + enabled: true, + recipients: [" OWNER@Example.test ", "owner@example.test"], + }); + + expect(functionCalls).toEqual([ + { + name: "site-alerts", + body: { + action: "save", + site_id: 12, + enabled: true, + uptime_enabled: true, + uptime_threshold_minutes: 10, + performance_enabled: true, + performance_threshold: 70, + seo_critical_enabled: true, + notify_on_recovery: true, + recipients: ["owner@example.test"], + }, + }, + ]); + }); + + it("maps every custom option to the exact setting fields", async () => { + const { supabase, functionCalls } = createSupabaseMock({}, { + setting: statusResult.setting, + evaluation: null, + incidents: [], + events: [], + }); + + await saveSiteAlerts(supabase, "12", { + enabled: false, + recipients: [], + uptimeEnabled: false, + uptimeThresholdMinutes: 1440, + performanceEnabled: false, + performanceThreshold: 1, + seoCriticalEnabled: false, + notifyOnRecovery: false, + }); + + expect(functionCalls[0]).toEqual({ + name: "site-alerts", + body: { + action: "save", + site_id: 12, + enabled: false, + uptime_enabled: false, + uptime_threshold_minutes: 1440, + performance_enabled: false, + performance_threshold: 1, + seo_critical_enabled: false, + notify_on_recovery: false, + recipients: [], + }, + }); + }); + + it("requires recipients and at least one active rule when enabled", async () => { + const { supabase, functionCalls } = createSupabaseMock({}); + + await expect(saveSiteAlerts(supabase, 12, { enabled: true })).rejects.toThrow( + "At least one alert recipient", + ); + await expect(saveSiteAlerts(supabase, 12, { + enabled: true, + recipients: ["owner@example.test"], + uptimeEnabled: false, + performanceEnabled: false, + seoCriticalEnabled: false, + })).rejects.toThrow("Enable at least one alert rule"); + expect(functionCalls).toEqual([]); + }); + + it("enforces both threshold ranges even when alerts are paused", async () => { + const { supabase, functionCalls } = createSupabaseMock({}); + + await expect(saveSiteAlerts(supabase, 12, { + enabled: false, + uptimeThresholdMinutes: 4, + })).rejects.toThrow("uptime threshold must be an integer between 5 and 1440"); + await expect(saveSiteAlerts(supabase, 12, { + enabled: false, + performanceThreshold: 101, + })).rejects.toThrow("performance threshold must be an integer between 1 and 100"); + expect(functionCalls).toEqual([]); + }); +}); + +describe("sendTestSiteAlert", () => { + it("normalizes and deduplicates recipients in the send_test payload", async () => { + const event = { id: "event-1", delivery_status: "sent", future_field: 1 }; + const { supabase, functionCalls } = createSupabaseMock({}, { event }); + + const result = await sendTestSiteAlert(supabase, "12", [ + " Alert@Example.test ", + "alert@example.test", + "second@example.test", + ]); + + expect(result.event).toEqual(event); + expect(functionCalls).toEqual([ + { + name: "site-alerts", + body: { + action: "send_test", + site_id: 12, + recipients: ["alert@example.test", "second@example.test"], + }, + }, + ]); + }); + + it("requires valid recipients and enforces the Lagos maximum", async () => { + const { supabase, functionCalls } = createSupabaseMock({}); + + await expect(sendTestSiteAlert(supabase, 12, [])).rejects.toThrow( + "At least one alert recipient", + ); + await expect(sendTestSiteAlert(supabase, 12, ["not-an-email"])).rejects.toThrow( + "Invalid email address", + ); + await expect(sendTestSiteAlert( + supabase, + 12, + Array.from( + { length: MAX_SITE_ALERT_RECIPIENTS + 1 }, + (_, index) => `person${index}@example.test`, + ), + )).rejects.toThrow("more than 10 unique addresses"); + expect(functionCalls).toEqual([]); + }); +}); diff --git a/src/alerts.ts b/src/alerts.ts new file mode 100644 index 0000000..16432ff --- /dev/null +++ b/src/alerts.ts @@ -0,0 +1,171 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { CliError } from "./errors.js"; +import { invokeFunction } from "./supabase.js"; +import { normalizeEmailList, parsePositiveInteger } from "./validation.js"; + +export const SITE_ALERT_ACTIONS = ["status", "save", "send_test"] as const; +export const MAX_SITE_ALERT_RECIPIENTS = 10; +export const SITE_ALERT_DEFAULTS = { + enabled: false, + uptimeEnabled: true, + uptimeThresholdMinutes: 10, + performanceEnabled: true, + performanceThreshold: 70, + seoCriticalEnabled: true, + notifyOnRecovery: true, +} as const; + +export type SiteAlertAction = (typeof SITE_ALERT_ACTIONS)[number]; +export type SiteAlertUnknownFields = Record; + +export interface SiteAlertSetting extends SiteAlertUnknownFields { + site_id: number; + enabled: boolean; + uptime_enabled: boolean; + uptime_threshold_minutes: number; + performance_enabled: boolean; + performance_threshold: number; + seo_critical_enabled: boolean; + notify_on_recovery: boolean; + recipients: string[]; + created_at?: string | null; + updated_at?: string | null; +} + +export interface SiteAlertCondition extends SiteAlertUnknownFields { + available?: boolean; + breached?: boolean; + currentValue?: unknown; + summary?: string; +} + +export interface SiteAlertIncident extends SiteAlertUnknownFields { + id?: string; + alert_type?: string; + status?: string; + summary?: string; + threshold_value?: unknown; + current_value?: unknown; + details?: SiteAlertUnknownFields; + triggered_at?: string | null; +} + +export interface SiteAlertEvent extends SiteAlertUnknownFields { + id?: string; + alert_type?: string; + event_type?: string; + severity?: string; + summary?: string; + recipients?: string[]; + delivery_status?: string; + delivered_at?: string | null; + error_message?: string | null; + created_at?: string | null; +} + +export interface SiteAlertsStatus extends SiteAlertUnknownFields { + configured: boolean; + setting: SiteAlertSetting; + conditions: Record; + incidents: SiteAlertIncident[]; + events: SiteAlertEvent[]; +} + +export interface SaveSiteAlertsOptions { + enabled: boolean; + recipients?: readonly string[]; + uptimeEnabled?: boolean; + uptimeThresholdMinutes?: number; + performanceEnabled?: boolean; + performanceThreshold?: number; + seoCriticalEnabled?: boolean; + notifyOnRecovery?: boolean; +} + +export interface SaveSiteAlertsResult extends SiteAlertUnknownFields { + setting: SiteAlertSetting; + evaluation: unknown; + incidents: SiteAlertIncident[]; + events: SiteAlertEvent[]; +} + +export interface SendTestSiteAlertResult extends SiteAlertUnknownFields { + event: SiteAlertEvent; +} + +function normalizeSiteId(siteId: string | number): number { + return parsePositiveInteger(siteId, "siteId", { min: 1 }); +} + +function normalizeRecipients( + recipients: readonly string[] | undefined, + required: boolean, +): string[] { + if (!Array.isArray(recipients) || recipients.length === 0) { + if (required) throw new CliError("At least one alert recipient is required."); + return []; + } + return normalizeEmailList([...recipients], { max: MAX_SITE_ALERT_RECIPIENTS }); +} + +export async function getSiteAlerts( + supabase: SupabaseClient, + siteId: string | number, +): Promise { + return invokeFunction(supabase, "site-alerts", { + action: "status", + site_id: normalizeSiteId(siteId), + }); +} + +export async function saveSiteAlerts( + supabase: SupabaseClient, + siteId: string | number, + options: SaveSiteAlertsOptions, +): Promise { + const enabled = options.enabled === true; + const uptimeEnabled = options.uptimeEnabled ?? SITE_ALERT_DEFAULTS.uptimeEnabled; + const performanceEnabled = options.performanceEnabled ?? SITE_ALERT_DEFAULTS.performanceEnabled; + const seoCriticalEnabled = options.seoCriticalEnabled ?? SITE_ALERT_DEFAULTS.seoCriticalEnabled; + const recipients = normalizeRecipients(options.recipients, enabled); + + if (enabled && !uptimeEnabled && !performanceEnabled && !seoCriticalEnabled) { + throw new CliError("Enable at least one alert rule."); + } + + const uptimeThresholdMinutes = parsePositiveInteger( + options.uptimeThresholdMinutes ?? SITE_ALERT_DEFAULTS.uptimeThresholdMinutes, + "uptime threshold", + { min: 5, max: 1440 }, + ); + const performanceThreshold = parsePositiveInteger( + options.performanceThreshold ?? SITE_ALERT_DEFAULTS.performanceThreshold, + "performance threshold", + { min: 1, max: 100 }, + ); + + return invokeFunction(supabase, "site-alerts", { + action: "save", + site_id: normalizeSiteId(siteId), + enabled, + uptime_enabled: uptimeEnabled, + uptime_threshold_minutes: uptimeThresholdMinutes, + performance_enabled: performanceEnabled, + performance_threshold: performanceThreshold, + seo_critical_enabled: seoCriticalEnabled, + notify_on_recovery: options.notifyOnRecovery ?? SITE_ALERT_DEFAULTS.notifyOnRecovery, + recipients, + }); +} + +export async function sendTestSiteAlert( + supabase: SupabaseClient, + siteId: string | number, + recipients: readonly string[], +): Promise { + return invokeFunction(supabase, "site-alerts", { + action: "send_test", + site_id: normalizeSiteId(siteId), + recipients: normalizeRecipients(recipients, true), + }); +} diff --git a/src/cli.ts b/src/cli.ts index 886bbc9..1b82caf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,22 +3,71 @@ import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { Command } from "commander"; import { getAccountUsage } from "./account.js"; +import { getSiteAlerts, saveSiteAlerts, sendTestSiteAlert } from "./alerts.js"; import { loginWithEmail, loginWithToken, logout } from "./auth.js"; import { getConfigPath } from "./config.js"; -import { addDomain, getHostnames, issueSsl, removeDomain, setPrimaryDomain } from "./domains.js"; import { + addDomain, + getHostnames, + issueSsl, + removeDomain, + setMountPath, + setPrimaryDomain, + transferDomain, +} from "./domains.js"; +import { + cancelExport, + getMigrationLog, + getRollbackStatus, + listProductionSiteReleases, + listRollbackSnapshots, + startRollback, +} from "./deployments.js"; +import { + connectEnvironmentDomain, createEnvironment, deleteEnvironment, disableEnvironments, enableEnvironments, getEnvironmentStatus, + issueEnvironmentSsl, + listEnvironmentHostnames, + removeEnvironmentDomains, } from "./environments.js"; import { CliError, cliErrorExitCode, cliErrorJson, cliErrorMessage } from "./errors.js"; +import { + MAX_SEO_FILE_BYTES, + applySeoFiles, + checkSitemaps, + deleteSeoFile, + disableIndexNow, + enableIndexNow, + getHealthOverview, + getIndexNowStatus, + listSeoFiles, + runHealthCheck, + saveSeoFile, + setIndexNowAutoSubmit, + submitIndexNow, +} from "./health.js"; import { getDebugLog } from "./logs.js"; import { printValue } from "./output.js"; import { getSiteStatistics, listPerformanceReports, runPerformanceTest } from "./performance.js"; import { confirm } from "./prompt.js"; import { deleteSshKey, getSshInfo, listBackups, queueBackup, queueSshKey } from "./queues.js"; +import { + createReportShare, + deleteReportSchedule, + disableReportSchedule, + generateReportPdf, + getReportById, + getSignedReportPdfUrl, + listReportSchedules, + listReportShares, + listReportsBySite, + revokeReportShare, + saveReportSchedule, +} from "./reports.js"; import { bulkCreateRedirects, createRedirect, @@ -50,7 +99,13 @@ import { assignTagToSite, createTag, deleteTag, listSiteTags, listTags, removeTa import { bulkInviteTeamMembers, listTeamMembers, parseEmailInputFile, removeTeamMember } from "./team.js"; import { uploadMigrationFile } from "./upload.js"; import { addExistingUser, inviteUser, listUsers, makeAdmin, removeUser } from "./users.js"; -import { normalizeEmailList, parsePositiveInteger } from "./validation.js"; +import { + assertReadableFileWithinLimit, + normalizeEmailList, + normalizeMountPath, + parsePositiveInteger, + requireAllowedValue, +} from "./validation.js"; import type { CommandGlobals } from "./types.js"; const program = new Command(); @@ -109,6 +164,25 @@ function redirectUpdateOptions(opts: ParsedOptions): { fromPath?: string; toPath }; } +function parseToggle(value: string, label: string): boolean { + const normalized = String(value || "").trim().toLowerCase(); + if (["on", "true", "yes", "1"].includes(normalized)) return true; + if (["off", "false", "no", "0"].includes(normalized)) return false; + throw new CliError(`${label} must be on or off.`); +} + +function readSecretEnvironmentVariable(name: string, label: string): string { + const variable = String(name || "").trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(variable)) { + throw new CliError(`${label} environment variable name is invalid.`); + } + const value = process.env[variable]; + if (value === undefined || value === "") { + throw new CliError(`${label} environment variable ${variable} is not set.`); + } + return value; +} + function printDebugLogResult(cmd: Command, result: Awaited>, outputFile?: string): void { if (globals(cmd).json) { print(cmd, result); @@ -279,20 +353,25 @@ sites .option("--name ", "site display name") .option("--subdomain ", "system-domain subdomain") .option("--tld ", "system-domain TLD", "onstatic.studio") - .option("--url ", "public site URL") - .option("--admin-url ", "WordPress admin URL") + .option("--url ", "managed Studio site URL") + .option("--mount-path ", "serve the static site below this URL path") .option("--bedrock", "create a Bedrock WordPress site") .option("--php-version ", "PHP version", "8.3") .option("--migration-file ", "upload a migration archive before queueing the site") .option("--allow-any-zip-name", "skip Static Studio backup ZIP filename check") .action(async (opts: ParsedOptions, cmd: Command) => { await withAuth(cmd, async ({ supabase, user }) => { + const phpVersion = requireAllowedValue( + String(opts.phpVersion || "8.3"), + ["7.4", "8.0", "8.1", "8.2", "8.3", "8.4"] as const, + "PHP version", + ); + const mountPath = normalizeMountPath(opts.mountPath); const seed = generateSiteSeed({ name: opts.name, subdomain: opts.subdomain, tld: opts.tld, url: opts.url, - adminUrl: opts.adminUrl, }); let upload: unknown = null; @@ -317,7 +396,8 @@ sites userId: user.id, bedrock: Boolean(opts.bedrock), hasMigration: Boolean(opts.migrationFile), - phpVersion: opts.phpVersion, + phpVersion, + mountPath, }); print(cmd, { ...result, upload }); @@ -418,6 +498,70 @@ sites }); }); +sites + .command("cancel-export ") + .description("cancel the site's active static export") + .option("-y, --yes", "skip confirmation") + .action(async (siteId: string, opts: ParsedOptions, cmd: Command) => { + if (!opts.yes && !(await confirm(`Cancel the active export for site ${siteId}?`))) { + throw new CliError("Cancelled.", 0); + } + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await cancelExport(supabase, siteId)); + }); + }); + +sites + .command("migration-log ") + .description("show the site's migration log") + .action(async (siteId: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + const result = await getMigrationLog(supabase, siteId); + print(cmd, globals(cmd).json ? result : result.content); + }); + }); + +sites + .command("releases ") + .description("list recent production releases") + .option("--limit ", "number of releases", "10") + .action(async (siteId: string, opts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await listProductionSiteReleases(supabase, siteId, Number(opts.limit))); + }); + }); + +sites + .command("rollback-status ") + .description("show rollback availability and state") + .action(async (siteId: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await getRollbackStatus(supabase, siteId)); + }); + }); + +sites + .command("rollback-snapshots ") + .description("list available rollback snapshots") + .action(async (siteId: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await listRollbackSnapshots(supabase, siteId)); + }); + }); + +sites + .command("rollback ") + .description("restore a static export snapshot") + .option("-y, --yes", "skip confirmation") + .action(async (siteId: string, snapshotId: string, opts: ParsedOptions, cmd: Command) => { + if (!opts.yes && !(await confirm(`Roll back site ${siteId} to snapshot ${snapshotId}?`))) { + throw new CliError("Cancelled.", 0); + } + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await startRollback(supabase, siteId, snapshotId)); + }); + }); + const domains = program.command("domains").description("manage domains and SSL"); domains @@ -459,11 +603,48 @@ domains }); domains - .command("issue-ssl ") + .command("mount [path]") + .description("set or clear the site's public mount path") + .action(async (siteId: string, domain: string, path: string | undefined, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await setMountPath(supabase, siteId, domain, path || "")); + }); + }); + +domains + .command("issue-ssl [domain]") .description("request SSL issuance for a domain") - .action(async (domain: string, _localOpts: ParsedOptions, cmd: Command) => { + .option("--site-id ", "site ID for the legacy single-domain syntax") + .action(async (siteOrDomain: string, domain: string | undefined, opts: ParsedOptions, cmd: Command) => { + const siteId = domain ? siteOrDomain : opts.siteId; + const hostname = domain || siteOrDomain; + if (!siteId) { + throw new CliError("A site ID is required. Use `domains issue-ssl `."); + } await withAuth(cmd, async ({ supabase }) => { - print(cmd, await issueSsl(supabase, domain)); + print(cmd, await issueSsl(supabase, siteId, hostname)); + }); + }); + +domains + .command("transfer ") + .description("move a custom domain between sites you administer") + .option("-y, --yes", "skip confirmation") + .action(async ( + sourceSiteId: string, + targetSiteId: string, + domain: string, + opts: ParsedOptions, + cmd: Command, + ) => { + if ( + !opts.yes && + !(await confirm(`Move ${domain} from site ${sourceSiteId} to site ${targetSiteId}?`)) + ) { + throw new CliError("Cancelled.", 0); + } + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await transferDomain(supabase, sourceSiteId, targetSiteId, domain)); }); }); @@ -482,13 +663,344 @@ account }); }); +const health = program.command("health").description("inspect and run Site Health checks"); + +health + .command("overview ") + .description("show health, deploy, issue, and release data for a site") + .option("--issue-limit ", "maximum open issues", "20") + .option("--result-limit ", "maximum recent check results", "50") + .option("--release-limit ", "maximum recent releases", "20") + .action(async (siteId: string, opts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await getHealthOverview(supabase, siteId, { + issueLimit: Number(opts.issueLimit), + resultLimit: Number(opts.resultLimit), + releaseLimit: Number(opts.releaseLimit), + })); + }); + }); + +health + .command("run ") + .description("run health checks for one or more sites") + .option("--checks ", "specific checks to run") + .action(async (siteIds: string[], opts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await runHealthCheck(supabase, siteIds, opts.checks)); + }); + }); + +const alerts = program.command("alerts").description("manage Site Health alerts"); + +alerts + .command("status ") + .description("show alert settings, conditions, incidents, and events") + .action(async (siteId: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await getSiteAlerts(supabase, siteId)); + }); + }); + +alerts + .command("save ") + .description("save Site Health alert settings") + .requiredOption("--enabled ", "turn alerts on or off") + .option("--recipients ", "alert recipient email addresses") + .option("--uptime ", "turn uptime alerts on or off") + .option("--uptime-threshold ", "downtime threshold in minutes", "10") + .option("--performance ", "turn performance alerts on or off") + .option("--performance-threshold ", "minimum PageSpeed score", "70") + .option("--seo-critical ", "turn critical SEO alerts on or off") + .option("--notify-on-recovery ", "turn recovery notices on or off") + .action(async (siteId: string, opts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await saveSiteAlerts(supabase, siteId, { + enabled: parseToggle(opts.enabled, "enabled"), + recipients: opts.recipients || [], + ...(opts.uptime === undefined ? {} : { uptimeEnabled: parseToggle(opts.uptime, "uptime") }), + uptimeThresholdMinutes: Number(opts.uptimeThreshold), + ...(opts.performance === undefined + ? {} + : { performanceEnabled: parseToggle(opts.performance, "performance") }), + performanceThreshold: Number(opts.performanceThreshold), + ...(opts.seoCritical === undefined + ? {} + : { seoCriticalEnabled: parseToggle(opts.seoCritical, "seo-critical") }), + ...(opts.notifyOnRecovery === undefined + ? {} + : { notifyOnRecovery: parseToggle(opts.notifyOnRecovery, "notify-on-recovery") }), + })); + }); + }); + +alerts + .command("test ") + .description("send a test Site Health alert") + .action(async (siteId: string, recipients: string[], _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await sendTestSiteAlert(supabase, siteId, recipients)); + }); + }); + +const seo = program.command("seo").description("manage root-level SEO files"); + +seo + .command("list ") + .description("list managed SEO files") + .action(async (siteId: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await listSeoFiles(supabase, siteId)); + }); + }); + +seo + .command("save ") + .description("create or update a root-level SEO file") + .option("--content ", "file content") + .option("--file ", "read content from a local file") + .option("--kind ", "SEO file kind") + .option("--mime-type ", "file MIME type") + .action(async (siteId: string, path: string, opts: ParsedOptions, cmd: Command) => { + if ((opts.content === undefined) === (opts.file === undefined)) { + throw new CliError("Provide exactly one of --content or --file."); + } + let content = String(opts.content ?? ""); + if (opts.file) { + await assertReadableFileWithinLimit(opts.file, MAX_SEO_FILE_BYTES); + content = await readFile(opts.file, "utf8"); + } + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await saveSeoFile(supabase, siteId, { + path, + content, + ...(opts.kind ? { fileKind: opts.kind } : {}), + ...(opts.mimeType ? { mimeType: opts.mimeType } : {}), + })); + }); + }); + +seo + .command("delete ") + .description("delete a managed SEO file") + .option("-y, --yes", "skip confirmation") + .action(async (siteId: string, path: string, opts: ParsedOptions, cmd: Command) => { + if (!opts.yes && !(await confirm(`Delete SEO file ${path} from site ${siteId}?`))) { + throw new CliError("Cancelled.", 0); + } + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await deleteSeoFile(supabase, siteId, path)); + }); + }); + +seo + .command("apply ") + .description("push all managed SEO files to the site's static storage") + .action(async (siteId: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await applySeoFiles(supabase, siteId)); + }); + }); + +seo + .command("check-sitemaps ") + .description("check common sitemap files on the public site") + .action(async (siteId: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await checkSitemaps(supabase, siteId)); + }); + }); + +const indexNow = program.command("indexnow").description("manage IndexNow submissions"); + +indexNow + .command("status ") + .description("show IndexNow settings and submission state") + .action(async (siteId: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await getIndexNowStatus(supabase, siteId)); + }); + }); + +indexNow + .command("enable ") + .description("enable IndexNow for a site") + .action(async (siteId: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await enableIndexNow(supabase, siteId)); + }); + }); + +indexNow + .command("disable ") + .description("disable IndexNow for a site") + .action(async (siteId: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await disableIndexNow(supabase, siteId)); + }); + }); + +indexNow + .command("auto-submit ") + .description("turn automatic post-release submission on or off") + .action(async (siteId: string, state: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await setIndexNowAutoSubmit(supabase, siteId, parseToggle(state, "state"))); + }); + }); + +indexNow + .command("submit ") + .description("submit the latest or a selected release to IndexNow") + .option("--release-id ", "release ID to submit") + .action(async (siteId: string, opts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await submitIndexNow(supabase, siteId, { + ...(opts.releaseId ? { releaseId: opts.releaseId } : {}), + })); + }); + }); + +const reports = program.command("reports").description("inspect and share Agency reports"); + +reports + .command("list ") + .description("list reports for a site") + .option("--limit ", "number of reports", "20") + .action(async (siteId: string, opts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await listReportsBySite(supabase, siteId, Number(opts.limit))); + }); + }); + +reports + .command("get ") + .description("show a stored report") + .action(async (reportId: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await getReportById(supabase, reportId)); + }); + }); + +reports + .command("generate-pdf ") + .description("generate and store a report PDF") + .action(async (reportId: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await generateReportPdf(supabase, reportId)); + }); + }); + +reports + .command("pdf-url ") + .description("create a short-lived signed URL for a report PDF") + .action(async (reportId: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + const url = await getSignedReportPdfUrl(supabase, reportId); + print(cmd, globals(cmd).json ? { url } : url); + }); + }); + +reports + .command("shares ") + .description("list client share links for a report") + .action(async (reportId: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await listReportShares(supabase, reportId)); + }); + }); + +reports + .command("share ") + .description("create a client share link") + .option("--recipient ", "recipient email") + .option("--password-env ", "read the optional share password from an environment variable") + .option("--expires-at ", "future expiration time") + .action(async (reportId: string, opts: ParsedOptions, cmd: Command) => { + const password = opts.passwordEnv + ? readSecretEnvironmentVariable(opts.passwordEnv, "Report password") + : undefined; + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await createReportShare(supabase, reportId, { + ...(opts.recipient ? { recipientEmail: opts.recipient } : {}), + ...(password ? { password } : {}), + ...(opts.expiresAt ? { expiresAt: opts.expiresAt } : {}), + })); + }); + }); + +reports + .command("revoke-share ") + .description("revoke a client share link") + .option("-y, --yes", "skip confirmation") + .action(async (reportId: string, shareId: string, opts: ParsedOptions, cmd: Command) => { + if (!opts.yes && !(await confirm(`Revoke report share ${shareId}?`))) { + throw new CliError("Cancelled.", 0); + } + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await revokeReportShare(supabase, reportId, shareId)); + }); + }); + +reports + .command("schedules [siteId]") + .description("list report schedules") + .action(async (siteId: string | undefined, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await listReportSchedules(supabase, siteId)); + }); + }); + +reports + .command("schedule ") + .description("create or update a weekly or monthly report schedule") + .option("--schedule-id ", "existing schedule UUID") + .option("--name ", "schedule name") + .option("--auto-share ", "turn automatic client sharing on or off") + .option("--recipient ", "automatic-share recipient") + .action(async (cadence: string, siteIds: string[], opts: ParsedOptions, cmd: Command) => { + const autoShare = opts.autoShare === undefined + ? undefined + : parseToggle(opts.autoShare, "auto-share"); + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await saveReportSchedule(supabase, siteIds, cadence, { + ...(opts.scheduleId ? { scheduleId: opts.scheduleId } : {}), + ...(opts.name ? { name: opts.name } : {}), + ...(autoShare === undefined ? {} : { autoShare }), + ...(opts.recipient ? { recipientEmail: opts.recipient } : {}), + })); + }); + }); + +reports + .command("disable-schedule ") + .description("disable a report schedule") + .action(async (scheduleId: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await disableReportSchedule(supabase, scheduleId)); + }); + }); + +reports + .command("delete-schedule ") + .description("delete a report schedule") + .option("-y, --yes", "skip confirmation") + .action(async (scheduleId: string, opts: ParsedOptions, cmd: Command) => { + if (!opts.yes && !(await confirm(`Delete report schedule ${scheduleId}?`))) { + throw new CliError("Cancelled.", 0); + } + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await deleteReportSchedule(supabase, scheduleId)); + }); + }); + const performance = program.command("performance").description("run and inspect performance data"); performance .command("run ") .description("run a PageSpeed and global TTFB performance test") .option("--force", "ignore cached PageSpeed report") - .option("--url ", "test a URL instead of the stored site URL") + .option("--url ", "deprecated; must match the stored site URL") .action(async (siteId: string, opts: ParsedOptions, cmd: Command) => { await withAuth(cmd, async ({ supabase }) => { print(cmd, await runPerformanceTest(supabase, siteId, { @@ -511,7 +1023,7 @@ performance .command("get ") .description("run a performance test and fetch CDN statistics") .option("--force", "ignore cached PageSpeed report") - .option("--url ", "test a URL instead of the stored site URL") + .option("--url ", "deprecated; must match the stored site URL") .action(async (siteId: string, opts: ParsedOptions, cmd: Command) => { await withAuth(cmd, async ({ supabase }) => { const [pagespeed, statistics] = await Promise.all([ @@ -612,6 +1124,58 @@ environments }); }); +environments + .command("domain-list ") + .description("list managed and custom hostnames for an environment") + .action(async (siteId: string, environment: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await listEnvironmentHostnames(supabase, siteId, environment)); + }); + }); + +environments + .command("domain-connect ") + .description("connect or replace an environment custom domain") + .action(async ( + siteId: string, + environment: string, + domain: string, + _localOpts: ParsedOptions, + cmd: Command, + ) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await connectEnvironmentDomain(supabase, siteId, environment, domain)); + }); + }); + +environments + .command("domain-remove ") + .description("remove all custom domains from an environment") + .option("-y, --yes", "skip confirmation") + .action(async (siteId: string, environment: string, opts: ParsedOptions, cmd: Command) => { + if (!opts.yes && !(await confirm(`Remove all custom domains from environment ${environment}?`))) { + throw new CliError("Cancelled.", 0); + } + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await removeEnvironmentDomains(supabase, siteId, environment)); + }); + }); + +environments + .command("domain-ssl ") + .description("request SSL for an environment hostname") + .action(async ( + siteId: string, + environment: string, + domain: string, + _localOpts: ParsedOptions, + cmd: Command, + ) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await issueEnvironmentSsl(supabase, siteId, environment, domain)); + }); + }); + const tags = program.command("tags").description("manage account tags and site tag assignments"); tags diff --git a/src/deployments.test.ts b/src/deployments.test.ts new file mode 100644 index 0000000..a240997 --- /dev/null +++ b/src/deployments.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { + cancelExport, + getMigrationLog, + getRollbackStatus, + listProductionSiteReleases, + listRollbackSnapshots, + startRollback, +} from "./deployments.js"; +import { createSupabaseMock } from "./test-utils.js"; + +describe("deployment operations", () => { + it("invokes the Lagos cancel-export and migration-log contracts", async () => { + const { supabase, functionCalls } = createSupabaseMock( + {}, + (name) => name === "migration-log" + ? { content: "Migrating\nDone", line_count: 2 } + : { status: "cancelling" }, + ); + + await expect(cancelExport(supabase, "42")).resolves.toEqual({ status: "cancelling" }); + await expect(getMigrationLog(supabase, "42")).resolves.toEqual({ + content: "Migrating\nDone", + line_count: 2, + }); + + expect(functionCalls).toEqual([ + { name: "cancel-export", body: { site_id: "42" } }, + { name: "migration-log", body: { site_id: "42" } }, + ]); + }); + + it("lists only production releases newest first with a bounded limit", async () => { + const releases = [ + { id: "release-1", site_id: "42", release_id: "deploy-1", environment: "production", status: "successful" }, + ]; + const { supabase, calls } = createSupabaseMock({ + site_release: { data: releases, error: null }, + }); + + await expect(listProductionSiteReleases(supabase, "42", 25)).resolves.toEqual(releases); + expect(calls).toEqual([ + { table: "site_release", method: "select", args: ["*"] }, + { table: "site_release", method: "eq", args: ["site_id", "42"] }, + { table: "site_release", method: "eq", args: ["environment", "production"] }, + { table: "site_release", method: "order", args: ["created_at", { ascending: false }] }, + { table: "site_release", method: "limit", args: [25] }, + ]); + }); + + it("rejects release limits outside the supported range before querying", async () => { + const { supabase, calls } = createSupabaseMock({}); + + await expect(listProductionSiteReleases(supabase, "42", 101)).rejects.toThrow( + "limit must be an integer between 1 and 100", + ); + expect(calls).toEqual([]); + }); + + it("unwraps rollback status and exposes its available snapshot list", async () => { + const status = { + available: true, + snapshots: [{ id: "snapshot-1", snapshot_id: "snapshot-1" }], + status: null, + max_snapshots: 3, + error: null, + }; + const { supabase, functionCalls } = createSupabaseMock({}, { data: status, error: null }); + + await expect(getRollbackStatus(supabase, "42")).resolves.toEqual(status); + await expect(listRollbackSnapshots(supabase, "42")).resolves.toEqual(status.snapshots); + expect(functionCalls).toEqual([ + { name: "rollback-static-export", body: { site_id: "42", action: "status" } }, + { name: "rollback-static-export", body: { site_id: "42", action: "status" } }, + ]); + }); + + it("starts a rollback with validated site and snapshot IDs", async () => { + const { supabase, functionCalls } = createSupabaseMock( + {}, + { data: { rollback: { status: "running" } }, error: null }, + ); + + await expect(startRollback(supabase, "42", "snapshot-abc_123")).resolves.toEqual({ + rollback: { status: "running" }, + }); + expect(functionCalls).toEqual([ + { + name: "rollback-static-export", + body: { site_id: "42", snapshot_id: "snapshot-abc_123" }, + }, + ]); + }); + + it("does not invoke rollback for unsafe IDs", async () => { + const { supabase, functionCalls } = createSupabaseMock({}); + + await expect(startRollback(supabase, "42", "../../snapshot")).rejects.toThrow( + "snapshotId must contain only letters, numbers, underscores, and hyphens", + ); + expect(functionCalls).toEqual([]); + }); +}); diff --git a/src/deployments.ts b/src/deployments.ts new file mode 100644 index 0000000..be88a8d --- /dev/null +++ b/src/deployments.ts @@ -0,0 +1,135 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { CliError } from "./errors.js"; +import { invokeFunction } from "./supabase.js"; +import { assertSafeId, parsePositiveInteger } from "./validation.js"; + +const DEFAULT_RELEASE_LIMIT = 10; +const MAX_RELEASE_LIMIT = 100; + +export interface MigrationLogResult { + content: string; + line_count: number; +} + +export interface SiteReleaseRecord { + id: string; + site_id: string | number; + release_id: string; + environment: string; + status: string; + snapshot_id?: string | null; + deploy_id?: string | null; + created_at?: string; + [key: string]: unknown; +} + +export interface RollbackSnapshot { + id: string; + snapshot_id?: string; + deploy_id?: string; + [key: string]: unknown; +} + +export interface RollbackStatus { + available: boolean; + snapshots: RollbackSnapshot[]; + status: unknown; + max_snapshots: number; + error: string | null; + [key: string]: unknown; +} + +type FunctionEnvelope = { + data?: T; + error?: unknown; +}; + +function unwrapFunctionData(result: T | FunctionEnvelope): T { + if (result && typeof result === "object" && "data" in result) { + const data = (result as FunctionEnvelope).data; + if (data !== undefined) return data; + } + return result as T; +} + +export async function cancelExport( + supabase: SupabaseClient, + siteId: string, +): Promise { + return invokeFunction(supabase, "cancel-export", { + site_id: assertSafeId(siteId, "siteId"), + }); +} + +export async function getMigrationLog( + supabase: SupabaseClient, + siteId: string, +): Promise { + return invokeFunction(supabase, "migration-log", { + site_id: assertSafeId(siteId, "siteId"), + }); +} + +export async function listProductionSiteReleases( + supabase: SupabaseClient, + siteId: string, + limit: number = DEFAULT_RELEASE_LIMIT, +): Promise { + const safeSiteId = assertSafeId(siteId, "siteId"); + const safeLimit = parsePositiveInteger(limit, "limit", { + min: 1, + max: MAX_RELEASE_LIMIT, + }); + const { data, error } = await supabase + .from("site_release") + .select("*") + .eq("site_id", safeSiteId) + .eq("environment", "production") + .order("created_at", { ascending: false }) + .limit(safeLimit); + + if (error) throw new CliError(error.message); + return (data || []) as SiteReleaseRecord[]; +} + +export async function getRollbackStatus( + supabase: SupabaseClient, + siteId: string, +): Promise { + const result = await invokeFunction>( + supabase, + "rollback-static-export", + { + site_id: assertSafeId(siteId, "siteId"), + action: "status", + }, + ); + return unwrapFunctionData(result); +} + +export async function listRollbackSnapshots( + supabase: SupabaseClient, + siteId: string, +): Promise { + const status = await getRollbackStatus(supabase, siteId); + return Array.isArray(status.snapshots) ? status.snapshots : []; +} + +export async function startRollback( + supabase: SupabaseClient, + siteId: string, + snapshotId: string, +): Promise { + const result = await invokeFunction(supabase, "rollback-static-export", { + site_id: assertSafeId(siteId, "siteId"), + snapshot_id: assertSafeId(snapshotId, "snapshotId"), + }); + return result && typeof result === "object" && "data" in result + ? (result as FunctionEnvelope).data + : result; +} + +// Short aliases are useful for callers that already have a site-scoped context. +export const listSiteReleases = listProductionSiteReleases; +export const getRollbackSnapshots = listRollbackSnapshots; +export const rollbackStaticExport = startRollback; diff --git a/src/domains.test.ts b/src/domains.test.ts new file mode 100644 index 0000000..f806ccc --- /dev/null +++ b/src/domains.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it } from "vitest"; +import { + addDomain, + issueSsl, + removeDomain, + setMountPath, + transferDomain, +} from "./domains.js"; +import { createSupabaseMock } from "./test-utils.js"; + +function domainTables() { + return { + site: { + data: { + id: "site-1", + url: "https://demo.onstatic.studio", + status: "deployed", + pull_zone_id: "501", + mount_path: "/blog", + bedrock: false, + }, + error: null, + }, + site_meta: { + data: { + site_id: "site-1", + admin_url: "https://wp-demo.onstatic.studio/wp-admin", + secret_key: "site-secret", + basic_auth_user: "basic-user", + basic_auth_password: "basic-password", + }, + error: null, + }, + }; +} + +describe("domain mutations", () => { + it("uses the authenticated Lagos domain registry, mount-path, refresh, and export payloads", async () => { + const { supabase, functionCalls } = createSupabaseMock(domainTables(), (name, body) => { + if (name === "domain-actions") { + return (body as { action?: string }).action === "lookup" + ? { conflict: null } + : { registered: true }; + } + if (name === "connect-domain") { + return { + wordpress: { attempted: true, updated: true, warning: null }, + secondary: { error: "The www hostname could not be added." }, + }; + } + if (name === "refresh-edge-rules") { + return { results: [{ siteId: "site-1", status: "success" }] }; + } + if (name === "export-site") return { data: { message: "Export started." } }; + return { ok: true }; + }); + + await expect(addDomain(supabase, "site-1", "Example.COM")).resolves.toMatchObject({ + domain: "example.com", + warnings: ["The www hostname could not be added."], + registry: { registered: true }, + }); + + expect(functionCalls).toEqual([ + { + name: "domain-actions", + body: { action: "lookup", site_id: "site-1", hostname: "example.com" }, + }, + { + name: "connect-domain", + body: { + pullZoneId: "501", + domain: "example.com", + siteId: "site-1", + secret_key: "site-secret", + site_url: "https://wp-demo.onstatic.studio", + basic_auth_user: "basic-user", + basic_auth_password: "basic-password", + bedrock: false, + mount_path: "/blog", + }, + }, + { + name: "domain-actions", + body: { action: "register", site_id: "site-1", hostname: "example.com" }, + }, + { + name: "refresh-edge-rules", + body: { siteId: "site-1", importExistingRedirects: true }, + }, + { + name: "export-site", + body: { site_id: "site-1", type: "export" }, + }, + ]); + }); + + it("stops before connecting when the domain registry reports a conflict", async () => { + const { supabase, functionCalls } = createSupabaseMock(domainTables(), (name) => + name === "domain-actions" + ? { conflict: { site_id: "site-2", site_name: "Existing Site" } } + : { ok: true }, + ); + + await expect(addDomain(supabase, "site-1", "example.com")).rejects.toThrow( + "already connected to Existing Site", + ); + expect(functionCalls).toEqual([ + { + name: "domain-actions", + body: { action: "lookup", site_id: "site-1", hostname: "example.com" }, + }, + ]); + }); + + it("does not switch public routing when WordPress rejects the domain update", async () => { + const { supabase, calls, functionCalls } = createSupabaseMock(domainTables(), (name, body) => { + if (name === "domain-actions") { + return (body as { action?: string }).action === "lookup" + ? { conflict: null } + : { registered: true }; + } + if (name === "connect-domain") { + return { + wordpress: { attempted: true, updated: false, warning: "WordPress is unavailable." }, + secondary: { error: null }, + }; + } + return { ok: true }; + }); + + await expect(addDomain(supabase, "site-1", "example.com")).rejects.toThrow( + "CDN hostname was connected, but WordPress could not update", + ); + expect(functionCalls.map((call) => call.name)).toEqual([ + "domain-actions", + "connect-domain", + "domain-actions", + ]); + expect(calls.some((call) => call.table === "site" && call.method === "update")).toBe(false); + }); + + it("treats a CDN removal warning as a failure and does not unregister the domain", async () => { + const { supabase, functionCalls } = createSupabaseMock(domainTables(), (name) => { + if (name === "get-hostnames") { + return { hostnames: ["demo.onstatic.studio", "example.com"] }; + } + if (name === "remove-domain") { + return { removal: { removed: false, warning: "Bunny rejected the removal." } }; + } + return { ok: true }; + }); + + await expect(removeDomain(supabase, "site-1", "example.com")).rejects.toThrow( + "Bunny rejected the removal.", + ); + expect(functionCalls.map((call) => call.name)).toEqual([ + "domain-actions", + "get-hostnames", + "remove-domain", + ]); + }); + + it("refuses to remove managed Studio hostnames", async () => { + const { supabase, functionCalls } = createSupabaseMock(domainTables()); + + await expect(removeDomain(supabase, "site-1", "demo.onstatic.studio")) + .rejects.toThrow("cannot be removed"); + expect(functionCalls).toEqual([]); + }); + + it("rejects ambiguous or malformed removal targets before invoking the backend", async () => { + const { supabase, functionCalls } = createSupabaseMock(domainTables()); + + for (const domain of [ + "example.com:8443", + "localhost", + "127.0.0.1", + "bad..example.com", + "-bad.example.com", + ]) { + await expect(removeDomain(supabase, "site-1", domain)).rejects.toThrow(); + } + expect(functionCalls).toEqual([]); + }); + + it("requires deployment to finish before changing domains", async () => { + const tables = domainTables(); + tables.site.data.status = "deploying"; + const { supabase, functionCalls } = createSupabaseMock(tables); + + await expect(addDomain(supabase, "site-1", "example.com")) + .rejects.toThrow("after this site finishes deploying"); + expect(functionCalls).toEqual([]); + }); +}); + +describe("issueSsl", () => { + it("includes the site ID required by the hardened SSL endpoint", async () => { + const { supabase, functionCalls } = createSupabaseMock({ + site: { data: { id: "site-1", status: "deployed" }, error: null }, + }); + + await issueSsl(supabase, "site-1", "HTTPS://Example.COM./", false); + + expect(functionCalls).toEqual([ + { + name: "issue-ssl", + body: { siteId: "site-1", domain: "example.com", includeSecondary: false }, + }, + ]); + }); +}); + +describe("setMountPath", () => { + it("supports an external SaaS domain without attaching it to the Studio CDN", async () => { + const { supabase, functionCalls } = createSupabaseMock(domainTables(), (name) => { + if (name === "connect-domain") { + return { wordpress: { attempted: true, updated: true, warning: null } }; + } + if (name === "refresh-edge-rules") { + return { results: [{ siteId: "site-1", status: "success" }] }; + } + if (name === "export-site") return { queued: true }; + return { ok: true }; + }); + + await expect(setMountPath(supabase, "site-1", "example.com", "/docs/")) + .resolves.toMatchObject({ domain: "example.com", mount_path: "/docs" }); + expect(functionCalls.map((call) => call.name)).toEqual([ + "connect-domain", + "refresh-edge-rules", + "export-site", + ]); + }); +}); + +describe("transferDomain", () => { + it("uses the authenticated domain transfer contract", async () => { + const { supabase, functionCalls } = createSupabaseMock({ + site: { data: { id: "site-1", status: "deployed" }, error: null }, + }, { transferred: true }); + + await transferDomain(supabase, "site-1", "site-2", "Example.COM"); + + expect(functionCalls).toEqual([ + { + name: "domain-actions", + body: { + action: "transfer", + source_site_id: "site-1", + site_id: "site-2", + hostname: "example.com", + }, + }, + { + name: "export-site", + body: { site_id: "site-2", type: "export" }, + }, + ]); + }); + + it("rejects a no-op transfer", async () => { + const { supabase, functionCalls } = createSupabaseMock({}); + + await expect(transferDomain(supabase, "site-1", "site-1", "example.com")) + .rejects.toThrow("must be different"); + expect(functionCalls).toEqual([]); + }); + + it("rejects managed hostnames before reading either site", async () => { + const { supabase, functionCalls, calls } = createSupabaseMock({}); + + await expect( + transferDomain(supabase, "site-1", "site-2", "demo.onstatic.studio"), + ).rejects.toThrow("Only custom domains"); + expect(functionCalls).toEqual([]); + expect(calls).toEqual([]); + }); +}); diff --git a/src/domains.ts b/src/domains.ts index 7239523..c4e1505 100644 --- a/src/domains.ts +++ b/src/domains.ts @@ -1,22 +1,115 @@ +import { isIP } from "node:net"; import type { SupabaseClient } from "@supabase/supabase-js"; import { CliError } from "./errors.js"; -import { invokeFunction } from "./supabase.js"; +import { refreshRedirectRules } from "./redirects.js"; import { exportSite, getSite, getSiteMeta } from "./sites.js"; +import { invokeFunction } from "./supabase.js"; +import type { SiteMetaRecord, SiteRecord } from "./types.js"; +import { assertSafeId, normalizeMountPath } from "./validation.js"; + +type DomainContext = { + siteId: string; + site: SiteRecord; + meta: SiteMetaRecord; + pullZoneId: string; + mountPath: string; + wordpressUrl: string; +}; + +export interface DomainMutationResult { + domain: string; + primary?: string | null; + removed?: string[]; + connect?: unknown; + refresh?: unknown; + registry?: unknown; + export?: unknown; + warnings?: string[]; +} + +function normalizeDomain(value: string): string { + const raw = String(value || "").trim(); + if (!raw) throw new CliError("Domain is required."); + + let url: URL; + try { + url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`); + } catch { + throw new CliError(`Invalid domain: ${value}`); + } + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password || + url.port || + (url.pathname !== "/" && url.pathname !== "") || + url.search || + url.hash + ) { + throw new CliError("Domain must be a hostname without credentials, a port, a path, a query, or a fragment."); + } + const hostname = url.hostname.toLowerCase().replace(/\.$/, ""); + const validHostname = hostname.length <= 253 && + isIP(hostname) === 0 && + /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(hostname); + if (!validHostname) { + throw new CliError(`Invalid domain: ${value}`); + } + return hostname; +} -function cleanHost(value: string): string { - return value.replace(/^https?:\/\//i, "").replace(/\/$/, ""); +function cleanHost(value: unknown): string { + const raw = String(value || "").trim(); + if (!raw) return ""; + try { + return new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`).hostname.toLowerCase(); + } catch { + return raw.replace(/^https?:\/\//i, "").split("/")[0]?.toLowerCase() || ""; + } +} + +function isManagedStudioHostname(hostname: string): boolean { + return hostname.endsWith(".onstatic.studio") || + hostname.endsWith(".static.studio") || + /\.static\d+\.studio$/.test(hostname); +} + +function isProviderHostname(hostname: string): boolean { + return hostname.endsWith(".b-cdn.net"); +} + +function assertDomainActionReady(site: SiteRecord): void { + if (site.status !== "deployed") { + throw new CliError("Domain actions are available after this site finishes deploying."); + } } function siteUrlFromAdminUrl(adminUrl: string): string { - return adminUrl.replace(/\/wp-admin\/?$/i, "").replace(/\/wp\/wp-admin\/?$/i, ""); + return adminUrl.replace(/\/wp\/wp-admin\/?$/i, "/wp").replace(/\/wp-admin\/?$/i, ""); } -function splitHost(host: string): { subdomain: string; tld: string } | null { - const firstDot = host.indexOf("."); - if (firstDot <= 0) return null; +function managedPublicHostFromAdminUrl(adminUrl: string): string { + return cleanHost(siteUrlFromAdminUrl(adminUrl).replace(/(https?:\/\/)wp[-.]/i, "$1")); +} + +async function domainContext(supabase: SupabaseClient, siteId: string): Promise { + const safeSiteId = assertSafeId(siteId, "siteId"); + const [site, meta] = await Promise.all([ + getSite(supabase, safeSiteId), + getSiteMeta(supabase, safeSiteId), + ]); + if (!site.pull_zone_id) throw new CliError("Site does not have a pull_zone_id yet."); + assertDomainActionReady(site); + if (!meta.admin_url || !meta.secret_key) { + throw new CliError("Site metadata is missing admin_url or secret_key."); + } return { - subdomain: host.slice(0, firstDot), - tld: host.slice(firstDot + 1), + siteId: safeSiteId, + site, + meta, + pullZoneId: String(site.pull_zone_id), + mountPath: normalizeMountPath(site.mount_path), + wordpressUrl: siteUrlFromAdminUrl(meta.admin_url), }; } @@ -28,154 +121,293 @@ export async function getHostnames( return data.hostnames || []; } -export async function addDomain( +async function domainAction( supabase: SupabaseClient, + action: "lookup" | "register" | "unregister", siteId: string, domain: string, -): Promise { - const site = await getSite(supabase, siteId); - const pullZoneId = site.pull_zone_id; - if (!pullZoneId) throw new CliError("Site does not have a pull_zone_id yet."); - const meta = await getSiteMeta(supabase, siteId); - if (!meta.admin_url || !meta.secret_key) { - throw new CliError("Site metadata is missing admin_url or secret_key."); +): Promise { + return invokeFunction(supabase, "domain-actions", { + action, + site_id: siteId, + hostname: domain, + }); +} + +function connectWarnings(result: any): string[] { + return [ + result?.wordpress?.warning, + result?.secondary?.error, + ].filter(Boolean).map(String); +} + +function wordpressUpdateFailure(result: any): string | null { + if (result?.wordpress?.warning) return String(result.wordpress.warning); + if (result?.wordpress && result.wordpress.updated !== true) { + return "WordPress did not confirm the domain update."; } + return null; +} - const siteUrl = siteUrlFromAdminUrl(meta.admin_url); - const connectData = await invokeFunction(supabase, "connect-domain", { - pullZoneId, +async function connectDomain( + supabase: SupabaseClient, + context: DomainContext, + domain: string, + options: { updateOnly?: boolean; includeSecondary?: boolean; mountPath?: string } = {}, +): Promise { + return invokeFunction(supabase, "connect-domain", { + pullZoneId: context.pullZoneId, domain, - secret_key: meta.secret_key, - site_url: siteUrl, - basic_auth_user: meta.basic_auth_user, - basic_auth_password: meta.basic_auth_password, - bedrock: Boolean(site.bedrock), + siteId: context.siteId, + secret_key: context.meta.secret_key, + site_url: context.wordpressUrl, + basic_auth_user: context.meta.basic_auth_user, + basic_auth_password: context.meta.basic_auth_password, + bedrock: Boolean(context.site.bedrock), + mount_path: options.mountPath ?? context.mountPath, + ...(options.updateOnly ? { updateOnly: true } : {}), + ...(options.includeSecondary === undefined ? {} : { includeSecondary: options.includeSecondary }), }); +} + +async function refreshDomains(supabase: SupabaseClient, siteId: string): Promise { + return refreshRedirectRules(supabase, siteId, { importExistingRedirects: true }); +} + +export async function addDomain( + supabase: SupabaseClient, + siteId: string, + domain: string, +): Promise { + const hostname = normalizeDomain(domain); + const context = await domainContext(supabase, siteId); + const ownership = await domainAction(supabase, "lookup", context.siteId, hostname); + if (ownership?.conflict) { + const conflict = ownership.conflict; + throw new CliError( + `${hostname} is already connected to ${conflict.site_name || `site ${conflict.site_id}`}. Remove or transfer it before continuing.`, + ); + } - const currentHost = cleanHost(String(site.url || "")); - const isStudioOrEmpty = - !currentHost || currentHost.endsWith(".onstatic.studio") || currentHost.endsWith(".static.studio"); - if (isStudioOrEmpty) { - const { error } = await supabase.from("site").update({ url: `https://${domain}` }).eq("id", siteId); + const connect = await connectDomain(supabase, context, hostname); + const wordpressFailure = wordpressUpdateFailure(connect); + if (wordpressFailure) { + await domainAction(supabase, "register", context.siteId, hostname); + throw new CliError( + `The CDN hostname was connected, but WordPress could not update the site URL: ${wordpressFailure}`, + ); + } + const currentHost = cleanHost(context.site.url); + if (!currentHost || isManagedStudioHostname(currentHost)) { + const { error } = await supabase.from("site").update({ url: `https://${hostname}` }).eq("id", context.siteId); if (error) throw new CliError(error.message); } - await refreshEdgeRulesForDomain(supabase, siteId, String(pullZoneId), domain, currentHost); - await exportSite(supabase, siteId); - return connectData; + const registry = await domainAction(supabase, "register", context.siteId, hostname); + const refresh = await refreshDomains(supabase, context.siteId); + const exported = await exportSite(supabase, context.siteId); + const warnings = connectWarnings(connect); + return { + domain: hostname, + connect, + refresh, + registry, + export: exported, + ...(warnings.length ? { warnings } : {}), + }; } export async function setPrimaryDomain( supabase: SupabaseClient, siteId: string, domain: string, -): Promise { - const site = await getSite(supabase, siteId); - const pullZoneId = site.pull_zone_id; - if (!pullZoneId) throw new CliError("Site does not have a pull_zone_id yet."); - const meta = await getSiteMeta(supabase, siteId); - if (!meta.admin_url || !meta.secret_key) { - throw new CliError("Site metadata is missing admin_url or secret_key."); +): Promise { + const hostname = normalizeDomain(domain); + const context = await domainContext(supabase, siteId); + const ownership = await domainAction(supabase, "lookup", context.siteId, hostname); + if (ownership?.conflict) { + throw new CliError(`${hostname} is already connected to another site.`); + } + const hostnames = (await getHostnames(supabase, context.pullZoneId)).map(cleanHost); + if (!hostnames.map((item) => item.toLowerCase()).includes(hostname)) { + throw new CliError(`${hostname} is not connected to this site's CDN zone.`); } - const connectData = await invokeFunction(supabase, "connect-domain", { - pullZoneId, - domain, - secret_key: meta.secret_key, - site_url: siteUrlFromAdminUrl(meta.admin_url), - basic_auth_user: meta.basic_auth_user, - basic_auth_password: meta.basic_auth_password, + const connect = await connectDomain(supabase, context, hostname, { updateOnly: true }); + const wordpressFailure = wordpressUpdateFailure(connect); + if (wordpressFailure) { + throw new CliError(`WordPress could not update the primary domain: ${wordpressFailure}`); + } + const { error } = await supabase + .from("site") + .update({ url: `https://${hostname}` }) + .eq("id", context.siteId); + if (error) throw new CliError(error.message); + + const registry = await domainAction(supabase, "register", context.siteId, hostname); + const refresh = await refreshDomains(supabase, context.siteId); + const exported = await exportSite(supabase, context.siteId); + const warnings = connectWarnings(connect); + return { + domain: hostname, + primary: hostname, + connect, + refresh, + registry, + export: exported, + ...(warnings.length ? { warnings } : {}), + }; +} + +export async function setMountPath( + supabase: SupabaseClient, + siteId: string, + publicDomain: string, + mountPath: string, +): Promise { + const hostname = normalizeDomain(publicDomain); + const normalizedMountPath = normalizeMountPath(mountPath); + const context = await domainContext(supabase, siteId); + const connect = await connectDomain(supabase, context, hostname, { updateOnly: true, - bedrock: Boolean(site.bedrock), + mountPath: normalizedMountPath, }); - - const { error } = await supabase.from("site").update({ url: `https://${domain}` }).eq("id", siteId); + const wordpressFailure = wordpressUpdateFailure(connect); + if (wordpressFailure) { + throw new CliError(`WordPress could not update the mount path: ${wordpressFailure}`); + } + const { error } = await supabase + .from("site") + .update({ url: `https://${hostname}`, mount_path: normalizedMountPath }) + .eq("id", context.siteId); if (error) throw new CliError(error.message); - await refreshEdgeRulesForDomain(supabase, siteId, String(pullZoneId), domain); - await exportSite(supabase, siteId); - return connectData; + const refresh = await refreshDomains(supabase, context.siteId); + const exported = await exportSite(supabase, context.siteId); + const warnings = connectWarnings(connect); + return { + domain: hostname, + primary: hostname, + mount_path: normalizedMountPath, + connect, + refresh, + export: exported, + ...(warnings.length ? { warnings } : {}), + }; } export async function removeDomain( supabase: SupabaseClient, siteId: string, domain: string, -): Promise<{ removed: string[]; primary: string | null }> { - const site = await getSite(supabase, siteId); - const pullZoneId = site.pull_zone_id; - if (!pullZoneId) throw new CliError("Site does not have a pull_zone_id yet."); - const meta = await getSiteMeta(supabase, siteId); - if (!meta.admin_url || !meta.secret_key) { - throw new CliError("Site metadata is missing admin_url or secret_key."); +): Promise { + const hostname = normalizeDomain(domain); + if (isManagedStudioHostname(hostname) || isProviderHostname(hostname)) { + throw new CliError("Managed Studio and CDN provider hostnames cannot be removed."); } - - const hostnames = await getHostnames(supabase, String(pullZoneId)); - const domainsToRemove = new Set([domain]); - if (domain.startsWith("www.")) { - domainsToRemove.add(domain.replace(/^www\./, "")); - } else { - domainsToRemove.add(`www.${domain}`); + const context = await domainContext(supabase, siteId); + await domainAction(supabase, "lookup", context.siteId, hostname); + const hostnames = (await getHostnames(supabase, context.pullZoneId)).map(cleanHost); + const domainsToRemove = new Set([ + hostname, + hostname.startsWith("www.") ? hostname.replace(/^www\./, "") : `www.${hostname}`, + ]); + const existing = [...domainsToRemove].filter((item) => hostnames.includes(item)); + if (existing.length === 0) { + throw new CliError(`${hostname} is not connected to this site's CDN zone.`); } - const existing = [...domainsToRemove].filter((hostname) => hostnames.includes(hostname)); - const remaining = hostnames.filter((hostname) => !domainsToRemove.has(hostname)); - const studioHostname = remaining.find( - (hostname) => hostname.endsWith(".onstatic.studio") || hostname.endsWith(".static.studio"), - ); - const remainingCustom = remaining.find( - (hostname) => !hostname.endsWith(".onstatic.studio") && !hostname.endsWith(".static.studio"), + const remaining = hostnames.filter((item) => !domainsToRemove.has(item)); + const managedFallback = remaining.find((item) => isManagedStudioHostname(item)); + const customFallback = remaining.find((item) => + !isManagedStudioHostname(item) && !isProviderHostname(item) ); + const currentPrimary = cleanHost(context.site.url); + const newPrimary = domainsToRemove.has(currentPrimary) + ? managedFallback || customFallback || managedPublicHostFromAdminUrl(String(context.meta.admin_url)) + : currentPrimary; - const currentPrimary = cleanHost(String(site.url || "")); - let newPrimary = currentPrimary; - if (domainsToRemove.has(currentPrimary)) { - newPrimary = studioHostname || remainingCustom || cleanHost(siteUrlFromAdminUrl(meta.admin_url)); - await supabase.from("site").update({ url: `https://${newPrimary}` }).eq("id", siteId); - } - - const oldUrl = siteUrlFromAdminUrl(meta.admin_url).replace(/(https?:\/\/)wp[-.]/, "$1"); - for (const hostname of existing) { - await invokeFunction(supabase, "remove-domain", { - pullZoneId, - domain: hostname, - site_url: siteUrlFromAdminUrl(meta.admin_url), - oldUrl, - secret_key: meta.secret_key, - basic_auth_user: meta.basic_auth_user, - basic_auth_password: meta.basic_auth_password, - bedrock: Boolean(site.bedrock), + for (const item of existing) { + const result = await invokeFunction<{ + removal?: { removed?: boolean; warning?: string | null }; + }>(supabase, "remove-domain", { + pullZoneId: context.pullZoneId, + domain: item, + siteId: context.siteId, + site_url: context.wordpressUrl, + oldUrl: `https://${newPrimary}`, + secret_key: context.meta.secret_key, + basic_auth_user: context.meta.basic_auth_user, + basic_auth_password: context.meta.basic_auth_password, + bedrock: Boolean(context.site.bedrock), + mount_path: context.mountPath, }); + if (result.removal?.removed !== true) { + throw new CliError(result.removal?.warning || `The CDN did not remove ${item}.`); + } } - await refreshEdgeRulesForDomain(supabase, siteId, String(pullZoneId), newPrimary); - return { removed: existing, primary: newPrimary || null }; -} + if (newPrimary && newPrimary !== currentPrimary) { + const { error } = await supabase + .from("site") + .update({ url: `https://${newPrimary}` }) + .eq("id", context.siteId); + if (error) throw new CliError(error.message); + } -export async function issueSsl(supabase: SupabaseClient, domain: string): Promise { - return invokeFunction(supabase, "issue-ssl", { domain }); + const registry = await domainAction(supabase, "unregister", context.siteId, hostname); + const refresh = await refreshDomains(supabase, context.siteId); + return { + domain: hostname, + removed: existing, + primary: newPrimary || null, + refresh, + registry, + }; } -async function refreshEdgeRulesForDomain( +export async function issueSsl( supabase: SupabaseClient, siteId: string, - pullZoneId: string, - newDomain: string, - fallbackHost?: string, -): Promise { - const hostnames = await getHostnames(supabase, pullZoneId); - const edgeHost = - hostnames.find((hostname) => hostname.endsWith(".onstatic.studio") || hostname.endsWith(".static.studio")) || - fallbackHost || - newDomain; - const split = splitHost(edgeHost); - if (!split) return; - - await invokeFunction(supabase, "create-edge-rules", { - pullZoneId, - subdomain: split.subdomain, - newDomain, - tld: split.tld, - siteId, + domain: string, + includeSecondary = true, +): Promise { + const safeSiteId = assertSafeId(siteId, "siteId"); + assertDomainActionReady(await getSite(supabase, safeSiteId)); + return invokeFunction(supabase, "issue-ssl", { + siteId: safeSiteId, + domain: normalizeDomain(domain), + includeSecondary, + }); +} + +export async function transferDomain( + supabase: SupabaseClient, + sourceSiteId: string, + targetSiteId: string, + domain: string, +): Promise { + const source = assertSafeId(sourceSiteId, "sourceSiteId"); + const target = assertSafeId(targetSiteId, "targetSiteId"); + const hostname = normalizeDomain(domain); + if (isManagedStudioHostname(hostname) || isProviderHostname(hostname)) { + throw new CliError("Only custom domains can be transferred between sites."); + } + if (source === target) { + throw new CliError("Source and target sites must be different."); + } + const [sourceSite, targetSite] = await Promise.all([ + getSite(supabase, source), + getSite(supabase, target), + ]); + assertDomainActionReady(sourceSite); + assertDomainActionReady(targetSite); + const transfer = await invokeFunction>(supabase, "domain-actions", { + action: "transfer", + source_site_id: source, + site_id: target, + hostname, }); + const exported = await exportSite(supabase, target); + return { ...transfer, export: exported }; } diff --git a/src/environments.test.ts b/src/environments.test.ts index 74d51e5..56272ca 100644 --- a/src/environments.test.ts +++ b/src/environments.test.ts @@ -1,7 +1,54 @@ import { describe, expect, it } from "vitest"; -import { createEnvironment, parentSubdomainFromAdminUrl } from "./environments.js"; +import { + connectEnvironmentDomain, + createEnvironment, + deleteEnvironment, + disableEnvironments, + issueEnvironmentSsl, + listEnvironmentHostnames, + parentSubdomainFromAdminUrl, + removeEnvironmentDomains, + resolveEnvironment, +} from "./environments.js"; import { createSupabaseMock } from "./test-utils.js"; +function environmentDomainTables(overrides: { + site?: Record; + environment?: Record; +} = {}) { + return { + site: { + data: { + id: "site-1", + status: "deployed", + bedrock: false, + ...overrides.site, + }, + error: null, + }, + site_meta: { + data: { + site_id: "site-1", + admin_url: "https://wp-demo.onstatic.studio/wp-admin", + secret_key: "site-secret", + }, + error: null, + }, + site_environment: { + data: [{ + id: "env-1", + site_id: "site-1", + name: "staging", + title: "Staging", + url: "https://demo-staging.onstatic.studio", + pull_zone_id: "701", + ...overrides.environment, + }], + error: null, + }, + }; +} + describe("parentSubdomainFromAdminUrl", () => { it("extracts parent subdomains from supported WordPress host styles", () => { expect(parentSubdomainFromAdminUrl("https://wp-demo.onstatic.studio/wp-admin")).toBe("demo"); @@ -29,6 +76,7 @@ describe("createEnvironment", () => { { name: "create-environment", body: { + site_id: "site-1", parent_subdomain: "demo", title: "Staging", secret_key: "secret", @@ -36,4 +84,403 @@ describe("createEnvironment", () => { }, ]); }); + + it("fails before invoking the backend when required site metadata is missing", async () => { + const { supabase, functionCalls } = createSupabaseMock({ + site_meta: { + data: { site_id: "site-1", admin_url: "https://wp-demo.onstatic.studio/wp-admin" }, + error: null, + }, + }); + + await expect(createEnvironment(supabase, "site-1", "Staging")).rejects.toThrow( + "missing admin_url or secret_key", + ); + expect(functionCalls).toEqual([]); + }); +}); + +describe("deleteEnvironment", () => { + it("includes site_id in the hardened delete payload", async () => { + const { supabase, functionCalls } = createSupabaseMock({ + site_meta: { + data: { + site_id: "site-1", + admin_url: "https://wp-demo.onstatic.studio/wp-admin", + secret_key: "secret", + }, + error: null, + }, + }); + + await deleteEnvironment(supabase, "site-1", "QA Review"); + + expect(functionCalls).toEqual([ + { + name: "delete-environment", + body: { + site_id: "site-1", + parent_subdomain: "demo", + name: "qa-review", + secret_key: "secret", + }, + }, + ]); + }); +}); + +describe("disableEnvironments", () => { + it("includes site_id for every child deletion and the final integration disable", async () => { + const { supabase, functionCalls } = createSupabaseMock({ + site: { data: { id: "site-1" }, error: null }, + site_meta: { + data: { + site_id: "site-1", + admin_url: "https://wp-demo.onstatic.studio/wp-admin", + secret_key: "secret", + }, + error: null, + }, + addon: { data: { id: "addon-1", environments: true }, error: null }, + site_environment: { + data: [{ name: "staging" }, { name: "qa" }], + error: null, + }, + }); + + await disableEnvironments(supabase, "site-1"); + + expect(functionCalls).toEqual([ + { + name: "delete-environment", + body: { + site_id: "site-1", + parent_subdomain: "demo", + name: "staging", + secret_key: "secret", + }, + }, + { + name: "delete-environment", + body: { + site_id: "site-1", + parent_subdomain: "demo", + name: "qa", + secret_key: "secret", + disable_integration: true, + }, + }, + ]); + }); +}); + +describe("resolveEnvironment", () => { + it("resolves an environment under its parent site by ID, name, or title", async () => { + const { supabase } = createSupabaseMock(environmentDomainTables()); + + await expect(resolveEnvironment(supabase, "site-1", "env-1")).resolves.toMatchObject({ id: "env-1" }); + await expect(resolveEnvironment(supabase, "site-1", "staging")).resolves.toMatchObject({ id: "env-1" }); + await expect(resolveEnvironment(supabase, "site-1", "Staging")).resolves.toMatchObject({ id: "env-1" }); + }); + + it("fails clearly when the environment is not assigned to the site", async () => { + const { supabase } = createSupabaseMock({ + site_environment: { data: [], error: null }, + }); + + await expect(resolveEnvironment(supabase, "site-1", "missing")).rejects.toThrow( + "Environment missing was not found for site site-1", + ); + }); +}); + +describe("listEnvironmentHostnames", () => { + it("loads hostnames from the environment pull zone and separates custom domains", async () => { + const { supabase, functionCalls } = createSupabaseMock( + environmentDomainTables(), + { hostnames: ["demo-staging.onstatic.studio", "preview.example.com"] }, + ); + + await expect(listEnvironmentHostnames(supabase, "site-1", "Staging")).resolves.toMatchObject({ + managedHostname: "demo-staging.onstatic.studio", + hostnames: ["demo-staging.onstatic.studio", "preview.example.com"], + customHostnames: ["preview.example.com"], + }); + expect(functionCalls).toEqual([ + { name: "get-hostnames", body: { pullZoneId: "701" } }, + ]); + }); + + it("recognizes numbered Static Studio cluster hostnames as managed", async () => { + const { supabase } = createSupabaseMock( + environmentDomainTables({ + environment: { url: "https://demo-staging.static1.studio" }, + }), + { hostnames: ["demo-staging.static1.studio", "preview.example.com"] }, + ); + + await expect(listEnvironmentHostnames(supabase, "site-1", "staging")).resolves.toMatchObject({ + managedHostname: "demo-staging.static1.studio", + hostnames: ["demo-staging.static1.studio", "preview.example.com"], + customHostnames: ["preview.example.com"], + }); + }); + + it("blocks domain operations until the parent site is deployed", async () => { + const { supabase, functionCalls } = createSupabaseMock( + environmentDomainTables({ site: { status: "deploying" } }), + ); + + await expect(listEnvironmentHostnames(supabase, "site-1", "staging")).rejects.toThrow( + "after the parent site finishes deploying", + ); + expect(functionCalls).toEqual([]); + }); + + it("requires both an environment pull zone and a managed Studio fallback", async () => { + const withoutZone = createSupabaseMock( + environmentDomainTables({ environment: { pull_zone_id: null } }), + ); + await expect(listEnvironmentHostnames(withoutZone.supabase, "site-1", "staging")).rejects.toThrow( + "missing a CDN pull zone", + ); + + const withoutFallback = createSupabaseMock( + environmentDomainTables({ environment: { url: "https://preview.example.com" } }), + ); + await expect(listEnvironmentHostnames(withoutFallback.supabase, "site-1", "staging")).rejects.toThrow( + "determine the environment Studio hostname", + ); + }); +}); + +describe("connectEnvironmentDomain", () => { + it("replaces the current custom domain and surfaces WordPress and edge-rule warnings", async () => { + let hostnameReads = 0; + const { supabase, functionCalls } = createSupabaseMock(environmentDomainTables(), (name) => { + if (name === "get-hostnames") { + hostnameReads += 1; + return hostnameReads === 1 + ? { hostnames: ["demo-staging.onstatic.studio", "old.example.com"] } + : { hostnames: ["demo-staging.onstatic.studio", "new.example.com"] }; + } + if (name === "remove-domain") return { removal: { removed: true, warning: null } }; + if (name === "connect-domain") { + return { wordpress: { warning: "WordPress could not switch immediately." } }; + } + if (name === "create-edge-rules") { + return { + warning: { + message: "Some default edge rules could not be created.", + rules: [{ details: "Trailing slash rule failed." }], + }, + }; + } + return { ok: true }; + }); + + await expect( + connectEnvironmentDomain(supabase, "site-1", "Staging", "NEW.Example.com"), + ).resolves.toMatchObject({ + domain: "new.example.com", + removed: ["old.example.com"], + customHostnames: ["new.example.com"], + warnings: [ + "WordPress could not switch immediately.", + "Some default edge rules could not be created.", + "Trailing slash rule failed.", + ], + }); + + expect(functionCalls).toEqual([ + { name: "get-hostnames", body: { pullZoneId: "701" } }, + { + name: "remove-domain", + body: { + pullZoneId: "701", + domain: "old.example.com", + siteId: "site-1", + environmentName: "staging", + }, + }, + { + name: "connect-domain", + body: { + pullZoneId: "701", + domain: "new.example.com", + includeSecondary: false, + siteId: "site-1", + environmentName: "staging", + secret_key: "site-secret", + site_url: "https://wp-demo.onstatic.studio", + bedrock: false, + }, + }, + { + name: "create-edge-rules", + body: { + pullZoneId: "701", + subdomain: "demo-staging", + tld: "onstatic.studio", + newDomain: "new.example.com", + }, + }, + { name: "get-hostnames", body: { pullZoneId: "701" } }, + ]); + }); + + it("requires remove-domain to confirm nested removal.removed before replacing", async () => { + const { supabase, functionCalls } = createSupabaseMock(environmentDomainTables(), (name) => { + if (name === "get-hostnames") { + return { hostnames: ["demo-staging.onstatic.studio", "old.example.com"] }; + } + if (name === "remove-domain") { + return { removal: { removed: false, warning: "Bunny kept the old hostname." } }; + } + return { ok: true }; + }); + + await expect( + connectEnvironmentDomain(supabase, "site-1", "staging", "new.example.com"), + ).rejects.toThrow("Bunny kept the old hostname."); + expect(functionCalls.map((call) => call.name)).toEqual(["get-hostnames", "remove-domain"]); + }); + + it("rejects unsafe and managed custom hostnames before invoking domain functions", async () => { + const { supabase, functionCalls } = createSupabaseMock(environmentDomainTables()); + + await expect( + connectEnvironmentDomain(supabase, "site-1", "staging", "example.com/path"), + ).rejects.toThrow("without credentials, a port, a path, a query, or a fragment"); + await expect( + connectEnvironmentDomain(supabase, "site-1", "staging", "example.com:8443"), + ).rejects.toThrow("without credentials, a port, a path, a query, or a fragment"); + await expect( + connectEnvironmentDomain(supabase, "site-1", "staging", "other.onstatic.studio"), + ).rejects.toThrow("cannot use a managed Studio hostname"); + expect(functionCalls).toEqual([]); + }); + + it("surfaces edge-rule failures instead of returning a partial success", async () => { + let hostnameReads = 0; + const { supabase } = createSupabaseMock(environmentDomainTables(), (name) => { + if (name === "get-hostnames") { + hostnameReads += 1; + return { hostnames: ["demo-staging.onstatic.studio"] }; + } + if (name === "connect-domain") return { wordpress: { warning: null } }; + if (name === "create-edge-rules") return { error: "Environment edge rules failed." }; + return { ok: true }; + }); + + await expect( + connectEnvironmentDomain(supabase, "site-1", "staging", "new.example.com"), + ).rejects.toThrow("Environment edge rules failed."); + expect(hostnameReads).toBe(1); + }); +}); + +describe("removeEnvironmentDomains", () => { + it("removes every custom hostname, restores WordPress to the managed fallback, and resets edge rules", async () => { + let hostnameReads = 0; + const { supabase, functionCalls } = createSupabaseMock(environmentDomainTables(), (name, body) => { + if (name === "get-hostnames") { + hostnameReads += 1; + return hostnameReads === 1 + ? { hostnames: ["demo-staging.onstatic.studio", "one.example.com", "two.example.com"] } + : { hostnames: ["demo-staging.onstatic.studio"] }; + } + if (name === "remove-domain") return { removal: { removed: true } }; + if (name === "connect-domain") { + expect(body).toMatchObject({ updateOnly: true }); + return { wordpress: { warning: "WordPress fallback queued." } }; + } + if (name === "create-edge-rules") return { message: "Rules reset." }; + return { ok: true }; + }); + + await expect(removeEnvironmentDomains(supabase, "site-1", "env-1")).resolves.toMatchObject({ + removed: ["one.example.com", "two.example.com"], + managedHostname: "demo-staging.onstatic.studio", + customHostnames: [], + warnings: ["WordPress fallback queued."], + }); + + expect(functionCalls).toEqual([ + { name: "get-hostnames", body: { pullZoneId: "701" } }, + { + name: "remove-domain", + body: { + pullZoneId: "701", + domain: "one.example.com", + siteId: "site-1", + environmentName: "staging", + }, + }, + { + name: "remove-domain", + body: { + pullZoneId: "701", + domain: "two.example.com", + siteId: "site-1", + environmentName: "staging", + }, + }, + { + name: "connect-domain", + body: { + pullZoneId: "701", + domain: "demo-staging.onstatic.studio", + updateOnly: true, + includeSecondary: false, + siteId: "site-1", + environmentName: "staging", + secret_key: "site-secret", + site_url: "https://wp-demo.onstatic.studio", + bedrock: false, + }, + }, + { + name: "create-edge-rules", + body: { + pullZoneId: "701", + subdomain: "demo-staging", + tld: "onstatic.studio", + }, + }, + { name: "get-hostnames", body: { pullZoneId: "701" } }, + ]); + }); +}); + +describe("issueEnvironmentSsl", () => { + it("verifies environment ownership and requests SSL without a secondary hostname", async () => { + const { supabase, functionCalls } = createSupabaseMock(environmentDomainTables(), (name) => + name === "get-hostnames" + ? { hostnames: ["demo-staging.onstatic.studio", "preview.example.com"] } + : { message: "SSL requested." }, + ); + + await issueEnvironmentSsl(supabase, "site-1", "staging", "Preview.Example.com"); + + expect(functionCalls).toEqual([ + { name: "get-hostnames", body: { pullZoneId: "701" } }, + { + name: "issue-ssl", + body: { domain: "preview.example.com", includeSecondary: false, siteId: "site-1" }, + }, + ]); + }); + + it("does not issue SSL for a hostname outside the environment pull zone", async () => { + const { supabase, functionCalls } = createSupabaseMock( + environmentDomainTables(), + { hostnames: ["demo-staging.onstatic.studio"] }, + ); + + await expect( + issueEnvironmentSsl(supabase, "site-1", "staging", "other.example.com"), + ).rejects.toThrow("is not connected to this environment's CDN zone"); + expect(functionCalls.map((call) => call.name)).toEqual(["get-hostnames"]); + }); }); diff --git a/src/environments.ts b/src/environments.ts index abf5442..939dc88 100644 --- a/src/environments.ts +++ b/src/environments.ts @@ -2,6 +2,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { CliError } from "./errors.js"; import { getSite, getSiteMeta } from "./sites.js"; import { invokeFunction } from "./supabase.js"; +import type { SiteMetaRecord, SiteRecord } from "./types.js"; import { assertSafeId, environmentSlug, normalizeEnvironmentTitle } from "./validation.js"; export interface EnvironmentRecord { @@ -21,6 +22,347 @@ export interface EnvironmentStatus { environments: EnvironmentRecord[]; } +export type EnvironmentReference = string | number; + +export interface EnvironmentHostnameState { + environment: EnvironmentRecord; + managedHostname: string; + hostnames: string[]; + customHostnames: string[]; +} + +export interface EnvironmentDomainMutationResult extends EnvironmentHostnameState { + domain?: string; + removed: string[]; + unchanged?: boolean; + connect?: unknown; + wordpress?: unknown; + edgeRules?: unknown; + warnings: string[]; +} + +type EnvironmentDomainContext = { + siteId: string; + site: SiteRecord; + meta: SiteMetaRecord; + environment: EnvironmentRecord; + environmentName: string; + pullZoneId: string; + managedHostname: string; + managedSubdomain: string; + managedTld: string; + wordpressUrl: string; +}; + +function normalizeHostname(value: unknown, label = "hostname"): string { + const raw = String(value ?? "").trim().replace(/[\u3002\uFF0E\uFF61]/g, "."); + if (!raw) throw new CliError(`${label} is required.`); + + let parsed: URL; + try { + parsed = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`); + } catch { + throw new CliError(`${label} must be a valid hostname.`); + } + if ( + !["http:", "https:"].includes(parsed.protocol) || + parsed.username || + parsed.password || + parsed.port || + (parsed.pathname !== "/" && parsed.pathname !== "") || + parsed.search || + parsed.hash + ) { + throw new CliError(`${label} must be a hostname without credentials, a port, a path, a query, or a fragment.`); + } + + const hostname = parsed.hostname.toLowerCase().replace(/\.$/, ""); + const valid = hostname.length <= 253 && + /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(hostname); + if (!valid) throw new CliError(`${label} must be a valid hostname.`); + return hostname; +} + +function isManagedStudioHostname(hostname: string): boolean { + return hostname.endsWith(".b-cdn.net") || + hostname.endsWith(".onstatic.studio") || + /\.static\d*\.studio$/.test(hostname); +} + +function managedEnvironmentParts(environment: EnvironmentRecord): { + hostname: string; + subdomain: string; + tld: string; +} { + const hostname = normalizeHostname(environment.url, "Environment managed hostname"); + const suffix = hostname.endsWith(".onstatic.studio") + ? ".onstatic.studio" + : hostname.match(/\.static\d*\.studio$/)?.[0]; + if (!suffix) { + throw new CliError("Could not determine the environment Studio hostname."); + } + const subdomain = hostname.slice(0, -suffix.length); + if (!subdomain) throw new CliError("Could not determine the environment Studio subdomain."); + return { hostname, subdomain, tld: suffix.slice(1) }; +} + +function wordpressUrlFromAdminUrl(adminUrl: string): string { + return adminUrl.replace(/\/wp-admin\/?$/i, "").replace(/\/$/, ""); +} + +function environmentLookupValue(value: EnvironmentReference): string { + const lookup = String(value ?? "").trim(); + if (!lookup || lookup.length > 200 || /[\u0000-\u001f\u007f]/.test(lookup)) { + throw new CliError("Environment identifier must be between 1 and 200 printable characters."); + } + return lookup; +} + +export async function resolveEnvironment( + supabase: SupabaseClient, + siteId: string, + reference: EnvironmentReference, +): Promise { + const safeSiteId = assertSafeId(siteId, "siteId"); + const lookup = environmentLookupValue(reference); + const lowerLookup = lookup.toLowerCase(); + const slugLookup = environmentSlug(lookup); + const { data, error } = await supabase + .from("site_environment") + .select("*") + .eq("site_id", safeSiteId) + .order("created_at", { ascending: true }); + if (error) throw new CliError(error.message); + + const environments = (data || []) as EnvironmentRecord[]; + const environment = environments.find((item) => String(item.id) === lookup) || + environments.find((item) => String(item.name || "").toLowerCase() === lowerLookup) || + environments.find((item) => String(item.name || "").toLowerCase() === slugLookup) || + environments.find((item) => String(item.title || "").toLowerCase() === lowerLookup); + if (!environment) { + throw new CliError(`Environment ${lookup} was not found for site ${safeSiteId}.`); + } + return environment; +} + +async function environmentDomainContext( + supabase: SupabaseClient, + siteId: string, + reference: EnvironmentReference, +): Promise { + const safeSiteId = assertSafeId(siteId, "siteId"); + const [site, meta, environment] = await Promise.all([ + getSite(supabase, safeSiteId), + getSiteMeta(supabase, safeSiteId), + resolveEnvironment(supabase, safeSiteId, reference), + ]); + if (site.status !== "deployed") { + throw new CliError("Environment domain actions are available after the parent site finishes deploying."); + } + if (!environment.pull_zone_id) { + throw new CliError("This environment is missing a CDN pull zone."); + } + if (!environment.name) { + throw new CliError("This environment is missing its WordPress environment name."); + } + if (!meta.admin_url || !meta.secret_key) { + throw new CliError("Site metadata is missing admin_url or secret_key."); + } + const managed = managedEnvironmentParts(environment); + return { + siteId: safeSiteId, + site, + meta, + environment, + environmentName: String(environment.name), + pullZoneId: String(environment.pull_zone_id), + managedHostname: managed.hostname, + managedSubdomain: managed.subdomain, + managedTld: managed.tld, + wordpressUrl: wordpressUrlFromAdminUrl(meta.admin_url), + }; +} + +async function getHostnamesForContext( + supabase: SupabaseClient, + context: EnvironmentDomainContext, +): Promise { + const result = await invokeFunction<{ hostnames?: unknown[] }>(supabase, "get-hostnames", { + pullZoneId: context.pullZoneId, + }); + const hostnames = [...new Set((Array.isArray(result.hostnames) ? result.hostnames : []) + .map((hostname) => normalizeHostname(hostname, "CDN hostname")))]; + return { + environment: context.environment, + managedHostname: context.managedHostname, + hostnames, + customHostnames: hostnames.filter((hostname) => !isManagedStudioHostname(hostname)), + }; +} + +export async function listEnvironmentHostnames( + supabase: SupabaseClient, + siteId: string, + reference: EnvironmentReference, +): Promise { + const context = await environmentDomainContext(supabase, siteId, reference); + return getHostnamesForContext(supabase, context); +} + +function wordpressConnectionBody(context: EnvironmentDomainContext) { + return { + siteId: context.siteId, + environmentName: context.environmentName, + secret_key: context.meta.secret_key, + site_url: context.wordpressUrl, + bedrock: Boolean(context.site.bedrock), + }; +} + +async function removeEnvironmentHostname( + supabase: SupabaseClient, + context: EnvironmentDomainContext, + hostname: string, +): Promise { + const result = await invokeFunction<{ + removal?: { removed?: boolean; warning?: string | null }; + }>(supabase, "remove-domain", { + pullZoneId: context.pullZoneId, + domain: hostname, + siteId: context.siteId, + environmentName: context.environmentName, + }); + if (result.removal?.removed !== true) { + throw new CliError(result.removal?.warning || `The CDN did not remove ${hostname}.`); + } + return result; +} + +function operationWarnings(result: any): string[] { + const warnings: string[] = []; + if (result?.wordpress?.warning) warnings.push(String(result.wordpress.warning)); + if (result?.secondary?.error) warnings.push(String(result.secondary.error)); + const edgeWarning = result?.warning; + if (typeof edgeWarning === "string") warnings.push(edgeWarning); + if (edgeWarning && typeof edgeWarning === "object") { + if (edgeWarning.message) warnings.push(String(edgeWarning.message)); + for (const rule of Array.isArray(edgeWarning.rules) ? edgeWarning.rules : []) { + const detail = rule?.details || rule?.error; + if (detail) warnings.push(String(detail)); + } + } + return warnings; +} + +async function syncEnvironmentEdgeRules( + supabase: SupabaseClient, + context: EnvironmentDomainContext, + customDomain?: string, +): Promise { + return invokeFunction(supabase, "create-edge-rules", { + pullZoneId: context.pullZoneId, + subdomain: context.managedSubdomain, + tld: context.managedTld, + ...(customDomain ? { newDomain: customDomain } : {}), + }); +} + +export async function connectEnvironmentDomain( + supabase: SupabaseClient, + siteId: string, + reference: EnvironmentReference, + domain: string, +): Promise { + const hostname = normalizeHostname(domain, "Custom domain"); + if (isManagedStudioHostname(hostname)) { + throw new CliError("Environment custom domains cannot use a managed Studio hostname."); + } + const context = await environmentDomainContext(supabase, siteId, reference); + const before = await getHostnamesForContext(supabase, context); + if (before.customHostnames.length === 1 && before.customHostnames[0] === hostname) { + return { ...before, domain: hostname, removed: [], unchanged: true, warnings: [] }; + } + + const removed: string[] = []; + for (const existing of before.customHostnames) { + await removeEnvironmentHostname(supabase, context, existing); + removed.push(existing); + } + + const connect = await invokeFunction(supabase, "connect-domain", { + pullZoneId: context.pullZoneId, + domain: hostname, + includeSecondary: false, + ...wordpressConnectionBody(context), + }); + const edgeRules = await syncEnvironmentEdgeRules(supabase, context, hostname); + const after = await getHostnamesForContext(supabase, context); + return { + ...after, + domain: hostname, + removed, + connect, + edgeRules, + warnings: [...operationWarnings(connect), ...operationWarnings(edgeRules)], + }; +} + +export async function removeEnvironmentDomains( + supabase: SupabaseClient, + siteId: string, + reference: EnvironmentReference, +): Promise { + const context = await environmentDomainContext(supabase, siteId, reference); + const before = await getHostnamesForContext(supabase, context); + const removed: string[] = []; + for (const hostname of before.customHostnames) { + await removeEnvironmentHostname(supabase, context, hostname); + removed.push(hostname); + } + if (removed.length === 0) { + return { ...before, removed, unchanged: true, warnings: [] }; + } + + const wordpress = await invokeFunction(supabase, "connect-domain", { + pullZoneId: context.pullZoneId, + domain: context.managedHostname, + updateOnly: true, + includeSecondary: false, + ...wordpressConnectionBody(context), + }); + const edgeRules = await syncEnvironmentEdgeRules(supabase, context); + const after = await getHostnamesForContext(supabase, context); + return { + ...after, + removed, + wordpress, + edgeRules, + warnings: [...operationWarnings(wordpress), ...operationWarnings(edgeRules)], + }; +} + +export async function issueEnvironmentSsl( + supabase: SupabaseClient, + siteId: string, + reference: EnvironmentReference, + domain: string, +): Promise { + const hostname = normalizeHostname(domain, "SSL hostname"); + const context = await environmentDomainContext(supabase, siteId, reference); + const state = await getHostnamesForContext(supabase, context); + if (!state.hostnames.includes(hostname)) { + throw new CliError(`${hostname} is not connected to this environment's CDN zone.`); + } + return invokeFunction(supabase, "issue-ssl", { + domain: hostname, + includeSecondary: false, + siteId: context.siteId, + }); +} + +export const connectEnvironmentCustomDomain = connectEnvironmentDomain; +export const removeEnvironmentCustomDomains = removeEnvironmentDomains; + export function parentSubdomainFromAdminUrl(adminUrl: string): string { const url = new URL(adminUrl); const parts = url.hostname.split("."); @@ -109,6 +451,7 @@ export async function createEnvironment( const cleanTitle = normalizeEnvironmentTitle(title); const context = await environmentContext(supabase, siteId); return invokeFunction(supabase, "create-environment", { + site_id: context.siteId, parent_subdomain: context.parentSubdomain, title: cleanTitle, secret_key: context.secretKey, @@ -128,6 +471,7 @@ export async function deleteEnvironment( } return invokeFunction(supabase, "delete-environment", { + site_id: context.siteId, parent_subdomain: context.parentSubdomain, name, secret_key: context.secretKey, @@ -153,6 +497,7 @@ export async function disableEnvironments( if (!name) continue; deleted.push( await invokeFunction(supabase, "delete-environment", { + site_id: context.siteId, parent_subdomain: context.parentSubdomain, name, secret_key: context.secretKey, @@ -163,6 +508,7 @@ export async function disableEnvironments( } else { deleted.push( await invokeFunction(supabase, "delete-environment", { + site_id: context.siteId, parent_subdomain: context.parentSubdomain, secret_key: context.secretKey, disable_integration: true, diff --git a/src/health.test.ts b/src/health.test.ts new file mode 100644 index 0000000..e82793e --- /dev/null +++ b/src/health.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_HEALTH_SITES_PER_RUN, + MAX_SEO_FILE_BYTES, + applySeoFiles, + checkSitemaps, + deleteSeoFile, + disableIndexNow, + enableIndexNow, + getHealthOverview, + getIndexNowStatus, + listSeoFiles, + normalizeSeoFilePath, + runHealthCheck, + saveSeoFile, + setIndexNowAutoSubmit, + submitIndexNow, +} from "./health.js"; +import { createSupabaseMock } from "./test-utils.js"; + +describe("getHealthOverview", () => { + it("loads the latest manifest, current health state, recent rows, and its diff", async () => { + const { supabase, calls } = createSupabaseMock((table) => { + if (table === "site_deploy_manifest") { + return { data: { id: "manifest-1", site_id: 12 }, error: null }; + } + if (table === "site_health_snapshot") { + return { data: { site_id: 12, overall_status: "healthy" }, error: null }; + } + if (table === "site_health_issue") { + return { data: [{ id: "issue-1", site_id: 12, status: "open" }], error: null }; + } + if (table === "site_health_result") { + return { data: [{ id: "result-1", site_id: 12 }], error: null }; + } + if (table === "site_release") { + return { data: [{ id: "release-1", site_id: 12 }], error: null }; + } + if (table === "site_deploy_diff") { + return { + data: { id: "diff-1", site_id: 12, to_manifest_id: "manifest-1" }, + error: null, + }; + } + return { data: null, error: null }; + }); + + const overview = await getHealthOverview(supabase, "12", { + issueLimit: 5, + resultLimit: 6, + releaseLimit: 7, + }); + + expect(overview).toMatchObject({ + siteId: "12", + latestManifest: { id: "manifest-1" }, + snapshot: { overall_status: "healthy" }, + openIssues: [{ id: "issue-1" }], + recentResults: [{ id: "result-1" }], + recentReleases: [{ id: "release-1" }], + latestDiff: { id: "diff-1" }, + }); + expect(calls).toContainEqual({ + table: "site_health_issue", + method: "eq", + args: ["status", "open"], + }); + expect(calls).toContainEqual({ table: "site_health_issue", method: "limit", args: [5] }); + expect(calls).toContainEqual({ table: "site_health_result", method: "limit", args: [6] }); + expect(calls).toContainEqual({ table: "site_release", method: "limit", args: [7] }); + expect(calls).toContainEqual({ + table: "site_deploy_diff", + method: "eq", + args: ["to_manifest_id", "manifest-1"], + }); + }); + + it("skips the diff query when no deploy manifest exists", async () => { + const { supabase, calls } = createSupabaseMock((table) => ({ + data: table === "site_deploy_manifest" ? null : [], + error: null, + })); + + const overview = await getHealthOverview(supabase, "12"); + + expect(overview.latestManifest).toBeNull(); + expect(overview.latestDiff).toBeNull(); + expect(calls.some((call) => call.table === "site_deploy_diff")).toBe(false); + }); + + it("rejects unbounded overview limits before querying", async () => { + const { supabase, calls } = createSupabaseMock({}); + + await expect(getHealthOverview(supabase, "12", { resultLimit: 101 })).rejects.toThrow( + "result limit must be an integer between 1 and 100", + ); + expect(calls).toEqual([]); + }); +}); + +describe("runHealthCheck", () => { + it("sends only allowed, deduplicated check types and site IDs", async () => { + const { supabase, functionCalls } = createSupabaseMock({}, { + checked: 2, + failed: 0, + results: [], + }); + + await runHealthCheck(supabase, ["12", "12", "13"], ["uptime", "ssl", "uptime"]); + + expect(functionCalls).toEqual([ + { + name: "site-health-check", + body: { + site_ids: ["12", "13"], + check_types: ["uptime", "ssl"], + }, + }, + ]); + }); + + it("uses the singular Edge Function contract when given one site ID", async () => { + const { supabase, functionCalls } = createSupabaseMock({}, { + checked: 1, + failed: 0, + results: [], + }); + + await runHealthCheck(supabase, "12"); + + expect(functionCalls[0]).toEqual({ + name: "site-health-check", + body: { site_id: "12" }, + }); + }); + + it("rejects unsupported checks and batches larger than the backend limit", async () => { + const { supabase, functionCalls } = createSupabaseMock({}); + + await expect(runHealthCheck(supabase, "12", ["uptime", "ping"])).rejects.toThrow( + "health check type must be one of", + ); + await expect( + runHealthCheck( + supabase, + Array.from({ length: MAX_HEALTH_SITES_PER_RUN + 1 }, (_, index) => String(index + 1)), + ), + ).rejects.toThrow("No more than 25 sites"); + expect(functionCalls).toEqual([]); + }); +}); + +describe("SEO file APIs", () => { + it("normalizes leading slashes but rejects nested or traversal paths", () => { + expect(normalizeSeoFilePath(" /robots.txt ")).toBe("robots.txt"); + expect(normalizeSeoFilePath("\\google123.html")).toBe("google123.html"); + expect(() => normalizeSeoFilePath("nested/robots.txt")).toThrow("root-level files"); + expect(() => normalizeSeoFilePath("robots..txt")).toThrow("root-level files"); + }); + + it("uses exact SEO Edge Function actions and request fields", async () => { + const { supabase, functionCalls } = createSupabaseMock({}, (_name, body) => { + const action = (body as { action?: string }).action; + if (action === "list") return { files: [{ site_id: 12, path: "robots.txt" }] }; + if (action === "save") return { file: { site_id: 12, path: "robots.txt" } }; + if (action === "delete") return { file: { site_id: 12, path: "robots.txt", status: "deleted" } }; + if (action === "apply") return { applied: 1, errors: [] }; + return { checks: [{ path: "sitemap.xml", url: "https://example.test/sitemap.xml", status: 200, ok: true, valid_xml: true }] }; + }); + + expect(await listSeoFiles(supabase, "12")).toHaveLength(1); + await saveSeoFile(supabase, "12", { + path: "/robots.txt", + content: "User-agent: *", + fileKind: "robots", + mimeType: "text/plain; charset=utf-8", + }); + await deleteSeoFile(supabase, "12", "/robots.txt"); + await applySeoFiles(supabase, "12"); + expect(await checkSitemaps(supabase, "12")).toHaveLength(1); + + expect(functionCalls).toEqual([ + { name: "seo-files", body: { action: "list", site_id: "12" } }, + { + name: "seo-files", + body: { + action: "save", + site_id: "12", + path: "robots.txt", + content: "User-agent: *", + file_kind: "robots", + mime_type: "text/plain; charset=utf-8", + }, + }, + { name: "seo-files", body: { action: "delete", site_id: "12", path: "robots.txt" } }, + { name: "seo-files", body: { action: "apply", site_id: "12" } }, + { name: "seo-files", body: { action: "check_sitemaps", site_id: "12" } }, + ]); + }); + + it("enforces the UTF-8 1 MiB limit before invoking the backend", async () => { + const { supabase, functionCalls } = createSupabaseMock({}); + const oversizedUnicodeContent = "é".repeat(Math.floor(MAX_SEO_FILE_BYTES / 2) + 1); + + await expect( + saveSeoFile(supabase, "12", { path: "robots.txt", content: oversizedUnicodeContent }), + ).rejects.toThrow("1 MiB"); + expect(functionCalls).toEqual([]); + }); +}); + +describe("IndexNow APIs", () => { + it("maps status and mutation helpers to the supported actions", async () => { + const { supabase, functionCalls } = createSupabaseMock({}, { + setting: null, + latest_release: null, + eligible_url_count: 0, + }); + + await getIndexNowStatus(supabase, "12"); + await enableIndexNow(supabase, "12"); + await disableIndexNow(supabase, "12"); + await setIndexNowAutoSubmit(supabase, "12", false); + await submitIndexNow(supabase, "12", { releaseId: "release-1" }); + + expect(functionCalls).toEqual([ + { name: "indexnow-submit", body: { action: "status", site_id: "12" } }, + { name: "indexnow-submit", body: { action: "enable", site_id: "12" } }, + { name: "indexnow-submit", body: { action: "disable", site_id: "12" } }, + { + name: "indexnow-submit", + body: { action: "set_auto_submit", site_id: "12", auto_submit: false }, + }, + { + name: "indexnow-submit", + body: { action: "submit", site_id: "12", release_id: "release-1" }, + }, + ]); + }); + + it("omits release_id when manually submitting the latest release", async () => { + const { supabase, functionCalls } = createSupabaseMock({}, { + setting: null, + latest_release: null, + eligible_url_count: 0, + }); + + await submitIndexNow(supabase, "12"); + + expect(functionCalls[0]).toEqual({ + name: "indexnow-submit", + body: { action: "submit", site_id: "12" }, + }); + }); +}); diff --git a/src/health.ts b/src/health.ts new file mode 100644 index 0000000..cdd814d --- /dev/null +++ b/src/health.ts @@ -0,0 +1,442 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { CliError } from "./errors.js"; +import { invokeFunction } from "./supabase.js"; +import { assertSafeId, parsePositiveInteger, requireAllowedValue } from "./validation.js"; + +export const HEALTH_CHECK_TYPES = [ + "uptime", + "response_time", + "ssl", + "dns", + "cdn", + "static_freshness", + "robots", + "sitemap", + "indexability", + "ai_readiness", +] as const; + +export const SEO_FILE_ACTIONS = ["list", "save", "delete", "apply", "check_sitemaps"] as const; +export const INDEXNOW_ACTIONS = ["status", "enable", "disable", "set_auto_submit", "submit"] as const; +export const MAX_HEALTH_SITES_PER_RUN = 25; +export const MAX_HEALTH_OVERVIEW_ROWS = 100; +export const MAX_SEO_FILE_BYTES = 1024 * 1024; + +export type HealthCheckType = (typeof HEALTH_CHECK_TYPES)[number]; +export type SeoFileAction = (typeof SEO_FILE_ACTIONS)[number]; +export type IndexNowAction = (typeof INDEXNOW_ACTIONS)[number]; +export type HealthRecord = Record; + +export interface SiteDeployManifest extends HealthRecord { + id: string; + site_id: string | number; + deploy_id?: string | null; + health_status?: string | null; + finished_at?: string | null; +} + +export interface SiteHealthSnapshot extends HealthRecord { + site_id: string | number; + overall_status?: string; + availability_status?: string; + freshness_status?: string; + seo_status?: string; + open_issue_count?: number; + last_checked_at?: string | null; +} + +export interface SiteHealthIssue extends HealthRecord { + id: string; + site_id: string | number; + pillar?: string; + check_type?: string; + severity?: string; + title?: string; + status?: string; + last_seen_at?: string; +} + +export interface SiteHealthResult extends HealthRecord { + id: string; + site_id: string | number; + pillar?: string; + check_type?: string; + status?: string; + checked_at?: string; +} + +export interface SiteRelease extends HealthRecord { + id: string; + site_id: string | number; + release_id?: string; + deploy_id?: string | null; + status?: string; + created_at?: string; +} + +export interface SiteDeployDiff extends HealthRecord { + id: string; + site_id: string | number; + from_manifest_id?: string | null; + to_manifest_id: string; + status?: string; + summary?: HealthRecord; + details?: HealthRecord; +} + +export interface HealthOverviewOptions { + issueLimit?: number; + resultLimit?: number; + releaseLimit?: number; +} + +export interface HealthOverview { + siteId: string; + latestManifest: SiteDeployManifest | null; + snapshot: SiteHealthSnapshot | null; + openIssues: SiteHealthIssue[]; + recentResults: SiteHealthResult[]; + recentReleases: SiteRelease[]; + latestDiff: SiteDeployDiff | null; +} + +export interface HealthCheckSiteResult extends HealthRecord { + site_id: string; + url?: string; + checks?: HealthCheckType[]; + errors?: Array<{ check_type: string; error: string }>; + error?: string; + alerts?: unknown; +} + +export interface HealthCheckResponse { + checked: number; + failed?: number; + results: HealthCheckSiteResult[]; +} + +export interface SeoFileRecord extends HealthRecord { + id?: string; + site_id: string | number; + path: string; + file_kind?: string; + mime_type?: string; + content_text?: string; + size?: number; + content_hash?: string | null; + status?: string; + last_pushed_at?: string | null; + last_push_status?: string | null; + last_push_error?: string | null; +} + +export interface SaveSeoFileOptions { + path: string; + content: string; + fileKind?: string; + mimeType?: string; +} + +export interface SeoFileApplyResult { + applied: number; + errors: string[]; +} + +export interface SitemapCheck extends HealthRecord { + path: string; + url: string; + status: number; + ok: boolean; + valid_xml: boolean; + bytes?: number; + error?: string; +} + +export interface IndexNowSetting extends HealthRecord { + id?: string; + site_id?: string | number; + enabled?: boolean; + auto_submit?: boolean; + key_path?: string; + key_location?: string | null; + endpoint?: string; + last_submitted_at?: string | null; + last_submission_status?: string | null; + last_submission_details?: HealthRecord; +} + +export interface IndexNowSubmission extends HealthRecord { + status?: string; + reason?: string; +} + +export interface IndexNowStatus extends HealthRecord { + setting: IndexNowSetting | null; + latest_release: SiteRelease | null; + eligible_url_count: number; + skipped_reason?: string; + submission?: IndexNowSubmission; +} + +export interface SubmitIndexNowOptions { + releaseId?: string; +} + +function throwQueryError(error: { message: string } | null, fallback: string): void { + if (error) throw new CliError(error.message || fallback); +} + +function overviewLimit(value: number | undefined, defaultValue: number, label: string): number { + return parsePositiveInteger(value ?? defaultValue, label, { + min: 1, + max: MAX_HEALTH_OVERVIEW_ROWS, + }); +} + +export async function getHealthOverview( + supabase: SupabaseClient, + siteId: string, + options: HealthOverviewOptions = {}, +): Promise { + const safeSiteId = assertSafeId(siteId, "siteId"); + const issueLimit = overviewLimit(options.issueLimit, 20, "issue limit"); + const resultLimit = overviewLimit(options.resultLimit, 50, "result limit"); + const releaseLimit = overviewLimit(options.releaseLimit, 20, "release limit"); + + const { data: manifestData, error: manifestError } = await supabase + .from("site_deploy_manifest") + .select("*") + .eq("site_id", safeSiteId) + .order("finished_at", { ascending: false }) + .limit(1) + .maybeSingle(); + throwQueryError(manifestError, "Could not load the latest deploy manifest."); + + const latestManifest = (manifestData || null) as SiteDeployManifest | null; + const diffQuery = latestManifest?.id + ? supabase + .from("site_deploy_diff") + .select("*") + .eq("to_manifest_id", latestManifest.id) + .maybeSingle() + : Promise.resolve({ data: null, error: null }); + + const [snapshotResult, issuesResult, resultsResult, releasesResult, diffResult] = await Promise.all([ + supabase + .from("site_health_snapshot") + .select("*") + .eq("site_id", safeSiteId) + .maybeSingle(), + supabase + .from("site_health_issue") + .select("*") + .eq("site_id", safeSiteId) + .eq("status", "open") + .order("last_seen_at", { ascending: false }) + .limit(issueLimit), + supabase + .from("site_health_result") + .select("*") + .eq("site_id", safeSiteId) + .order("checked_at", { ascending: false }) + .limit(resultLimit), + supabase + .from("site_release") + .select("*") + .eq("site_id", safeSiteId) + .order("created_at", { ascending: false }) + .limit(releaseLimit), + diffQuery, + ]); + + throwQueryError(snapshotResult.error, "Could not load the health snapshot."); + throwQueryError(issuesResult.error, "Could not load open health issues."); + throwQueryError(resultsResult.error, "Could not load recent health results."); + throwQueryError(releasesResult.error, "Could not load recent releases."); + throwQueryError(diffResult.error, "Could not load the latest deploy diff."); + + return { + siteId: safeSiteId, + latestManifest, + snapshot: (snapshotResult.data || null) as SiteHealthSnapshot | null, + openIssues: (issuesResult.data || []) as SiteHealthIssue[], + recentResults: (resultsResult.data || []) as SiteHealthResult[], + recentReleases: (releasesResult.data || []) as SiteRelease[], + latestDiff: (diffResult.data || null) as SiteDeployDiff | null, + }; +} + +function normalizeHealthCheckTypes(checkTypes: readonly string[] | undefined): HealthCheckType[] | undefined { + if (checkTypes === undefined) return undefined; + if (checkTypes.length === 0) { + throw new CliError("Choose at least one health check."); + } + + const normalized = checkTypes.map((value) => + requireAllowedValue(String(value || "").trim(), HEALTH_CHECK_TYPES, "health check type"), + ); + return [...new Set(normalized)]; +} + +export async function runHealthCheck( + supabase: SupabaseClient, + siteIdOrIds: string | readonly string[], + checkTypes?: readonly string[], +): Promise { + const types = normalizeHealthCheckTypes(checkTypes); + const body: { site_id?: string; site_ids?: string[]; check_types?: HealthCheckType[] } = {}; + + if (typeof siteIdOrIds !== "string") { + if (siteIdOrIds.length === 0) { + throw new CliError("Provide at least one siteId to check."); + } + if (siteIdOrIds.length > MAX_HEALTH_SITES_PER_RUN) { + throw new CliError(`No more than ${MAX_HEALTH_SITES_PER_RUN} sites can be checked at once.`); + } + body.site_ids = [...new Set(siteIdOrIds.map((siteId) => assertSafeId(siteId, "siteId")))]; + } else { + body.site_id = assertSafeId(siteIdOrIds, "siteId"); + } + + if (types) body.check_types = types; + return invokeFunction(supabase, "site-health-check", body); +} + +export function normalizeSeoFilePath(value: string): string { + const path = String(value || "") + .trim() + .replace(/\\/g, "/") + .replace(/^\/+/, ""); + + if (!path || path.includes("/") || path.includes("..") || path.includes("?") || path.includes("#")) { + throw new CliError("SEO files must be root-level files such as robots.txt or google123.html."); + } + return path; +} + +export function validateSeoFileContent(content: string): string { + const normalized = String(content ?? ""); + const bytes = new TextEncoder().encode(normalized).byteLength; + if (bytes > MAX_SEO_FILE_BYTES) { + throw new CliError(`SEO files must be ${MAX_SEO_FILE_BYTES} bytes (1 MiB) or smaller.`); + } + return normalized; +} + +export async function listSeoFiles( + supabase: SupabaseClient, + siteId: string, +): Promise { + const response = await invokeFunction<{ files?: SeoFileRecord[] }>(supabase, "seo-files", { + action: "list", + site_id: assertSafeId(siteId, "siteId"), + }); + return Array.isArray(response?.files) ? response.files : []; +} + +export async function saveSeoFile( + supabase: SupabaseClient, + siteId: string, + options: SaveSeoFileOptions, +): Promise { + const response = await invokeFunction<{ file?: SeoFileRecord }>(supabase, "seo-files", { + action: "save", + site_id: assertSafeId(siteId, "siteId"), + path: normalizeSeoFilePath(options.path), + content: validateSeoFileContent(options.content), + ...(options.fileKind === undefined ? {} : { file_kind: options.fileKind }), + ...(options.mimeType === undefined ? {} : { mime_type: options.mimeType }), + }); + + if (!response?.file) throw new CliError("SEO file save did not return a file record."); + return response.file; +} + +export async function deleteSeoFile( + supabase: SupabaseClient, + siteId: string, + path: string, +): Promise { + const response = await invokeFunction<{ file?: SeoFileRecord | null }>(supabase, "seo-files", { + action: "delete", + site_id: assertSafeId(siteId, "siteId"), + path: normalizeSeoFilePath(path), + }); + return response?.file || null; +} + +export async function applySeoFiles( + supabase: SupabaseClient, + siteId: string, +): Promise { + return invokeFunction(supabase, "seo-files", { + action: "apply", + site_id: assertSafeId(siteId, "siteId"), + }); +} + +export async function checkSitemaps( + supabase: SupabaseClient, + siteId: string, +): Promise { + const response = await invokeFunction<{ checks?: SitemapCheck[] }>(supabase, "seo-files", { + action: "check_sitemaps", + site_id: assertSafeId(siteId, "siteId"), + }); + return Array.isArray(response?.checks) ? response.checks : []; +} + +async function invokeIndexNow( + supabase: SupabaseClient, + siteId: string, + action: IndexNowAction, + extra: Record = {}, +): Promise { + return invokeFunction(supabase, "indexnow-submit", { + action, + site_id: assertSafeId(siteId, "siteId"), + ...extra, + }); +} + +export async function getIndexNowStatus( + supabase: SupabaseClient, + siteId: string, +): Promise { + return invokeIndexNow(supabase, siteId, "status"); +} + +export async function enableIndexNow( + supabase: SupabaseClient, + siteId: string, +): Promise { + return invokeIndexNow(supabase, siteId, "enable"); +} + +export async function disableIndexNow( + supabase: SupabaseClient, + siteId: string, +): Promise { + return invokeIndexNow(supabase, siteId, "disable"); +} + +export async function setIndexNowAutoSubmit( + supabase: SupabaseClient, + siteId: string, + autoSubmit: boolean, +): Promise { + if (typeof autoSubmit !== "boolean") throw new CliError("autoSubmit must be a boolean."); + return invokeIndexNow(supabase, siteId, "set_auto_submit", { auto_submit: autoSubmit }); +} + +export async function submitIndexNow( + supabase: SupabaseClient, + siteId: string, + options: SubmitIndexNowOptions = {}, +): Promise { + return invokeIndexNow(supabase, siteId, "submit", { + ...(options.releaseId === undefined + ? {} + : { release_id: assertSafeId(options.releaseId, "releaseId") }), + }); +} diff --git a/src/index.ts b/src/index.ts index 957d718..9997cfe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,18 @@ export * from "./auth.js"; export * from "./account.js"; +export * from "./alerts.js"; export * from "./config.js"; export * from "./constants.js"; export * from "./domains.js"; +export * from "./deployments.js"; export * from "./environments.js"; export * from "./errors.js"; +export * from "./health.js"; export * from "./logs.js"; export * from "./performance.js"; export * from "./queues.js"; export * from "./redirects.js"; +export * from "./reports.js"; export * from "./sites.js"; export * from "./tags.js"; export * from "./team.js"; diff --git a/src/performance.test.ts b/src/performance.test.ts index 5e071c2..c32fc5e 100644 --- a/src/performance.test.ts +++ b/src/performance.test.ts @@ -36,6 +36,20 @@ describe("runPerformanceTest", () => { await expect(runPerformanceTest(supabase, "site-1")).rejects.toThrow(CliError); }); + + it("rejects custom URLs because Lagos always tests the stored site URL", async () => { + const { supabase, functionCalls } = createSupabaseMock({ + site: { + data: { id: "site-1", url: "https://example.test" }, + error: null, + }, + }); + + await expect( + runPerformanceTest(supabase, "site-1", { url: "https://other.example.test" }), + ).rejects.toThrow("Custom performance URLs are no longer supported"); + expect(functionCalls).toEqual([]); + }); }); describe("getSiteStatistics", () => { diff --git a/src/performance.ts b/src/performance.ts index 4e37eb3..80986b6 100644 --- a/src/performance.ts +++ b/src/performance.ts @@ -51,13 +51,19 @@ export async function runPerformanceTest( ): Promise { const safeSiteId = assertSafeId(siteId, "siteId"); const site = await getSite(supabase, safeSiteId); - const rawUrl = options.url || String(site.url || ""); + const rawUrl = String(site.url || ""); if (!rawUrl) { throw new CliError("Site does not have a URL."); } + const canonicalUrl = assertPublicHttpUrl(rawUrl, "Site URL"); + if (options.url && assertPublicHttpUrl(options.url, "Test URL") !== canonicalUrl) { + throw new CliError( + "Custom performance URLs are no longer supported; Studio tests the site's stored URL.", + ); + } return invokeFunction(supabase, "pagespeed", { - url: assertPublicHttpUrl(rawUrl, "Site URL"), + url: canonicalUrl, site_id: safeSiteId, ...(options.force ? { force: true } : {}), }); diff --git a/src/redirects.test.ts b/src/redirects.test.ts index cbaaebf..f4c40c6 100644 --- a/src/redirects.test.ts +++ b/src/redirects.test.ts @@ -11,6 +11,10 @@ import { } from "./redirects.js"; import { createSupabaseMock } from "./test-utils.js"; +const refreshSuccess = { + results: [{ siteId: "site-1", status: "success" }], +}; + describe("bulkCreateRedirects", () => { it("validates redirect files before invoking the backend", async () => { const filePath = join(tmpdir(), `redirects-${Date.now()}.json`); @@ -31,7 +35,7 @@ describe("bulkCreateRedirects", () => { describe("updateRedirect", () => { it("updates DB-backed redirects and refreshes edge rules without importing stale CDN rules", async () => { - const { supabase, functionCalls } = createSupabaseMock({}); + const { supabase, functionCalls } = createSupabaseMock({}, refreshSuccess); await updateRedirect(supabase, "site-1", "10", { fromPath: " /old ", @@ -59,7 +63,7 @@ describe("updateRedirect", () => { }); it("requires at least one field to update", async () => { - const { supabase, functionCalls } = createSupabaseMock({}); + const { supabase, functionCalls } = createSupabaseMock({}, refreshSuccess); await expect(updateRedirect(supabase, "site-1", "10", {})).rejects.toThrow( "at least one redirect field", @@ -70,7 +74,7 @@ describe("updateRedirect", () => { describe("setRedirectActive", () => { it("updates the persisted redirect status", async () => { - const { supabase, functionCalls } = createSupabaseMock({}); + const { supabase, functionCalls } = createSupabaseMock({}, refreshSuccess); await setRedirectActive(supabase, "site-1", "10", false); @@ -87,7 +91,7 @@ describe("setRedirectActive", () => { describe("deleteRedirect", () => { it("deletes DB-backed redirects and refreshes edge rules without importing stale CDN rules", async () => { - const { supabase, functionCalls } = createSupabaseMock({}); + const { supabase, functionCalls } = createSupabaseMock({}, refreshSuccess); await deleteRedirect(supabase, "site-1", "10", { db: true }); @@ -120,7 +124,9 @@ describe("deleteRedirect", () => { (name) => name === "delete-redirect" ? { error: "Redirect not found or cannot be deleted." } - : { ok: true }, + : name === "refresh-edge-rules" + ? refreshSuccess + : { ok: true }, ); await expect(deleteRedirect(supabase, "site-1", "10")).rejects.toThrow( @@ -149,7 +155,9 @@ describe("deleteRedirect", () => { (name) => name === "delete-redirect" ? { error: "Redirect not found or cannot be deleted." } - : { ok: true }, + : name === "refresh-edge-rules" + ? refreshSuccess + : { ok: true }, ); await deleteRedirect(supabase, "site-1", "rule-1"); @@ -186,7 +194,7 @@ describe("deleteRedirect", () => { data: { id: "site-1", url: "https://example.test", pull_zone_id: "pull-1" }, error: null, }, - }); + }, refreshSuccess); await deleteRedirect(supabase, "site-1", "rule-1", { edgeRule: true }); @@ -212,7 +220,7 @@ describe("deleteRedirect", () => { describe("refreshRedirectRules", () => { it("passes through the importExistingRedirects option", async () => { - const { supabase, functionCalls } = createSupabaseMock({}); + const { supabase, functionCalls } = createSupabaseMock({}, refreshSuccess); await refreshRedirectRules(supabase, "site-1", { importExistingRedirects: true }); @@ -226,4 +234,47 @@ describe("refreshRedirectRules", () => { }, ]); }); + + it("surfaces per-site Lagos refresh failures instead of reporting success", async () => { + const { supabase } = createSupabaseMock({}, { + results: [{ + siteId: "site-1", + status: "partial", + error: "Could not update all rules", + defaultRuleErrors: ["Markdown rule failed"], + }], + }); + + await expect( + refreshRedirectRules(supabase, "site-1", { importExistingRedirects: true }), + ).rejects.toThrow("Could not update all rules; Markdown rule failed"); + }); + + it("rejects a success-shaped response that omits the requested site", async () => { + const { supabase } = createSupabaseMock({}, { message: "No sites found to refresh." }); + + await expect(refreshRedirectRules(supabase, "site-1")) + .rejects.toThrow("did not return a result for site site-1"); + }); +}); + +describe("legacy edge-rule failure handling", () => { + it("does not refresh after disable-redirect reports a warning response", async () => { + const { supabase, functionCalls } = createSupabaseMock( + { + site: { + data: { id: "site-1", url: "https://example.test", pull_zone_id: "pull-1" }, + error: null, + }, + }, + (name) => name === "disable-redirect" + ? { success: false, warning: { details: "Bunny kept the edge rule." } } + : { ok: true }, + ); + + await expect( + deleteRedirect(supabase, "site-1", "rule-1", { edgeRule: true }), + ).rejects.toThrow("Bunny kept the edge rule."); + expect(functionCalls.map((call) => call.name)).toEqual(["disable-redirect"]); + }); }); diff --git a/src/redirects.ts b/src/redirects.ts index 8400494..8d493fe 100644 --- a/src/redirects.ts +++ b/src/redirects.ts @@ -91,12 +91,33 @@ export async function refreshRedirectRules( options: RefreshRedirectRulesOptions = {}, ): Promise { const safeSiteId = assertSafeId(siteId, "siteId"); - return invokeFunction(supabase, "refresh-edge-rules", { + const result = await invokeFunction<{ + results?: Array<{ + siteId?: string | number; + status?: string; + error?: string; + defaultRuleErrors?: unknown[]; + redirectErrors?: unknown[]; + }>; + }>(supabase, "refresh-edge-rules", { siteId: safeSiteId, ...(options.importExistingRedirects === undefined ? {} : { importExistingRedirects: options.importExistingRedirects }), }); + const siteResult = result?.results?.find((item) => String(item.siteId) === safeSiteId); + if (!siteResult) { + throw new CliError(`Edge-rule refresh did not return a result for site ${safeSiteId}.`); + } + if (siteResult.status !== "success") { + const details = [ + siteResult.error, + ...(siteResult.defaultRuleErrors || []), + ...(siteResult.redirectErrors || []), + ].filter(Boolean).map(String).join("; "); + throw new CliError(`Edge-rule refresh ${siteResult.status || "failed"}${details ? `: ${details}` : "."}`); + } + return result; } export async function listRedirects( @@ -185,6 +206,10 @@ async function deleteLegacyEdgeRule( ruleId, siteId: safeSiteId, }); + if ((result as { success?: boolean; warning?: unknown })?.success === false) { + const warning = (result as { warning?: { error?: string; details?: string } }).warning; + throw new CliError(warning?.details || warning?.error || "The legacy edge rule could not be disabled."); + } await refreshRedirectRules(supabase, safeSiteId, { importExistingRedirects: true }); return result; } diff --git a/src/reports.test.ts b/src/reports.test.ts new file mode 100644 index 0000000..4eb45a1 --- /dev/null +++ b/src/reports.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from "vitest"; +import { + createReportShare, + deleteReportSchedule, + disableReportSchedule, + generateReportPdf, + getReportById, + getSignedReportPdfUrl, + listReportSchedules, + listReportsBySite, + listReportShares, + revokeReportShare, + saveReportSchedule, +} from "./reports.js"; +import { createSupabaseMock } from "./test-utils.js"; + +const REPORT_ID = "123e4567-e89b-42d3-a456-426614174000"; +const SHARE_ID = "223e4567-e89b-42d3-a456-426614174000"; +const SCHEDULE_ID = "323e4567-e89b-42d3-a456-426614174000"; + +describe("report queries", () => { + it("lists a site's reports newest first with a bounded limit", async () => { + const reports = [{ id: REPORT_ID, site_id: 42 }]; + const { supabase, calls } = createSupabaseMock({ + reports: { data: reports, error: null }, + }); + + await expect(listReportsBySite(supabase, "42", 50)).resolves.toEqual(reports); + expect(calls).toEqual([ + { table: "reports", method: "select", args: ["*"] }, + { table: "reports", method: "eq", args: ["site_id", 42] }, + { table: "reports", method: "order", args: ["created_at", { ascending: false }] }, + { table: "reports", method: "limit", args: [50] }, + ]); + }); + + it("rejects invalid limits and site IDs before querying", async () => { + const { supabase, calls } = createSupabaseMock({}); + + await expect(listReportsBySite(supabase, "not-a-site", 20)).rejects.toThrow("siteId"); + await expect(listReportsBySite(supabase, 42, 201)).rejects.toThrow( + "limit must be an integer between 1 and 200", + ); + expect(calls).toEqual([]); + }); + + it("gets one report by its backend-compatible UUID", async () => { + const report = { id: REPORT_ID, site_id: 42 }; + const { supabase, calls } = createSupabaseMock({ + reports: { data: report, error: null }, + }); + + await expect(getReportById(supabase, REPORT_ID)).resolves.toEqual(report); + expect(calls).toEqual([ + { table: "reports", method: "select", args: ["*"] }, + { table: "reports", method: "eq", args: ["id", REPORT_ID] }, + { table: "reports", method: "maybeSingle", args: [] }, + ]); + }); + + it("rejects malformed report IDs without querying", async () => { + const { supabase, calls } = createSupabaseMock({}); + + await expect(getReportById(supabase, "report-1")).rejects.toThrow("valid UUID"); + expect(calls).toEqual([]); + }); +}); + +describe("report PDF actions", () => { + it("uses report-actions for user-authorized PDF generation and signed URLs", async () => { + const report = { id: REPORT_ID, site_id: 42, pdf_storage_path: "reports/demo.pdf" }; + const { supabase, functionCalls } = createSupabaseMock({}, (name, body) => { + const action = (body as { action?: string }).action; + return action === "generate_pdf" + ? { report } + : { signed_url: "https://storage.example.test/report.pdf?token=secret" }; + }); + + await expect(generateReportPdf(supabase, REPORT_ID)).resolves.toEqual(report); + await expect(getSignedReportPdfUrl(supabase, REPORT_ID)).resolves.toBe( + "https://storage.example.test/report.pdf?token=secret", + ); + expect(functionCalls).toEqual([ + { name: "report-actions", body: { action: "generate_pdf", report_id: REPORT_ID } }, + { name: "report-actions", body: { action: "signed_pdf_url", report_id: REPORT_ID } }, + ]); + }); + + it("never invokes an action for an invalid report ID", async () => { + const { supabase, functionCalls } = createSupabaseMock({}); + + await expect(generateReportPdf(supabase, "../../report")).rejects.toThrow("valid UUID"); + expect(functionCalls).toEqual([]); + }); +}); + +describe("report shares", () => { + it("lists, creates, and revokes shares using normalized optional fields", async () => { + const share = { id: SHARE_ID, report_id: REPORT_ID, site_id: 42 }; + const { supabase, functionCalls } = createSupabaseMock({}, (name, body) => { + const action = (body as { action?: string }).action; + if (action === "list_shares") return { shares: [share] }; + return { share }; + }); + + await expect(listReportShares(supabase, REPORT_ID)).resolves.toEqual([share]); + await expect(createReportShare(supabase, REPORT_ID, { + recipientEmail: " CLIENT@Example.com ", + password: " report-secret ", + expiresAt: "2099-01-02T03:04:05Z", + })).resolves.toEqual(share); + await expect(revokeReportShare(supabase, REPORT_ID, SHARE_ID)).resolves.toEqual(share); + + expect(functionCalls).toEqual([ + { name: "report-actions", body: { action: "list_shares", report_id: REPORT_ID } }, + { + name: "report-actions", + body: { + action: "create_share", + report_id: REPORT_ID, + recipient_email: "client@example.com", + password: "report-secret", + expires_at: "2099-01-02T03:04:05.000Z", + }, + }, + { + name: "report-actions", + body: { action: "revoke_share", report_id: REPORT_ID, share_id: SHARE_ID }, + }, + ]); + }); + + it("rejects invalid share inputs before invoking the backend", async () => { + const { supabase, functionCalls } = createSupabaseMock({}); + + await expect(createReportShare(supabase, REPORT_ID, { + recipientEmail: "not-an-email", + })).rejects.toThrow("Invalid email address"); + await expect(createReportShare(supabase, REPORT_ID, { + expiresAt: "yesterday-ish", + })).rejects.toThrow("valid date and time"); + await expect(revokeReportShare(supabase, REPORT_ID, "share-1")).rejects.toThrow("valid UUID"); + expect(functionCalls).toEqual([]); + }); +}); + +describe("report schedules", () => { + it("lists all schedules or filters by a validated site ID", async () => { + const schedule = { id: SCHEDULE_ID, site_id: 42, cadence: "monthly" }; + const { supabase, functionCalls } = createSupabaseMock({}, { schedules: [schedule] }); + + await expect(listReportSchedules(supabase)).resolves.toEqual([schedule]); + await expect(listReportSchedules(supabase, "42")).resolves.toEqual([schedule]); + expect(functionCalls).toEqual([ + { name: "report-actions", body: { action: "list_schedules" } }, + { name: "report-actions", body: { action: "list_schedules", site_id: 42 } }, + ]); + }); + + it("saves a portfolio schedule with deduplicated numeric site IDs", async () => { + const schedule = { id: SCHEDULE_ID, site_id: 42, cadence: "monthly" }; + const { supabase, functionCalls } = createSupabaseMock({}, { schedule }); + + await expect(saveReportSchedule(supabase, ["42", 43, "42"], "MONTHLY", { + scheduleId: SCHEDULE_ID, + name: " Client Portfolio ", + autoShare: true, + recipientEmail: "CLIENT@example.com", + })).resolves.toEqual(schedule); + + expect(functionCalls).toEqual([ + { + name: "report-actions", + body: { + action: "save_schedule", + schedule_id: SCHEDULE_ID, + site_id: 42, + site_ids: [42, 43], + scope: "portfolio", + cadence: "monthly", + name: "Client Portfolio", + auto_share: true, + recipient_email: "client@example.com", + }, + }, + ]); + }); + + it("rejects quarterly because Lagos supports only weekly and monthly schedules", async () => { + const { supabase, functionCalls } = createSupabaseMock({}); + + await expect(saveReportSchedule(supabase, [42], "quarterly")).rejects.toThrow( + "cadence must be one of: weekly, monthly", + ); + await expect(saveReportSchedule(supabase, [42], "monthly", { + autoShare: true, + })).rejects.toThrow("recipientEmail is required"); + expect(functionCalls).toEqual([]); + }); + + it("disables by UUID and deletes by the backend site/cadence selector", async () => { + const schedule = { id: SCHEDULE_ID, site_id: 42, cadence: "weekly" }; + const { supabase, functionCalls } = createSupabaseMock({}, (name, body) => + (body as { action?: string }).action === "disable_schedule" + ? { schedule } + : { deleted: true, schedule_id: SCHEDULE_ID }, + ); + + await expect(disableReportSchedule(supabase, SCHEDULE_ID)).resolves.toEqual(schedule); + await expect(deleteReportSchedule(supabase, { siteId: "42", cadence: "weekly" })).resolves.toEqual({ + deleted: true, + schedule_id: SCHEDULE_ID, + }); + expect(functionCalls).toEqual([ + { + name: "report-actions", + body: { action: "disable_schedule", schedule_id: SCHEDULE_ID }, + }, + { + name: "report-actions", + body: { action: "delete_schedule", site_id: 42, cadence: "weekly" }, + }, + ]); + }); +}); diff --git a/src/reports.ts b/src/reports.ts new file mode 100644 index 0000000..7327280 --- /dev/null +++ b/src/reports.ts @@ -0,0 +1,326 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { CliError } from "./errors.js"; +import { invokeFunction } from "./supabase.js"; +import { + normalizeEmail, + parsePositiveInteger, + requireAllowedValue, +} from "./validation.js"; + +const DEFAULT_REPORT_LIMIT = 20; +const MAX_REPORT_LIMIT = 200; +const MAX_SCHEDULE_SITES = 100; +const REPORT_CADENCES = ["weekly", "monthly"] as const; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export type ReportCadence = (typeof REPORT_CADENCES)[number]; + +export interface ReportRecord { + id: string; + site_id: string | number; + report_kind?: string; + generation_status?: string; + outcome_status?: string; + pdf_storage_path?: string | null; + created_at?: string; + [key: string]: unknown; +} + +export interface ReportShareRecord { + id: string; + report_id: string; + site_id: string | number; + public_url?: string; + recipient_email?: string; + expires_at?: string | null; + revoked_at?: string | null; + [key: string]: unknown; +} + +export interface ReportScheduleRecord { + id: string; + site_id: string | number; + site_ids?: Array; + scope?: "site" | "portfolio"; + cadence: ReportCadence; + enabled?: boolean; + name?: string; + recipient_email?: string; + auto_share?: boolean; + [key: string]: unknown; +} + +export interface CreateReportShareOptions { + recipientEmail?: string; + password?: string; + expiresAt?: string; +} + +export interface SaveReportScheduleOptions { + scheduleId?: string; + name?: string; + autoShare?: boolean; + recipientEmail?: string; +} + +export type ReportScheduleSelector = + | string + | { scheduleId: string } + | { siteId: string | number; cadence: string }; + +function requiredUuid(value: unknown, label: string): string { + const id = String(value ?? "").trim(); + if (!UUID_PATTERN.test(id)) { + throw new CliError(`${label} must be a valid UUID.`); + } + return id; +} + +function normalizeCadence(value: string): ReportCadence { + return requireAllowedValue( + String(value || "").trim().toLowerCase(), + REPORT_CADENCES, + "cadence", + ); +} + +function normalizeSiteId(value: string | number, label = "siteId"): number { + return parsePositiveInteger(value, label, { min: 1 }); +} + +function normalizeSiteIds(values: Array): number[] { + if (!Array.isArray(values) || values.length === 0) { + throw new CliError("Provide at least one site ID."); + } + if (values.length > MAX_SCHEDULE_SITES) { + throw new CliError(`A report schedule cannot include more than ${MAX_SCHEDULE_SITES} sites.`); + } + return [...new Set(values.map((value) => normalizeSiteId(value, "site ID")))]; +} + +function normalizeOptionalTimestamp( + value: string | undefined, + label: string, + options: { future?: boolean } = {}, +): string | undefined { + if (value === undefined || String(value).trim() === "") return undefined; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new CliError(`${label} must be a valid date and time.`); + } + if (options.future && parsed.getTime() <= Date.now()) { + throw new CliError(`${label} must be in the future.`); + } + return parsed.toISOString(); +} + +function normalizeOptionalName(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const name = value.trim(); + if (!name) return undefined; + if (name.length > 200) { + throw new CliError("Schedule name must be 200 characters or fewer."); + } + return name; +} + +function responseField(value: T | null | undefined, label: string): T { + if (value === undefined || value === null) { + throw new CliError(`Report service did not return ${label}.`); + } + return value; +} + +function scheduleMutationBody(selector: ReportScheduleSelector): Record { + if (typeof selector === "string") { + return { schedule_id: requiredUuid(selector, "scheduleId") }; + } + if ("scheduleId" in selector) { + return { schedule_id: requiredUuid(selector.scheduleId, "scheduleId") }; + } + return { + site_id: normalizeSiteId(selector.siteId), + cadence: normalizeCadence(selector.cadence), + }; +} + +export async function listReportsBySite( + supabase: SupabaseClient, + siteId: string | number, + limit = DEFAULT_REPORT_LIMIT, +): Promise { + const safeSiteId = normalizeSiteId(siteId); + const safeLimit = parsePositiveInteger(limit, "limit", { + min: 1, + max: MAX_REPORT_LIMIT, + }); + const { data, error } = await supabase + .from("reports") + .select("*") + .eq("site_id", safeSiteId) + .order("created_at", { ascending: false }) + .limit(safeLimit); + + if (error) throw new CliError(error.message); + return (data || []) as ReportRecord[]; +} + +export async function getReportById( + supabase: SupabaseClient, + reportId: string, +): Promise { + const safeReportId = requiredUuid(reportId, "reportId"); + const { data, error } = await supabase + .from("reports") + .select("*") + .eq("id", safeReportId) + .maybeSingle(); + + if (error) throw new CliError(error.message); + if (!data) throw new CliError(`Report ${safeReportId} was not found.`); + return data as ReportRecord; +} + +export async function generateReportPdf( + supabase: SupabaseClient, + reportId: string, +): Promise { + const result = await invokeFunction<{ report?: ReportRecord }>(supabase, "report-actions", { + action: "generate_pdf", + report_id: requiredUuid(reportId, "reportId"), + }); + return responseField(result.report, "the generated report"); +} + +export async function getSignedReportPdfUrl( + supabase: SupabaseClient, + reportId: string, +): Promise { + const result = await invokeFunction<{ signed_url?: string | null }>(supabase, "report-actions", { + action: "signed_pdf_url", + report_id: requiredUuid(reportId, "reportId"), + }); + const signedUrl = responseField(result.signed_url, "a signed PDF URL"); + if (!/^https?:\/\//i.test(signedUrl)) { + throw new CliError("Report service returned an invalid signed PDF URL."); + } + return signedUrl; +} + +export async function listReportShares( + supabase: SupabaseClient, + reportId: string, +): Promise { + const result = await invokeFunction<{ shares?: ReportShareRecord[] }>(supabase, "report-actions", { + action: "list_shares", + report_id: requiredUuid(reportId, "reportId"), + }); + return Array.isArray(result.shares) ? result.shares : []; +} + +export async function createReportShare( + supabase: SupabaseClient, + reportId: string, + options: CreateReportShareOptions = {}, +): Promise { + const recipientEmail = options.recipientEmail === undefined + ? undefined + : normalizeEmail(options.recipientEmail); + const password = options.password?.trim(); + if (password && password.length > 256) { + throw new CliError("Report password must be 256 characters or fewer."); + } + const expiresAt = normalizeOptionalTimestamp(options.expiresAt, "expiresAt", { + future: true, + }); + const result = await invokeFunction<{ share?: ReportShareRecord }>(supabase, "report-actions", { + action: "create_share", + report_id: requiredUuid(reportId, "reportId"), + ...(recipientEmail ? { recipient_email: recipientEmail } : {}), + ...(password ? { password } : {}), + ...(expiresAt ? { expires_at: expiresAt } : {}), + }); + return responseField(result.share, "the created share link"); +} + +export async function revokeReportShare( + supabase: SupabaseClient, + reportId: string, + shareId: string, +): Promise { + const result = await invokeFunction<{ share?: ReportShareRecord }>(supabase, "report-actions", { + action: "revoke_share", + report_id: requiredUuid(reportId, "reportId"), + share_id: requiredUuid(shareId, "shareId"), + }); + return responseField(result.share, "the revoked share link"); +} + +export async function listReportSchedules( + supabase: SupabaseClient, + siteId?: string | number, +): Promise { + const result = await invokeFunction<{ schedules?: ReportScheduleRecord[] }>(supabase, "report-actions", { + action: "list_schedules", + ...(siteId === undefined ? {} : { site_id: normalizeSiteId(siteId) }), + }); + return Array.isArray(result.schedules) ? result.schedules : []; +} + +export async function saveReportSchedule( + supabase: SupabaseClient, + siteIds: Array, + cadence: string, + options: SaveReportScheduleOptions = {}, +): Promise { + const normalizedSiteIds = normalizeSiteIds(siteIds); + const normalizedCadence = normalizeCadence(cadence); + const recipientEmail = options.recipientEmail === undefined + ? undefined + : normalizeEmail(options.recipientEmail); + if (options.autoShare === true && !recipientEmail) { + throw new CliError("recipientEmail is required when automatic sharing is enabled."); + } + const name = normalizeOptionalName(options.name); + const scheduleId = options.scheduleId === undefined + ? undefined + : requiredUuid(options.scheduleId, "scheduleId"); + const result = await invokeFunction<{ schedule?: ReportScheduleRecord }>(supabase, "report-actions", { + action: "save_schedule", + ...(scheduleId ? { schedule_id: scheduleId } : {}), + site_id: normalizedSiteIds[0], + site_ids: normalizedSiteIds, + scope: normalizedSiteIds.length > 1 ? "portfolio" : "site", + cadence: normalizedCadence, + ...(name ? { name } : {}), + ...(options.autoShare === undefined ? {} : { auto_share: options.autoShare }), + ...(recipientEmail ? { recipient_email: recipientEmail } : {}), + }); + return responseField(result.schedule, "the saved schedule"); +} + +export async function disableReportSchedule( + supabase: SupabaseClient, + selector: ReportScheduleSelector, +): Promise { + const result = await invokeFunction<{ schedule?: ReportScheduleRecord }>(supabase, "report-actions", { + action: "disable_schedule", + ...scheduleMutationBody(selector), + }); + return responseField(result.schedule, "the disabled schedule"); +} + +export async function deleteReportSchedule( + supabase: SupabaseClient, + selector: ReportScheduleSelector, +): Promise<{ deleted: boolean; schedule_id: string }> { + return invokeFunction(supabase, "report-actions", { + action: "delete_schedule", + ...scheduleMutationBody(selector), + }); +} + +export const listReports = listReportsBySite; +export const getReport = getReportById; +export const getSignedPdfUrl = getSignedReportPdfUrl; diff --git a/src/sites.test.ts b/src/sites.test.ts index a9aaa6e..ebfdeef 100644 --- a/src/sites.test.ts +++ b/src/sites.test.ts @@ -3,9 +3,13 @@ import { DEFAULT_TLD } from "./constants.js"; import { CliError } from "./errors.js"; import { basicAuthCredentialsFromMeta, + clearCache, + createSite, generateSiteSeed, getMagicLoginLink, listSites, + normalizeManagedSiteUrl, + siteSummaryRows, sitePushModeToExportType, } from "./sites.js"; import { createSupabaseMock } from "./test-utils.js"; @@ -27,17 +31,28 @@ describe("generateSiteSeed", () => { }); }); - it("preserves explicit URLs", () => { + it("derives upload routing and the admin URL from explicit managed URLs", () => { const seed = generateSiteSeed({ name: "Example", - subdomain: "demo", - tld: "example.test", - url: "https://custom.example", - adminUrl: "https://wp.custom.example/wp-admin", + url: "https://custom.static1.studio", + adminUrl: "https://ignored.example/wp-admin", }); - expect(seed.url).toBe("https://custom.example"); - expect(seed.adminUrl).toBe("https://wp.custom.example/wp-admin"); + expect(seed).toMatchObject({ + subdomain: "custom", + tld: "static1.studio", + url: "https://custom.static1.studio", + adminUrl: "https://wp.custom.static1.studio/wp-admin", + }); + }); + + it("rejects custom hosts and mismatched upload subdomains before provisioning", () => { + expect(() => normalizeManagedSiteUrl("https://example.com")).toThrow("managed Studio"); + expect(() => generateSiteSeed({ + subdomain: "other", + url: "https://demo.onstatic.studio", + })).toThrow("must match"); + expect(() => generateSiteSeed({ name: " ".repeat(201) })).toThrow("Site name"); }); }); @@ -116,9 +131,204 @@ describe("getMagicLoginLink", () => { }, ]); }); + + it("uses the canonical static_site_record_id when a legacy alias also exists", async () => { + const { supabase, functionCalls } = createSupabaseMock((table) => { + if (table === "site") { + return { + data: { + id: "site-1", + static_site_id: "legacy-static-id", + static_site_record_id: "canonical-static-id", + }, + error: null, + }; + } + if (table === "site_meta") { + return { data: { site_id: "site-1", email: "person@example.com" }, error: null }; + } + return { data: null, error: null }; + }, { url: "https://wp.example.test/wp-admin" }); + + await getMagicLoginLink(supabase, "site-1", "person@example.com"); + + expect(functionCalls[0]).toEqual({ + name: "get-magic-link", + body: { + siteId: "site-1", + staticSiteId: "canonical-static-id", + email: "person@example.com", + }, + }); + }); +}); + +describe("createSite", () => { + const serverCredentials = { + site_id: "42", + email: "person@example.com", + username: "server-user", + password: "server-password", + basic_auth_user: "server-basic-user", + basic_auth_password: "server-basic-password", + admin_url: "https://wp-demo.onstatic.studio/wp-admin", + secret_key: "server-secret", + }; + + it("uses server-generated credentials and the reduced Lagos queue payload", async () => { + const { supabase, functionCalls } = createSupabaseMock( + { site_meta: { data: serverCredentials, error: null } }, + (name) => name === "create-site-record" + ? { site: { id: "42", name: "Example", url: "https://demo.onstatic.studio" } } + : { message: "Site queued" }, + ); + + await expect(createSite(supabase, { + name: "Example", + subdomain: "demo", + email: "person@example.com", + userId: "user-1", + mountPath: " /blog/ ", + hasMigration: true, + phpVersion: "8.4", + })).resolves.toMatchObject({ + site: { id: "42" }, + credentials: { + username: "server-user", + password: "server-password", + basic_auth_user: "server-basic-user", + basic_auth_password: "server-basic-password", + admin_url: "https://wp-demo.onstatic.studio/wp-admin", + secret_key: "server-secret", + }, + }); + + expect(functionCalls).toEqual([ + { + name: "create-site-record", + body: { + url: "https://demo.onstatic.studio", + name: "Example", + email: "person@example.com", + user_id: "user-1", + mount_path: "/blog", + }, + }, + { + name: "queue-site", + body: { + siteHasMigration: true, + site_id: "42", + apiVersion: 2, + phpVersion: "8.4", + }, + }, + ]); + }); + + it("reports when the server-created credential record is incomplete", async () => { + const { password: _password, ...incompleteCredentials } = serverCredentials; + const { supabase } = createSupabaseMock( + { site_meta: { data: incompleteCredentials, error: null } }, + (name) => name === "create-site-record" + ? { site: { id: "42", name: "Example", url: "https://demo.onstatic.studio" } } + : { message: "Site queued" }, + ); + + await expect(createSite(supabase, { + name: "Example", + subdomain: "demo", + email: "person@example.com", + userId: "user-1", + })).rejects.toThrow("password was missing from its server-generated credentials"); + }); + + it("rejects unsupported PHP versions before creating a site record", async () => { + const { supabase, functionCalls } = createSupabaseMock({}); + + await expect(createSite(supabase, { + name: "Example", + subdomain: "demo", + email: "person@example.com", + userId: "user-1", + phpVersion: "9.0", + })).rejects.toThrow("PHP version must be one of"); + expect(functionCalls).toEqual([]); + }); +}); + +describe("clearCache", () => { + it("resolves the site pull zone and sends the hardened pull_zone payload", async () => { + const { supabase, functionCalls } = createSupabaseMock({ + site: { data: { id: "site-1", pull_zone_id: 501 }, error: null }, + }); + + await clearCache(supabase, "site-1"); + + expect(functionCalls).toEqual([ + { name: "clear-cache", body: { pull_zone: 501 } }, + ]); + }); + + it("does not invoke cache clearing before a pull zone exists", async () => { + const { supabase, functionCalls } = createSupabaseMock({ + site: { data: { id: "site-1", pull_zone_id: null }, error: null }, + }); + + await expect(clearCache(supabase, "site-1")).rejects.toThrow("does not have a pull_zone_id"); + expect(functionCalls).toEqual([]); + }); +}); + +describe("siteSummaryRows", () => { + it("shows the mounted public URL while retaining the Studio base URL", () => { + expect(siteSummaryRows([{ + id: "site-1", + name: "Mounted Site", + url: "https://example.com/", + mount_path: "/blog/", + status: "deployed", + }])).toEqual([{ + id: "site-1", + name: "Mounted Site", + url: "https://example.com/blog", + base_url: "https://example.com/", + mount_path: "/blog", + status: "deployed", + created_at: undefined, + }]); + }); }); describe("listSites validation", () => { + it("keeps team sites visible while scoping metadata and quoting search filters", async () => { + const { supabase, calls } = createSupabaseMock((table) => + table === "subscription" + ? { data: null, error: null } + : { data: [], error: null, count: 0 }, + ); + + await listSites( + supabase, + { id: "user-1", email: "person@example.com" }, + { search: 'client "alpha"' }, + ); + + expect(calls).toContainEqual({ + table: "site", + method: "eq", + args: ["site_meta.email", "person@example.com"], + }); + expect(calls).toContainEqual({ + table: "site", + method: "or", + args: ['name.ilike."%client \\"alpha\\"%",url.ilike."%client \\"alpha\\"%"'], + }); + const select = calls.find((call) => call.table === "site" && call.method === "select"); + expect(String(select?.args[0])).toContain("site_meta(email"); + expect(String(select?.args[0])).not.toContain("site_meta!inner"); + }); + it("rejects unsafe sort fields before querying", async () => { const { supabase } = createSupabaseMock({ site: { data: [], error: null, count: 0 } }); await expect( diff --git a/src/sites.ts b/src/sites.ts index c4eca28..e502892 100644 --- a/src/sites.ts +++ b/src/sites.ts @@ -2,10 +2,11 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { DEFAULT_TLD, SUPABASE_API_VERSION } from "./constants.js"; import { CliError } from "./errors.js"; import { invokeFunction } from "./supabase.js"; -import { md5, randomAlphanumeric, randomAlpha, randomDomainWord, randomPassword, randomUsername } from "./random.js"; +import { randomAlpha, randomAlphanumeric, randomDomainWord } from "./random.js"; import type { SiteMetaRecord, SiteRecord } from "./types.js"; import { assertSafeId, + normalizeMountPath, parsePositiveInteger, requireAllowedValue, sanitizeSearchTerm, @@ -16,13 +17,61 @@ export interface CreateSiteOptions { subdomain?: string; tld?: string; url?: string; + /** @deprecated Studio derives the admin URL on the server. */ adminUrl?: string; email: string; userId: string; bedrock?: boolean; hasMigration?: boolean; phpVersion?: string; + /** @deprecated Studio derives account ownership from the authenticated user. */ ownerAccountId?: string | null; + mountPath?: string; +} + +const MANAGED_SITE_HOST_RE = /(?:\.onstatic\.studio|\.static\d*\.studio)$/; +const MANAGED_SUBDOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const PHP_VERSIONS = ["7.4", "8.0", "8.1", "8.2", "8.3", "8.4"] as const; + +export function normalizeManagedSiteUrl(value: string): string { + let url: URL; + try { + url = new URL(String(value || "").trim()); + } catch { + throw new CliError("Site URL must be a valid managed Studio HTTPS URL."); + } + const hostname = url.hostname.toLowerCase().replace(/\.$/, ""); + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.port || + (url.pathname !== "/" && url.pathname !== "") || + url.search || + url.hash || + !MANAGED_SITE_HOST_RE.test(hostname) + ) { + throw new CliError("New sites must use an HTTPS managed Studio hostname without a path, query, or credentials."); + } + return `https://${hostname}`; +} + +function normalizeManagedSubdomain(value: string): string { + const subdomain = String(value || "").trim().toLowerCase(); + if (!MANAGED_SUBDOMAIN_RE.test(subdomain)) { + throw new CliError( + "Site subdomain must be 1-63 lowercase letters, numbers, or hyphens and cannot start or end with a hyphen.", + ); + } + return subdomain; +} + +function normalizeSiteName(value: string | undefined): string { + const name = String(value || randomDomainWord()).trim(); + if (!name || name.length > 200 || /[\u0000-\u001f\u007f]/.test(name)) { + throw new CliError("Site name must be between 1 and 200 printable characters."); + } + return name; } export function generateSiteSeed(options: Partial = {}): { @@ -32,12 +81,24 @@ export function generateSiteSeed(options: Partial = {}): { url: string; adminUrl: string; } { - const tld = options.tld || DEFAULT_TLD; - const subdomain = options.subdomain || `${randomAlpha(2)}${randomAlphanumeric(13)}`; - const url = options.url || `https://${subdomain}.${tld}`; - const adminUrl = options.adminUrl || `https://wp-${subdomain}.${tld}/wp-admin`; + const requestedUrl = options.url ? normalizeManagedSiteUrl(options.url) : undefined; + const requestedHost = requestedUrl ? new URL(requestedUrl).hostname : ""; + const urlSubdomain = requestedHost.split(".")[0] || ""; + const subdomain = normalizeManagedSubdomain( + options.subdomain || urlSubdomain || `${randomAlpha(2)}${randomAlphanumeric(13)}`, + ); + if (urlSubdomain && subdomain !== urlSubdomain) { + throw new CliError("Site subdomain must match the hostname supplied with --url."); + } + const tld = requestedHost + ? requestedHost.split(".").slice(1).join(".") + : String(options.tld || DEFAULT_TLD).trim().toLowerCase(); + const url = requestedUrl || normalizeManagedSiteUrl(`https://${subdomain}.${tld}`); + const hostname = new URL(url).hostname; + const wpHostname = hostname.endsWith(".onstatic.studio") ? `wp-${hostname}` : `wp.${hostname}`; + const adminUrl = `https://${wpHostname}/wp-admin`; return { - name: options.name || randomDomainWord(), + name: normalizeSiteName(options.name), subdomain, tld, url, @@ -70,22 +131,37 @@ export async function listSites( ] as const, "sort"); const orderAsc = options.ascending ?? false; + const { data: subscription } = await supabase + .from("subscription") + .select("partner_id") + .eq("user_id", user.id) + .order("id", { ascending: false }) + .limit(1) + .maybeSingle(); + let query = supabase .from("site") .select( - "*, user_site!inner(user_id, admin, owner_account_id),site_meta!inner(email, admin_url, secret_key, pagespeed_desktop, pagespeed_mobile, ttfb)", + "*, user_site!inner(user_id, admin, owner_account_id),site_meta(email, admin_url, secret_key, pagespeed_desktop, pagespeed_mobile, ttfb)", { count: "exact" }, ) .eq("user_site.user_id", user.id) .order(orderField, { ascending: orderAsc }); + // Keep the embed left-joined so team-only rows are still visible, while + // preventing one member from receiving another member's site metadata. if (user.email) { query = query.eq("site_meta.email", user.email); } + if ((subscription as { partner_id?: string } | null)?.partner_id === "hardypress") { + query = query.eq("hp_webhook_pending", false); + } + const search = sanitizeSearchTerm(options.search); if (search) { - query = query.or(`name.ilike.%${search}%,url.ilike.%${search}%`); + const escaped = search.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + query = query.or(`name.ilike."%${escaped}%",url.ilike."%${escaped}%"`); } const { data, error, count } = await query.range(from, to); @@ -177,7 +253,9 @@ export async function getMagicLoginLink( getSite(supabase, safeSiteId), getSiteMeta(supabase, safeSiteId, email), ]); - const staticSiteId = site.static_site_record_id ? String(site.static_site_record_id) : ""; + const staticSiteId = site.static_site_record_id + ? String(site.static_site_record_id) + : ""; if (!staticSiteId) { throw new CliError(`Site ${safeSiteId} is missing static_site_record_id.`); } @@ -208,61 +286,53 @@ export async function createSite( options: CreateSiteOptions, ): Promise<{ site: SiteRecord; queue: unknown; credentials: Record }> { const seed = generateSiteSeed(options); - const password = randomPassword(); - const username = randomUsername(); - const basicAuthUser = randomUsername(); - const basicAuthPassword = randomPassword(); - const secretKey = md5(seed.url); - const adminUrl = options.bedrock - ? seed.adminUrl.replace(/\/wp-admin\/?$/, "/wp/wp-admin") - : seed.adminUrl; + const mountPath = normalizeMountPath(options.mountPath); + const phpVersion = requireAllowedValue( + String(options.phpVersion || "8.3"), + PHP_VERSIONS, + "PHP version", + ); const recordResult = await invokeFunction<{ site: SiteRecord }>(supabase, "create-site-record", { url: seed.url, name: seed.name, - initial_export_done: false, ...(options.bedrock ? { bedrock: true } : {}), - password, - username, - basic_auth_user: basicAuthUser, - basic_auth_password: basicAuthPassword, - admin_url: adminUrl, - secret_key: secretKey, email: options.email, user_id: options.userId, - owner_account_id: options.ownerAccountId || null, + mount_path: mountPath, }); const siteId = String(recordResult.site.id); const queue = await invokeFunction(supabase, "queue-site", { - subdomain: seed.subdomain, - tld: seed.tld, - username, - password, - email: options.email, - basic_auth_user: basicAuthUser, - basic_auth_password: basicAuthPassword, - secret_key: secretKey, siteHasMigration: Boolean(options.hasMigration), site_id: siteId, apiVersion: SUPABASE_API_VERSION, - phpVersion: options.phpVersion || "8.3", + phpVersion, }); + const meta = await getSiteMeta(supabase, siteId, options.email); return { site: recordResult.site, queue, credentials: { - username, - password, - basic_auth_user: basicAuthUser, - basic_auth_password: basicAuthPassword, - admin_url: adminUrl, - secret_key: secretKey, + username: requiredCredential(meta.username, "username"), + password: requiredCredential(meta.password, "password"), + basic_auth_user: requiredCredential(meta.basic_auth_user, "basic_auth_user"), + basic_auth_password: requiredCredential(meta.basic_auth_password, "basic_auth_password"), + admin_url: requiredCredential(meta.admin_url, "admin_url"), + secret_key: requiredCredential(meta.secret_key, "secret_key"), }, }; } +function requiredCredential(value: unknown, field: string): string { + const credential = String(value || ""); + if (!credential) { + throw new CliError(`The site was created, but ${field} was missing from its server-generated credentials.`); + } + return credential; +} + export async function updateSite( supabase: SupabaseClient, siteId: string, @@ -356,15 +426,24 @@ export async function getChangesCount(supabase: SupabaseClient, siteId: string): } export async function clearCache(supabase: SupabaseClient, siteId: string): Promise { - return invokeFunction(supabase, "clear-cache", { siteId: assertSafeId(siteId, "siteId") }); + const site = await getSite(supabase, assertSafeId(siteId, "siteId")); + if (!site.pull_zone_id) { + throw new CliError("Site does not have a pull_zone_id yet."); + } + return invokeFunction(supabase, "clear-cache", { pull_zone: site.pull_zone_id }); } export function siteSummaryRows(sites: SiteRecord[]): Record[] { - return sites.map((site) => ({ - id: site.id, - name: site.name, - url: site.url, - status: site.status, - created_at: site.created_at, - })); + return sites.map((site) => { + const mountPath = normalizeMountPath(site.mount_path); + const baseUrl = String(site.url || "").replace(/\/+$/, ""); + return { + id: site.id, + name: site.name, + url: baseUrl ? `${baseUrl}${mountPath}` : site.url, + ...(mountPath ? { base_url: site.url, mount_path: mountPath } : {}), + status: site.status, + created_at: site.created_at, + }; + }); } diff --git a/src/team.test.ts b/src/team.test.ts index 98d922d..641ae17 100644 --- a/src/team.test.ts +++ b/src/team.test.ts @@ -2,7 +2,7 @@ import { writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { bulkInviteTeamMembers, parseEmailInputFile } from "./team.js"; +import { bulkInviteTeamMembers, parseEmailInputFile, removeTeamMember } from "./team.js"; import { createSupabaseMock } from "./test-utils.js"; describe("parseEmailInputFile", () => { @@ -68,4 +68,71 @@ describe("bulkInviteTeamMembers", () => { }, ]); }); + + it("rejects the subscriber role before querying or invoking manage-user", async () => { + const { supabase, calls, functionCalls } = createSupabaseMock({}); + + await expect( + bulkInviteTeamMembers(supabase, "owner-1", ["member@example.com"], { role: "subscriber" }), + ).rejects.toThrow("role must be one of: administrator, editor, author, contributor"); + expect(calls).toEqual([]); + expect(functionCalls).toEqual([]); + }); +}); + +describe("removeTeamMember", () => { + it("removes the member's site_meta rows as well as site access", async () => { + const { supabase, calls, functionCalls } = createSupabaseMock({ + user_site: { + data: [{ site_id: "site-1" }, { site_id: "site-2" }], + error: null, + }, + site_meta: { data: null, error: null }, + account_member: { data: null, error: null }, + }); + + await expect( + removeTeamMember(supabase, "owner-1", "member-1", "member@example.com"), + ).resolves.toEqual({ removed: true, memberId: "member-1", sitesProcessed: 2 }); + + expect(functionCalls).toEqual([ + { + name: "manage-user", + body: { + site_id: "site-1", + email: "member@example.com", + action: "delete", + site_user_id: "member-1", + }, + }, + { + name: "manage-user", + body: { + site_id: "site-2", + email: "member@example.com", + action: "delete", + site_user_id: "member-1", + }, + }, + ]); + expect(calls.filter((call) => call.table === "site_meta" && call.method === "delete")).toHaveLength(2); + expect(calls).toEqual(expect.arrayContaining([ + { table: "site_meta", method: "eq", args: ["site_id", "site-1"] }, + { table: "site_meta", method: "eq", args: ["site_id", "site-2"] }, + { table: "site_meta", method: "eq", args: ["email", "member@example.com"] }, + ])); + }); + + it("does not remove the account membership when site_meta cleanup fails", async () => { + const { supabase, calls } = createSupabaseMock((table) => { + if (table === "user_site") return { data: [{ site_id: "site-1" }], error: null }; + if (table === "site_meta") return { data: null, error: { message: "site_meta cleanup failed" } }; + return { data: null, error: null }; + }); + + await expect( + removeTeamMember(supabase, "owner-1", "member-1", "member@example.com"), + ).rejects.toThrow("site_meta cleanup failed"); + expect(calls.some((call) => call.table === "account_member" && call.method === "delete")).toBe(false); + }); }); diff --git a/src/team.ts b/src/team.ts index 22d86ce..7865c22 100644 --- a/src/team.ts +++ b/src/team.ts @@ -14,7 +14,7 @@ import { const MAX_TEAM_EMAILS = 100; const MAX_EMAIL_FILE_BYTES = 64 * 1024; -const WORDPRESS_ROLES = ["administrator", "editor", "author", "contributor", "subscriber"] as const; +const WORDPRESS_ROLES = ["administrator", "editor", "author", "contributor"] as const; export interface TeamMemberRecord { id: string | number; @@ -248,6 +248,14 @@ export async function removeTeamMember( } const { error } = await supabase.from("user_site").delete().eq("user_id", safeMemberId).eq("site_id", site.site_id); if (error) throw new CliError(error.message); + if (memberEmail) { + const { error: metaError } = await supabase + .from("site_meta") + .delete() + .eq("site_id", site.site_id) + .eq("email", memberEmail); + if (metaError) throw new CliError(metaError.message); + } sitesProcessed += 1; } diff --git a/src/types.ts b/src/types.ts index dde060a..7ab9135 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,8 +39,10 @@ export interface SiteRecord { notes?: string | null; pull_zone_id?: string | number | null; storage_zone_id?: string | number | null; + static_site_id?: string | number | null; static_site_record_id?: string | number | null; site_id?: string | number | null; + mount_path?: string | null; bedrock?: boolean | null; [key: string]: unknown; } diff --git a/src/upload.test.ts b/src/upload.test.ts index 4a37d6d..2ec1c52 100644 --- a/src/upload.test.ts +++ b/src/upload.test.ts @@ -1,27 +1,254 @@ -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { + closeSync, + mkdtempSync, + openSync, + rmSync, + truncateSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; -import { describe, expect, it } from "vitest"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CliError } from "./errors.js"; -import { getMigrationObjectInfo } from "./upload.js"; +import { getMigrationObjectInfo, uploadMigrationFile } from "./upload.js"; -describe("getMigrationObjectInfo", () => { - it("creates the expected storage object for backup ZIPs", () => { - const filePath = join(tmpdir(), `demo-studio-backup-${Date.now()}.zip`); - writeFileSync(filePath, "zip"); +const PART_SIZE = 25 * 1024 * 1024; + +type InvokeBody = Record; + +function protocolClient( + handler: (body: InvokeBody) => unknown | Promise, +): { supabase: any; bodies: InvokeBody[] } { + const bodies: InvokeBody[] = []; + return { + bodies, + supabase: { + functions: { + async invoke(name: string, options: { body?: InvokeBody } = {}) { + expect(name).toBe("get-upload-credentials"); + const body = options.body || {}; + bodies.push(body); + return { data: await handler(body), error: null }; + }, + }, + }, + }; +} + +describe("migration uploads", () => { + let directory: string; + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "static-studio-upload-")); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + rmSync(directory, { recursive: true, force: true }); + }); + + function file(name: string, size: number): string { + const path = join(directory, name); + const descriptor = openSync(path, "w"); + closeSync(descriptor); + truncateSync(path, size); + return path; + } + + describe("getMigrationObjectInfo", () => { + it("creates the expected storage object for supported archives", () => { + const zip = file("demo-studio-backup-1.zip", 3); + const tgz = file("demo.tgz", 7); - expect(getMigrationObjectInfo(filePath, "abc123")).toMatchObject({ + expect(getMigrationObjectInfo(zip, "abc123")).toEqual({ + bucket: "site_migrations", + key: "public/site-migration-abc123.zip", + contentType: "application/zip", + size: 3, + }); + expect(getMigrationObjectInfo(tgz, "demo-site")).toEqual({ + bucket: "site_migrations", + key: "public/site-migration-demo-site.tar.gz", + contentType: "application/gzip", + size: 7, + }); + }); + + it("validates filenames, subdomains, and non-empty files", () => { + const arbitraryZip = file("demo.zip", 3); + const emptyZip = file("empty-studio-backup-1.zip", 0); + + expect(() => getMigrationObjectInfo(arbitraryZip, "abc123")).toThrow(CliError); + expect(() => getMigrationObjectInfo(arbitraryZip, "abc123", true)).not.toThrow(); + expect(() => getMigrationObjectInfo(arbitraryZip, "UPPER", true)).toThrow(/subdomain/i); + expect(() => getMigrationObjectInfo(emptyZip, "abc123")).toThrow(/empty/i); + }); + }); + + it("uploads signed parts with bounded concurrency and completes with sorted ETags", async () => { + const filePath = file("demo-studio-backup-1.zip", PART_SIZE + 3); + const progress: Array<{ loaded: number; total?: number }> = []; + let activeUploads = 0; + let maxActiveUploads = 0; + const uploaded: Array<{ partNumber: number; bytes: number }> = []; + let releaseUploads!: () => void; + const bothUploadsStarted = new Promise((resolve) => { + releaseUploads = resolve; + }); + + const { supabase, bodies } = protocolClient((body) => { + if (body.action === "init") return { token: "upload-token", partCount: 2 }; + if (body.action === "sign_parts") { + return { + urls: body.partNumbers.map((partNumber: number) => ({ + partNumber, + url: `https://uploads.example.test/${partNumber}`, + })), + }; + } + if (body.action === "complete") return { ok: true, key: body.key }; + if (body.action === "abort") return { ok: true }; + throw new Error(`Unexpected action: ${body.action}`); + }); + + vi.stubGlobal("fetch", async (input: string | URL | Request, init?: RequestInit) => { + activeUploads += 1; + maxActiveUploads = Math.max(maxActiveUploads, activeUploads); + if (activeUploads === 2) releaseUploads(); + await bothUploadsStarted; + const partNumber = Number(new URL(String(input)).pathname.slice(1)); + uploaded.push({ + partNumber, + bytes: (init?.body as Buffer).byteLength, + }); + activeUploads -= 1; + return new Response(null, { + status: 200, + headers: { etag: `"etag-${partNumber}"` }, + }); + }); + + await expect( + uploadMigrationFile(supabase, { + filePath, + subdomain: "abc123", + onProgress: (value) => progress.push(value), + }), + ).resolves.toEqual({ + bucket: "site_migrations", + key: "public/site-migration-abc123.zip", + size: PART_SIZE + 3, + }); + + expect(bodies[0]).toEqual({ + action: "init", bucket: "site_migrations", key: "public/site-migration-abc123.zip", contentType: "application/zip", - size: 3, + fileSize: PART_SIZE + 3, + partSize: PART_SIZE, + }); + expect(bodies[1]).toEqual({ + action: "sign_parts", + token: "upload-token", + partNumbers: [1, 2], + }); + expect(bodies[1]?.partNumbers.length).toBeLessThanOrEqual(10); + expect(bodies[2]).toEqual({ + action: "complete", + token: "upload-token", + parts: [ + { partNumber: 1, etag: '"etag-1"' }, + { partNumber: 2, etag: '"etag-2"' }, + ], + }); + expect(uploaded.sort((a, b) => a.partNumber - b.partNumber)).toEqual([ + { partNumber: 1, bytes: PART_SIZE }, + { partNumber: 2, bytes: 3 }, + ]); + expect(maxActiveUploads).toBe(2); + expect(progress).toHaveLength(2); + expect(progress.at(-1)).toEqual({ loaded: PART_SIZE + 3, total: PART_SIZE + 3 }); + }); + + it("retries a failed part without double-counting progress", async () => { + const filePath = join(directory, "retry-studio-backup-1.zip"); + writeFileSync(filePath, "zip"); + let fetchAttempts = 0; + const progress: number[] = []; + const { supabase, bodies } = protocolClient((body) => { + if (body.action === "init") return { token: "retry-token", partCount: 1 }; + if (body.action === "sign_parts") { + return { urls: [{ partNumber: 1, url: "https://uploads.example.test/1" }] }; + } + if (body.action === "complete") return { ok: true }; + if (body.action === "abort") return { ok: true }; + throw new Error(`Unexpected action: ${body.action}`); }); + + vi.stubGlobal("fetch", async () => { + fetchAttempts += 1; + return fetchAttempts === 1 + ? new Response("temporary", { status: 503 }) + : new Response(null, { status: 200, headers: { etag: "retry-etag" } }); + }); + + await uploadMigrationFile(supabase, { + filePath, + subdomain: "retry", + onProgress: ({ loaded }) => progress.push(loaded), + }); + + expect(fetchAttempts).toBe(2); + expect(progress).toEqual([3]); + expect(bodies.map((body) => body.action)).toEqual(["init", "sign_parts", "complete"]); }); - it("rejects arbitrary ZIP names by default", () => { - const filePath = join(tmpdir(), `demo-${Date.now()}.zip`); + it("best-effort aborts when completion fails", async () => { + const filePath = join(directory, "abort-studio-backup-1.zip"); writeFileSync(filePath, "zip"); + const { supabase, bodies } = protocolClient((body) => { + if (body.action === "init") return { token: "abort-token", partCount: 1 }; + if (body.action === "sign_parts") { + return { urls: [{ partNumber: 1, url: "https://uploads.example.test/1" }] }; + } + if (body.action === "complete") return { error: "Could not complete upload" }; + if (body.action === "abort") return { ok: true }; + throw new Error(`Unexpected action: ${body.action}`); + }); + vi.stubGlobal("fetch", async () => + new Response(null, { status: 200, headers: { etag: "abort-etag" } }) + ); + + await expect( + uploadMigrationFile(supabase, { filePath, subdomain: "abort" }), + ).rejects.toThrow("Could not complete upload"); + expect(bodies.map((body) => body.action)).toEqual([ + "init", + "sign_parts", + "complete", + "abort", + ]); + }); + + it("rejects non-HTTPS signed upload URLs and aborts", async () => { + const filePath = join(directory, "secure-studio-backup-1.zip"); + writeFileSync(filePath, "zip"); + const { supabase, bodies } = protocolClient((body) => { + if (body.action === "init") return { token: "secure-token", partCount: 1 }; + if (body.action === "sign_parts") { + return { urls: [{ partNumber: 1, url: "http://uploads.example.test/1" }] }; + } + if (body.action === "abort") return { ok: true }; + throw new Error(`Unexpected action: ${body.action}`); + }); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); - expect(() => getMigrationObjectInfo(filePath, "abc123")).toThrow(CliError); + await expect(uploadMigrationFile(supabase, { filePath, subdomain: "secure" })) + .rejects.toThrow("invalid signed part data"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(bodies.map((body) => body.action)).toEqual(["init", "sign_parts", "abort"]); }); }); diff --git a/src/upload.ts b/src/upload.ts index 3220cac..fa44710 100644 --- a/src/upload.ts +++ b/src/upload.ts @@ -1,13 +1,34 @@ -import { createReadStream, statSync } from "node:fs"; import { basename } from "node:path"; -import { S3Client } from "@aws-sdk/client-s3"; -import { Upload } from "@aws-sdk/lib-storage"; +import { statSync } from "node:fs"; +import { open, type FileHandle } from "node:fs/promises"; import type { SupabaseClient } from "@supabase/supabase-js"; import { CliError } from "./errors.js"; import { invokeFunction } from "./supabase.js"; const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 * 1024; const DEFAULT_PART_SIZE = 25 * 1024 * 1024; +const UPLOAD_CONCURRENCY = 2; +const SIGN_BATCH_SIZE = 10; +const MAX_UPLOAD_ATTEMPTS = 5; +const RETRY_DELAY_MS = 200; +const SUBDOMAIN_RE = /^[a-z0-9][a-z0-9-]{0,62}$/; + +type MigrationObjectInfo = { + bucket: string; + key: string; + contentType: string; + size: number; +}; + +type SignedPart = { + partNumber: number; + url: string; +}; + +type CompletedPart = { + partNumber: number; + etag: string; +}; export interface UploadMigrationOptions { filePath: string; @@ -16,12 +37,18 @@ export interface UploadMigrationOptions { onProgress?: (progress: { loaded: number; total?: number }) => void; } -export function getMigrationObjectInfo(filePath: string, subdomain: string, allowAnyZipName = false): { - bucket: string; - key: string; - contentType: string; - size: number; -} { +export function getMigrationObjectInfo( + filePath: string, + subdomain: string, + allowAnyZipName = false, +): MigrationObjectInfo { + const cleanSubdomain = String(subdomain || "").trim(); + if (!SUBDOMAIN_RE.test(cleanSubdomain)) { + throw new CliError( + "Migration subdomain must be 1-63 lowercase letters, numbers, or hyphens and start with a letter or number.", + ); + } + const fileName = basename(filePath).toLowerCase(); const isTarGz = fileName.endsWith(".tar.gz") || fileName.endsWith(".tgz"); const isZip = fileName.endsWith(".zip"); @@ -29,77 +56,277 @@ export function getMigrationObjectInfo(filePath: string, subdomain: string, allo throw new CliError("Migration files must end in .zip, .tar.gz, or .tgz."); } if (isZip && !allowAnyZipName && !fileName.includes("studio-backup-")) { - throw new CliError("ZIP migration files must be exported by Static Studio Backup and Migrate and include `studio-backup-` in the filename."); + throw new CliError( + "ZIP migration files must be exported by Static Studio Backup and Migrate and include `studio-backup-` in the filename.", + ); } - const { size } = statSync(filePath); - if (size > MAX_UPLOAD_SIZE) { + let info; + try { + info = statSync(filePath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new CliError(`Could not read migration file: ${message}`); + } + if (!info.isFile()) { + throw new CliError("The selected migration path must be a file."); + } + if (info.size <= 0) { + throw new CliError("The selected migration file is empty."); + } + if (info.size > MAX_UPLOAD_SIZE) { throw new CliError("The selected file exceeds the 10 GB migration upload limit."); } const extension = isTarGz ? ".tar.gz" : ".zip"; return { bucket: "site_migrations", - key: `public/site-migration-${subdomain}${extension}`, + key: `public/site-migration-${cleanSubdomain}${extension}`, contentType: isTarGz ? "application/gzip" : "application/zip", - size, + size: info.size, }; } -export async function uploadMigrationFile( +async function invokeUploadAction( supabase: SupabaseClient, - options: UploadMigrationOptions, -): Promise<{ bucket: string; key: string; size: number }> { - const object = getMigrationObjectInfo(options.filePath, options.subdomain, options.allowAnyZipName); - const creds = await invokeFunction<{ - accessKeyId?: string; - secretAccessKey?: string; - region?: string; - endpoint?: string; - }>(supabase, "get-upload-credentials"); - - if (!creds.accessKeyId || !creds.secretAccessKey || !creds.endpoint) { - throw new CliError("Upload credentials were incomplete."); + body: Record, +): Promise { + return invokeFunction(supabase, "get-upload-credentials", body); +} + +async function readPart( + file: FileHandle, + partNumber: number, + fileSize: number, +): Promise { + const position = (partNumber - 1) * DEFAULT_PART_SIZE; + const length = Math.min(DEFAULT_PART_SIZE, fileSize - position); + if (length <= 0) { + throw new CliError(`Upload part ${partNumber} is outside the migration file.`); } - const client = new S3Client({ - region: creds.region || "us-east-1", - endpoint: creds.endpoint, - forcePathStyle: true, - credentials: { - accessKeyId: creds.accessKeyId, - secretAccessKey: creds.secretAccessKey, - }, - maxAttempts: 5, - retryMode: "adaptive", + const buffer = Buffer.allocUnsafe(length); + let offset = 0; + while (offset < length) { + const { bytesRead } = await file.read( + buffer, + offset, + length - offset, + position + offset, + ); + if (bytesRead === 0) { + throw new CliError("The migration file changed while it was being uploaded."); + } + offset += bytesRead; + } + return buffer; +} + +function retryDelay(attempt: number): Promise { + const delay = Math.min(RETRY_DELAY_MS * 2 ** Math.max(0, attempt - 1), 2_000); + return new Promise((resolve) => setTimeout(resolve, delay)); +} + +async function uploadPart( + file: FileHandle, + signedPart: SignedPart, + fileSize: number, +): Promise { + const body = await readPart(file, signedPart.partNumber, fileSize); + let lastError: unknown; + + for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) { + try { + const response = await fetch(signedPart.url, { + method: "PUT", + body, + redirect: "error", + }); + if (!response.ok) { + const details = (await response.text().catch(() => "")).slice(0, 300); + throw new Error( + `HTTP ${response.status}${details ? `: ${details}` : ""}`, + ); + } + + const etag = response.headers.get("etag")?.trim(); + if (!etag) { + throw new Error("storage did not return an ETag"); + } + return { partNumber: signedPart.partNumber, etag }; + } catch (error) { + lastError = error; + if (attempt < MAX_UPLOAD_ATTEMPTS) { + await retryDelay(attempt); + } + } + } + + const message = lastError instanceof Error ? lastError.message : String(lastError); + throw new CliError( + `Upload part ${signedPart.partNumber} failed after ${MAX_UPLOAD_ATTEMPTS} attempts: ${message}`, + ); +} + +function validateSignedParts( + value: unknown, + requestedPartNumbers: number[], +): SignedPart[] { + if (!Array.isArray(value) || value.length !== requestedPartNumbers.length) { + throw new CliError("Could not sign all multipart upload chunks."); + } + + const requested = new Set(requestedPartNumbers); + const seen = new Set(); + const parts = value.map((entry) => { + const record = entry as { partNumber?: unknown; url?: unknown }; + const partNumber = Number(record?.partNumber); + const url = typeof record?.url === "string" ? record.url : ""; + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + throw new CliError("Upload service returned an invalid signed URL."); + } + if ( + !Number.isInteger(partNumber) || + !requested.has(partNumber) || + seen.has(partNumber) || + parsedUrl.protocol !== "https:" + ) { + throw new CliError("Upload service returned invalid signed part data."); + } + seen.add(partNumber); + // Preserve the signed URL byte-for-byte; re-serializing it can invalidate + // provider signatures even when the parsed URL is semantically equivalent. + return { partNumber, url }; }); - const uploader = new Upload({ - client, - params: { - Bucket: object.bucket, - Key: object.key, - ContentType: object.contentType, - Body: createReadStream(options.filePath), + return parts.sort((a, b) => a.partNumber - b.partNumber); +} + +async function uploadSignedBatch( + file: FileHandle, + signedParts: SignedPart[], + fileSize: number, + onPartComplete: (part: CompletedPart) => void, +): Promise { + let cursor = 0; + let failed = false; + let firstError: unknown; + + const workers = Array.from( + { length: Math.min(UPLOAD_CONCURRENCY, signedParts.length) }, + async () => { + while (!failed) { + const index = cursor; + cursor += 1; + const signedPart = signedParts[index]; + if (!signedPart) return; + + try { + onPartComplete(await uploadPart(file, signedPart, fileSize)); + } catch (error) { + if (!failed) firstError = error; + failed = true; + } + } }, - queueSize: 2, - partSize: DEFAULT_PART_SIZE, - leavePartsOnError: false, - }); + ); - if (options.onProgress) { - uploader.on("httpUploadProgress", (progress) => { - options.onProgress?.({ - loaded: progress.loaded || 0, - ...(progress.total ? { total: progress.total } : {}), + await Promise.all(workers); + if (failed) throw firstError; +} + +export async function uploadMigrationFile( + supabase: SupabaseClient, + options: UploadMigrationOptions, +): Promise<{ bucket: string; key: string; size: number }> { + const object = getMigrationObjectInfo( + options.filePath, + options.subdomain, + options.allowAnyZipName, + ); + const partCount = Math.ceil(object.size / DEFAULT_PART_SIZE); + let uploadToken: string | undefined; + let file: FileHandle | undefined; + + try { + file = await open(options.filePath, "r"); + const openedInfo = await file.stat(); + if (!openedInfo.isFile() || openedInfo.size !== object.size) { + throw new CliError("The migration file changed before the upload started."); + } + + const initialized = await invokeUploadAction<{ + token?: unknown; + partCount?: unknown; + }>(supabase, { + action: "init", + bucket: object.bucket, + key: object.key, + contentType: object.contentType, + fileSize: object.size, + partSize: DEFAULT_PART_SIZE, + }); + + if (typeof initialized?.token === "string" && initialized.token) { + uploadToken = initialized.token; + } + if (!uploadToken || Number(initialized.partCount) !== partCount) { + throw new CliError("Could not initialize multipart migration upload."); + } + + const completedParts: CompletedPart[] = []; + let loaded = 0; + for (let first = 1; first <= partCount; first += SIGN_BATCH_SIZE) { + const requestedPartNumbers = Array.from( + { length: Math.min(SIGN_BATCH_SIZE, partCount - first + 1) }, + (_, index) => first + index, + ); + const signed = await invokeUploadAction<{ urls?: unknown }>(supabase, { + action: "sign_parts", + token: uploadToken, + partNumbers: requestedPartNumbers, }); + const signedParts = validateSignedParts(signed?.urls, requestedPartNumbers); + + await uploadSignedBatch(file, signedParts, object.size, (completed) => { + completedParts.push(completed); + const start = (completed.partNumber - 1) * DEFAULT_PART_SIZE; + loaded += Math.min(DEFAULT_PART_SIZE, object.size - start); + try { + options.onProgress?.({ loaded, total: object.size }); + } catch { + // Progress reporting must never interrupt an otherwise valid upload. + } + }); + } + + completedParts.sort((a, b) => a.partNumber - b.partNumber); + const completed = await invokeUploadAction<{ ok?: unknown }>(supabase, { + action: "complete", + token: uploadToken, + parts: completedParts, }); - } + if (completed?.ok !== true) { + throw new CliError("Storage did not confirm the completed migration upload."); + } - await uploader.done(); - return { - bucket: object.bucket, - key: object.key, - size: object.size, - }; + return { + bucket: object.bucket, + key: object.key, + size: object.size, + }; + } catch (error) { + if (uploadToken) { + await invokeUploadAction(supabase, { + action: "abort", + token: uploadToken, + }).catch(() => undefined); + } + throw error; + } finally { + await file?.close().catch(() => undefined); + } } diff --git a/src/users.test.ts b/src/users.test.ts index bed1e1f..cde2584 100644 --- a/src/users.test.ts +++ b/src/users.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { makeAdmin, removeUser } from "./users.js"; +import { inviteUser, listUsers, makeAdmin, removeUser } from "./users.js"; import { createSupabaseMock } from "./test-utils.js"; describe("removeUser", () => { @@ -67,3 +67,60 @@ describe("makeAdmin", () => { ]); }); }); + +describe("listUsers", () => { + it("includes site_id in the hardened get-user-confirmations payload", async () => { + const { supabase, functionCalls } = createSupabaseMock( + { + user_site: { + data: [{ user_id: "user-2", email: "member@example.com", admin: false, site_id: "site-1" }], + error: null, + }, + }, + { + items: [{ id: "user-2", email_confirmed: true, has_paid_subscription: true }], + }, + ); + + await expect(listUsers(supabase, "site-1")).resolves.toEqual([ + expect.objectContaining({ + user_id: "user-2", + email_confirmed: true, + has_paid_subscription: true, + }), + ]); + expect(functionCalls).toEqual([ + { + name: "get-user-confirmations", + body: { ids: ["user-2"], site_id: "site-1" }, + }, + ]); + }); + + it("preserves the existing confirmation fallback when lookup fails", async () => { + const { supabase } = createSupabaseMock( + { + user_site: { + data: [{ user_id: "user-2", email: "member@example.com", admin: false, site_id: "site-1" }], + error: null, + }, + }, + { error: "Confirmation lookup failed" }, + ); + + await expect(listUsers(supabase, "site-1")).resolves.toEqual([ + expect.objectContaining({ email_confirmed: true, has_paid_subscription: false }), + ]); + }); +}); + +describe("WordPress role validation", () => { + it("rejects the subscriber role removed by the hardened manage-user endpoint", async () => { + const { supabase, functionCalls } = createSupabaseMock({}); + + await expect( + inviteUser(supabase, "site-1", "person@example.com", "subscriber"), + ).rejects.toThrow("role must be one of: administrator, editor, author, contributor"); + expect(functionCalls).toEqual([]); + }); +}); diff --git a/src/users.ts b/src/users.ts index fbf4495..74e0d65 100644 --- a/src/users.ts +++ b/src/users.ts @@ -4,7 +4,7 @@ import { invokeFunction } from "./supabase.js"; import { randomUsername } from "./random.js"; import { assertSafeId, normalizeEmail, requireAllowedValue } from "./validation.js"; -const WORDPRESS_ROLES = ["administrator", "editor", "author", "contributor", "subscriber"] as const; +const WORDPRESS_ROLES = ["administrator", "editor", "author", "contributor"] as const; function normalizeRole(role: string): (typeof WORDPRESS_ROLES)[number] { return requireAllowedValue(role || "administrator", WORDPRESS_ROLES, "role"); @@ -26,7 +26,7 @@ export async function listUsers(supabase: SupabaseClient, siteId: string): Promi const result = await invokeFunction<{ items?: { id: string; email_confirmed?: boolean; has_paid_subscription?: boolean }[] }>( supabase, "get-user-confirmations", - { ids }, + { ids, site_id: safeSiteId }, ); confirmations = Object.fromEntries((result.items || []).map((item) => [item.id, item])); } catch { diff --git a/src/validation.test.ts b/src/validation.test.ts index afdf161..52b991f 100644 --- a/src/validation.test.ts +++ b/src/validation.test.ts @@ -4,6 +4,7 @@ import { normalizeEmailList, normalizeEnvironmentTitle, normalizeHexColor, + normalizeMountPath, parsePositiveInteger, sanitizeSearchTerm, } from "./validation.js"; @@ -31,4 +32,16 @@ describe("validation helpers", () => { expect(normalizeHexColor("#3858e9")).toBe("#3858E9"); expect(() => normalizeHexColor("blue")).toThrow(CliError); }); + + it("normalizes mount paths using the Lagos path rules", () => { + expect(normalizeMountPath(undefined)).toBe(""); + expect(normalizeMountPath("/")).toBe(""); + expect(normalizeMountPath(" blog//news/ ")).toBe("/blog/news"); + expect(normalizeMountPath("https://example.com/resources/?preview=1#top")).toBe("/resources"); + }); + + it("rejects unsafe mount paths", () => { + expect(() => normalizeMountPath("/blog/../admin")).toThrow(CliError); + expect(() => normalizeMountPath("/blog/