@agilesyndrome/cf-genai-base 5.0.0 → 5.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLI.md +121 -0
- package/CONTRACT.md +2 -2
- package/README.md +49 -0
- package/bin/cf-genai.js +11 -0
- package/changelog.md +10 -0
- package/package.json +14 -4
- package/src/auth/index.js +1 -0
- package/src/auth/oauth-single.js +193 -0
- package/src/cli/cli.js +133 -0
- package/src/cli/d1.js +165 -0
- package/src/cli/operations.js +34 -0
- package/src/cli/project.js +560 -0
- package/src/cli/status.js +46 -0
- package/src/cli/tenants.js +16 -0
- package/src/cli/users.js +34 -0
- package/src/cli/version.js +54 -0
package/CLI.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# `cf-genai` command
|
|
2
|
+
|
|
3
|
+
The `cf-genai` executable is published by `@agilesyndrome/cf-genai-base` and
|
|
4
|
+
provides safe operational commands for Cloudflare Worker repositories using
|
|
5
|
+
Wrangler and D1. The refresh command surface contains only local and staging
|
|
6
|
+
targets.
|
|
7
|
+
|
|
8
|
+
Projects that previously installed `@agilesyndrome/cf-genai-cli` should remove
|
|
9
|
+
that dependency. Installing `@agilesyndrome/cf-genai-base` now installs the
|
|
10
|
+
same `cf-genai` executable.
|
|
11
|
+
|
|
12
|
+
Automation can import the command runner from
|
|
13
|
+
`@agilesyndrome/cf-genai-base/cli`. The migrated helper modules remain
|
|
14
|
+
available through subpaths such as `@agilesyndrome/cf-genai-base/cli/d1` and
|
|
15
|
+
`@agilesyndrome/cf-genai-base/cli/project`.
|
|
16
|
+
|
|
17
|
+
It also provides shared project automation so dependent repositories do not
|
|
18
|
+
need to duplicate their build and release logic:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
cf-genai check
|
|
22
|
+
cf-genai lint data-access
|
|
23
|
+
cf-genai test
|
|
24
|
+
cf-genai ci
|
|
25
|
+
cf-genai dev
|
|
26
|
+
cf-genai upgrade base latest
|
|
27
|
+
cf-genai upgrade auth latest
|
|
28
|
+
cf-genai upgrade llm 5.0.0
|
|
29
|
+
cf-genai upgrade messaging latest
|
|
30
|
+
cf-genai release --confirm
|
|
31
|
+
cf-genai release --first --confirm
|
|
32
|
+
cf-genai release --add-trust --confirm
|
|
33
|
+
cf-genai release --dry-run
|
|
34
|
+
cf-genai release --version 4.1 --confirm
|
|
35
|
+
cf-genai release-status
|
|
36
|
+
cf-genai release-status --wait 3 --json
|
|
37
|
+
cf-genai version
|
|
38
|
+
cf-genai status
|
|
39
|
+
cf-genai status --json
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`dev` runs through `op run --env-file=.env.dev --`, using the repository's
|
|
43
|
+
`npm run dev` script when present, otherwise starting `wrangler dev`. This
|
|
44
|
+
loads all `.env.dev` variables and resolves any `op://` values automatically.
|
|
45
|
+
`check` also rejects direct `env.DB.prepare(...)` and `DB.prepare(...)` calls
|
|
46
|
+
in `cf-genai-*` application source; domain code must use the scoped data reader
|
|
47
|
+
provided by `cf-genai-base`. Tests and migrations are excluded from this lint.
|
|
48
|
+
`release` owns versioning, tagging, and pushing the release trigger used by
|
|
49
|
+
GitHub Actions. `version` shows the installed CLI version and the latest npm
|
|
50
|
+
version, with an upgrade command when one is available.
|
|
51
|
+
|
|
52
|
+
For the initial npm publication, use `cf-genai release --first --confirm`.
|
|
53
|
+
It checks npm authentication, publishes the current package once with public
|
|
54
|
+
access and provenance disabled, and then creates the matching npm Trusted
|
|
55
|
+
Publisher rule for `.github/workflows/publish.yml`. npm 11.15.0 or newer and
|
|
56
|
+
account-level 2FA are required for the trust step. If the package is already
|
|
57
|
+
published, use `cf-genai release --add-trust --confirm`.
|
|
58
|
+
Subsequent releases use the normal tag-triggered workflow.
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
cf-genai d1 refresh local
|
|
62
|
+
cf-genai d1 refresh staging --yes
|
|
63
|
+
cf-genai d1 migrate local
|
|
64
|
+
cf-genai d1 migrate staging
|
|
65
|
+
cf-genai d1 migrate production --confirm-production
|
|
66
|
+
cf-genai d1 status staging
|
|
67
|
+
cf-genai d1 check production
|
|
68
|
+
cf-genai config check
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`refresh local` exports production data, applies local migrations, clears local
|
|
72
|
+
application tables, and imports the data. `refresh staging` does the same for a
|
|
73
|
+
remote staging environment. Both exclude Wrangler's migration ledger and
|
|
74
|
+
internal tables. The default D1 binding is `DB`; override it with `--database`.
|
|
75
|
+
|
|
76
|
+
Credential loading stays outside the CLI, so repositories can use their normal
|
|
77
|
+
wrapper:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
op run --env-file=.env.op -- cf-genai d1 refresh local
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Production migration requires `--confirm-production`. Remote staging refresh
|
|
84
|
+
requires `--yes`. Releases require `--confirm` (or can be inspected
|
|
85
|
+
with `--dry-run`); the CLI verifies a
|
|
86
|
+
clean checkout on `main`, fetches and compares `origin/main`, pushes and
|
|
87
|
+
verifies the release commit before creating the tag, and only then pushes the
|
|
88
|
+
tag that triggers npm publishing. The release tag publishes the package through GitHub Actions with provenance. `config check` runs a Wrangler deploy dry-run. Production refresh remains intentionally unavailable; backups and restores are available with explicit file paths.
|
|
89
|
+
|
|
90
|
+
`upgrade PACKAGE VERSION` upgrades the first-party `base`, `auth`, `llm`, or
|
|
91
|
+
`messaging` package with npm and vendors package migrations not already represented in the repository's
|
|
92
|
+
`migrations/` directory. The generated files are ordinary committed Wrangler
|
|
93
|
+
migrations, so the same schema change is applied consistently to local,
|
|
94
|
+
staging, and production D1 databases. Review and commit the package files,
|
|
95
|
+
`package.json`, `package-lock.json`, and generated migrations together.
|
|
96
|
+
Operational status can be read directly from the current site directory through Wrangler (no CLI login prompt):
|
|
97
|
+
cf-genai healthcheck:list --env local
|
|
98
|
+
cf-genai healthcheck:list --env staging
|
|
99
|
+
cf-genai circuit-breaker:list --env prod
|
|
100
|
+
cf-genai circuit-breaker:set llm:openai-models on --env staging
|
|
101
|
+
The standardized admin surface mirrors cf-genai-base and uses Wrangler authentication from the current machine:
|
|
102
|
+
cf-genai admin status --env staging
|
|
103
|
+
cf-genai admin features --env staging
|
|
104
|
+
cf-genai admin users --env staging
|
|
105
|
+
cf-genai admin tenants --env staging
|
|
106
|
+
cf-genai admin scopes --env staging
|
|
107
|
+
cf-genai admin groups --env staging
|
|
108
|
+
cf-genai admin healthchecks --env staging
|
|
109
|
+
cf-genai admin circuit-breakers --env staging
|
|
110
|
+
cf-genai tenant list --env staging
|
|
111
|
+
cf-genai tenant get easley-family --env staging
|
|
112
|
+
cf-genai tenant create acme --name "Acme Corporation" --env staging
|
|
113
|
+
cf-genai tenant update acme --name "Acme Inc." --env staging
|
|
114
|
+
cf-genai user get someone@example.com --env staging
|
|
115
|
+
cf-genai user update someone@example.com --tenants easley-family,acme --env staging
|
|
116
|
+
cf-genai healthchecks set llm:provider red --env staging
|
|
117
|
+
cf-genai circuit-breakers set llm:provider tripped --env staging
|
|
118
|
+
Back up and restore a complete D1 database with explicit files:
|
|
119
|
+
cf-genai d1 backup production --output ./backup.sql --confirm-production
|
|
120
|
+
cf-genai d1 restore staging --file ./backup.sql --yes
|
|
121
|
+
`release` creates the version commit and tag; the tag-triggered workflow publishes to npm. `release-status` verifies a clean, pushed workspace, the remote release tag, successful GitHub Actions runs for that tag, npm publication, and whether the declared `cf-genai-base` version is current. An older base version is shown in yellow with an upgrade command. `--wait` is one total timeout in minutes shared by GitHub Actions and npm polling; the default is five minutes. Use `release --version MAJOR.MINOR` to explicitly jump to a version such as `5.0`; the CLI assigns patch `0`, accepts an equal prepared manifest version when its tag and npm version do not exist, rejects older versions, and refuses any version already present on npm or GitHub. This is useful for synchronizing packages onto a common release line.
|
package/CONTRACT.md
CHANGED
|
@@ -90,8 +90,8 @@ them explicitly. Base cannot provide row-level security to
|
|
|
90
90
|
direct D1 calls, so applications must keep raw database access out of domain
|
|
91
91
|
features. The cookbook migration must add and backfill `tenant_id` on recipe
|
|
92
92
|
tables, register recipes as tenant-scoped, replace direct D1 reads/writes with
|
|
93
|
-
`state.data.tenant`, and add cross-tenant isolation tests. The
|
|
94
|
-
`cf-genai
|
|
93
|
+
`state.data.tenant`, and add cross-tenant isolation tests. The bundled
|
|
94
|
+
`cf-genai` command lints `cf-genai-*` working folders for direct
|
|
95
95
|
`env.DB.prepare(` usage as a follow-up enforcement check.
|
|
96
96
|
Scoped write violations are returned as a generic 403 response; the detailed
|
|
97
97
|
scope/resource identity is retained in the audit log only.
|
package/README.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Opinionated startup boilerplate for small Cloudflare Workers.
|
|
4
4
|
|
|
5
|
+
The package also ships the `cf-genai` command. Runtime, administration, D1,
|
|
6
|
+
project automation, and release tooling are versioned and published together;
|
|
7
|
+
there is no separate CLI dependency.
|
|
8
|
+
|
|
5
9
|
The base owns the shared security boundary as well as Worker lifecycle concerns. It reserves `/admin` and `/api/admin` routes, authenticates them using `AUTH_STRATEGY` (default `http_basic`, or `oauth` when an auth provider is supplied), and applies the optional `authorize` policy. Sites still own their router, HTML, D1 queries, R2 keys, and scheduled jobs.
|
|
6
10
|
Use D1 bindings for durable application data and R2 bindings for binary assets;
|
|
7
11
|
do not put either into module-level state.
|
|
@@ -32,6 +36,51 @@ pages. Import `AdminShell` and the platform catalogs from
|
|
|
32
36
|
`@agilesyndrome/cf-genai-base/ui`. Sites provide their own application links and
|
|
33
37
|
theme while the base components consume the shared JSON admin APIs.
|
|
34
38
|
|
|
39
|
+
## Command-line tools
|
|
40
|
+
|
|
41
|
+
Install base in a project (or globally) and use the bundled executable:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
npm install @agilesyndrome/cf-genai-base
|
|
45
|
+
npx cf-genai version
|
|
46
|
+
|
|
47
|
+
cf-genai check
|
|
48
|
+
cf-genai test
|
|
49
|
+
cf-genai ci
|
|
50
|
+
cf-genai dev
|
|
51
|
+
cf-genai upgrade base latest
|
|
52
|
+
cf-genai release --confirm
|
|
53
|
+
cf-genai release-status --wait 3
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Projects upgrading from the standalone package should remove
|
|
57
|
+
`@agilesyndrome/cf-genai-cli`; their existing base dependency now supplies the
|
|
58
|
+
same `cf-genai` executable.
|
|
59
|
+
|
|
60
|
+
The CLI includes all commands formerly published by
|
|
61
|
+
`@agilesyndrome/cf-genai-cli`: project checks and releases, scoped-data linting,
|
|
62
|
+
package upgrades with migration vendoring, D1 refresh/backup/restore/migration,
|
|
63
|
+
site status, and user, tenant, healthcheck, circuit-breaker, and platform admin
|
|
64
|
+
operations.
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
cf-genai status --env staging
|
|
68
|
+
cf-genai d1 refresh local
|
|
69
|
+
cf-genai d1 migrate production --confirm-production
|
|
70
|
+
cf-genai d1 backup production --output ./backup.sql --confirm-production
|
|
71
|
+
cf-genai admin features --env staging
|
|
72
|
+
cf-genai admin users --env staging
|
|
73
|
+
cf-genai tenant list --env staging
|
|
74
|
+
cf-genai user get someone@example.com --env staging
|
|
75
|
+
cf-genai healthchecks set llm:provider red --env staging
|
|
76
|
+
cf-genai circuit-breakers set llm:provider tripped --env staging
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
See [CLI.md](CLI.md) or run `cf-genai --help` for the complete command grammar. Credential loading
|
|
80
|
+
stays outside the command, so a repository can continue to wrap it with
|
|
81
|
+
`op run --env-file=.env.op --`. Destructive remote operations retain their
|
|
82
|
+
existing explicit confirmation flags.
|
|
83
|
+
|
|
35
84
|
## Shared platform helpers
|
|
36
85
|
|
|
37
86
|
`createWorker` can own `/health` and `/api/health`, and run a boot validator
|
package/bin/cf-genai.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { main } from "../src/cli/cli.js";
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
const result = await main(process.argv.slice(2));
|
|
7
|
+
if (result?.ok === false) process.exitCode = 1;
|
|
8
|
+
} catch (error) {
|
|
9
|
+
console.error(`Error: ${error.message}`);
|
|
10
|
+
process.exitCode = 1;
|
|
11
|
+
}
|
package/changelog.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Fold the complete `cf-genai` operational CLI into this package so Worker,
|
|
6
|
+
admin, healthcheck, circuit-breaker, D1, project, and release tooling share a
|
|
7
|
+
single version and installation.
|
|
8
|
+
- Publish the `cf-genai` executable and the programmatic `/cli` entrypoint from
|
|
9
|
+
`@agilesyndrome/cf-genai-base`.
|
|
10
|
+
- Make base CI validate the bundled CLI directly instead of downloading the
|
|
11
|
+
retired `@agilesyndrome/cf-genai-cli` package.
|
|
12
|
+
|
|
3
13
|
## 5.0.0
|
|
4
14
|
|
|
5
15
|
- Replace `@agilesyndrome/cf-genai-base/authorization` imports with `/auth`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agilesyndrome/cf-genai-base",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.1",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"cf-genai": "bin/cf-genai.js"
|
|
7
|
+
},
|
|
5
8
|
"exports": {
|
|
6
9
|
".": "./src/index.js",
|
|
7
10
|
"./auth": "./src/auth/index.js",
|
|
@@ -14,9 +17,14 @@
|
|
|
14
17
|
"./core": "./src/core/index.js",
|
|
15
18
|
"./ui": "./src/ui/index.js",
|
|
16
19
|
"./ui/react": "./src/ui/react/index.jsx",
|
|
17
|
-
"./ui/styles.css": "./src/ui/styles.css"
|
|
20
|
+
"./ui/styles.css": "./src/ui/styles.css",
|
|
21
|
+
"./cli": "./src/cli/cli.js",
|
|
22
|
+
"./cli/*": "./src/cli/*.js"
|
|
23
|
+
},
|
|
24
|
+
"description": "Cloudflare Worker lifecycle, security, administration, and operational CLI helpers.",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
18
27
|
},
|
|
19
|
-
"description": "Lean Worker lifecycle and security helpers for Cloudflare sites.",
|
|
20
28
|
"license": "MIT",
|
|
21
29
|
"peerDependencies": {
|
|
22
30
|
"react": "^19.0.0"
|
|
@@ -26,9 +34,11 @@
|
|
|
26
34
|
"provenance": true
|
|
27
35
|
},
|
|
28
36
|
"files": [
|
|
37
|
+
"bin",
|
|
29
38
|
"src",
|
|
30
39
|
"migrations",
|
|
31
40
|
"README.md",
|
|
41
|
+
"CLI.md",
|
|
32
42
|
"changelog.md",
|
|
33
43
|
"CONTRACT.md",
|
|
34
44
|
"LICENSE"
|
|
@@ -39,7 +49,7 @@
|
|
|
39
49
|
},
|
|
40
50
|
"homepage": "https://github.com/agilesyndrome/cf-genai-base#readme",
|
|
41
51
|
"scripts": {
|
|
42
|
-
"check": "node --check src/index.js && node --check src/core/index.js && node --check src/core/jobs.js && node --check src/core/event-hub.js && node --check src/auth/index.js && node --check src/runtime/worker.js && node --check src/runtime/features.js && node --check src/runtime/health.js && node --check src/admin/index.js && node --check src/admin/routes.js && node --check src/data/index.js && node --check src/data/resources.js && node --check src/data/reader.js && node --check src/data/context.js && node --check src/api/index.js && node --check src/api/contracts.js && node --check src/api/client.js && node --check src/api/jobs.js && node --check src/ui/index.js",
|
|
52
|
+
"check": "node --check bin/cf-genai.js && node --check src/index.js && node --check src/core/index.js && node --check src/core/jobs.js && node --check src/core/event-hub.js && node --check src/auth/index.js && node --check src/auth/oauth-single.js && node --check src/runtime/worker.js && node --check src/runtime/features.js && node --check src/runtime/health.js && node --check src/admin/index.js && node --check src/admin/routes.js && node --check src/data/index.js && node --check src/data/resources.js && node --check src/data/reader.js && node --check src/data/context.js && node --check src/api/index.js && node --check src/api/contracts.js && node --check src/api/client.js && node --check src/api/jobs.js && node --check src/ui/index.js && node --check src/cli/cli.js && node --check src/cli/d1.js && node --check src/cli/operations.js && node --check src/cli/project.js && node --check src/cli/status.js && node --check src/cli/tenants.js && node --check src/cli/users.js && node --check src/cli/version.js",
|
|
43
53
|
"test": "node --test tests/*.test.mjs",
|
|
44
54
|
"build": "npm run check && npm test && npm pack --dry-run"
|
|
45
55
|
}
|
package/src/auth/index.js
CHANGED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { sameOrigin, secureJson, secureText } from "../core/security.js";
|
|
2
|
+
import { ensureUser } from "./users.js";
|
|
3
|
+
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
const decoder = new TextDecoder();
|
|
6
|
+
const configurationCache = new Map();
|
|
7
|
+
const configurationRequests = new Map();
|
|
8
|
+
const jwksCache = new Map();
|
|
9
|
+
const jwksRequests = new Map();
|
|
10
|
+
const OIDC_CACHE_MS = 15 * 60 * 1000;
|
|
11
|
+
const OIDC_TIMEOUT_MS = 10_000;
|
|
12
|
+
export const OAUTH_SINGLE = "OAUTH_SINGLE";
|
|
13
|
+
export const PACKAGE_NAME = "@agilesyndrome/cf-genai-base";
|
|
14
|
+
export const VERSION = "5.0.0";
|
|
15
|
+
|
|
16
|
+
export const authRepositoryDefinitions = [
|
|
17
|
+
{ name: "users", resource: "auth_users", scope: "system", resourceDefinition: { name: "auth_users", table: "auth_users", scope: "system", columns: ["id", "provider", "subject", "email", "display_name", "is_admin", "created_at", "updated_at"], readableColumns: ["id", "provider", "subject", "email", "display_name", "is_admin", "created_at", "updated_at"], orderableColumns: ["id", "email", "created_at"], writableColumns: ["display_name", "is_admin"] } },
|
|
18
|
+
{ name: "groups", resource: "auth_groups", scope: "system", resourceDefinition: { name: "auth_groups", table: "auth_groups", scope: "system", columns: ["name", "display_name", "description", "created_at", "updated_at"], idColumn: "name", readableColumns: ["name", "display_name", "description", "created_at", "updated_at"], orderableColumns: ["name", "created_at"], writableColumns: ["name", "display_name", "description"] } },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generic OIDC auth for Workers. It uses Authorization Code + PKCE and a
|
|
23
|
+
* signed, host-only cookie, so a site needs no auth service of its own.
|
|
24
|
+
* Site-specific roles can be derived from `claims` in `onLogin`.
|
|
25
|
+
*/
|
|
26
|
+
export function createAuth(options = {}) {
|
|
27
|
+
const prefix = options.cookiePrefix || "__Host-cfgenai";
|
|
28
|
+
if (!/^(?:__Host-)?[A-Za-z0-9_-]+$/.test(prefix)) throw new Error("cookiePrefix contains invalid characters");
|
|
29
|
+
const names = { state: options.stateCookieName || `${prefix}_state`, session: options.sessionCookieName || `${prefix}_session` };
|
|
30
|
+
const publicPaths = options.publicPaths || ["/auth/", "/favicon.svg", "/robots.txt", "/health"];
|
|
31
|
+
const protectedPath = options.protectedPath || (() => true);
|
|
32
|
+
const envName = (key, fallback) => options.env?.[key] || fallback;
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
name: "auth", displayName: options.displayName || "Authentication", packageName: PACKAGE_NAME, version: VERSION, strategy: OAUTH_SINGLE, repositories: options.repositories || authRepositoryDefinitions,
|
|
36
|
+
dataResources: options.dataResources || [], routes: options.routes || [],
|
|
37
|
+
healthchecks: options.healthchecks || [], circuitBreakers: options.circuitBreakers || [],
|
|
38
|
+
async handle(request, env) {
|
|
39
|
+
const url = new URL(request.url);
|
|
40
|
+
if (url.pathname === "/auth/login") return login(request, env);
|
|
41
|
+
if (url.pathname === "/auth/callback") return callback(request, env);
|
|
42
|
+
if (url.pathname === "/auth/logout") {
|
|
43
|
+
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST", "Cache-Control": "no-store" } });
|
|
44
|
+
const originResponse = checkOrigin(request, options.allowedOrigins);
|
|
45
|
+
if (originResponse) return originResponse;
|
|
46
|
+
return logout(request, env, names.session, options);
|
|
47
|
+
}
|
|
48
|
+
if (url.pathname === "/api/me") return secureJson({ user: publicUser(await resolveUser(request, env)) });
|
|
49
|
+
if (!protectedPath(url.pathname) || publicPaths.some((path) => path === "/" ? url.pathname === "/" : url.pathname.startsWith(path))) return null;
|
|
50
|
+
if (isMutation(request)) { const originResponse = checkOrigin(request, options.allowedOrigins); if (originResponse) return originResponse; }
|
|
51
|
+
const user = await resolveUser(request, env);
|
|
52
|
+
if (user && options.authorize && !(await options.authorize({ request, url, user, env }))) {
|
|
53
|
+
return url.pathname.startsWith("/api/") ? Response.json({ error: "Administrator access is required." }, { status: 403, headers: { "Cache-Control": "no-store" } }) : authError("Administrator access is required.", 403);
|
|
54
|
+
}
|
|
55
|
+
if (user) return null;
|
|
56
|
+
if (url.pathname.startsWith("/api/")) return Response.json({ error: "Authentication is required." }, { status: 401, headers: { "Cache-Control": "no-store" } });
|
|
57
|
+
return Response.redirect(`${url.origin}/auth/login?return_to=${encodeURIComponent(safeReturnTo(url.pathname + url.search))}`, 302);
|
|
58
|
+
},
|
|
59
|
+
getUser: (request, env) => resolveUser(request, env),
|
|
60
|
+
healthcheck: async (env) => ({ feature: "auth", component: "configuration", displayName: "Authentication configuration", state: Boolean(env?.DB) && [envName("issuer", "OIDC_ISSUER"), envName("clientId", "OIDC_CLIENT_ID"), envName("clientSecret", "OIDC_CLIENT_SECRET"), envName("sessionSecret", "AUTH_SESSION_SECRET")].every((key) => env?.[key] && !String(env[key]).startsWith("replace-with-")) ? "green" : "red" }),
|
|
61
|
+
middleware(request, env, ctx, next, state) {
|
|
62
|
+
return this.handle(request, env, ctx).then((response) => response || next(request, env, ctx, state));
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
async function login(request, env) {
|
|
67
|
+
if (options.getSession && await resolveUser(request, env)) {
|
|
68
|
+
const target = safeReturnTo(new URL(request.url).searchParams.get("return_to") || "/");
|
|
69
|
+
return redirect(new URL(request.url).origin + target, [clearCookie(names.state)]);
|
|
70
|
+
}
|
|
71
|
+
const config = await configuration(env, options);
|
|
72
|
+
const state = random();
|
|
73
|
+
const verifier = random();
|
|
74
|
+
const nonce = random();
|
|
75
|
+
const challenge = base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(verifier))));
|
|
76
|
+
const returnTo = safeReturnTo(new URL(request.url).searchParams.get("return_to") || "/");
|
|
77
|
+
const stateValue = `${state}.${verifier}.${nonce}.${base64url(encoder.encode(returnTo))}`;
|
|
78
|
+
const authorize = new URL(config.authorization_endpoint);
|
|
79
|
+
authorize.search = new URLSearchParams({ client_id: required(env, envName("clientId", "OIDC_CLIENT_ID")), response_type: "code", redirect_uri: callbackUrl(request, env, options), scope: options.scope || "openid profile email", state, code_challenge: challenge, code_challenge_method: "S256", nonce }).toString();
|
|
80
|
+
return redirect(authorize, [cookie(names.state, stateValue, 600)]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function callback(request, env) {
|
|
84
|
+
const url = new URL(request.url);
|
|
85
|
+
const value = cookies(request)[names.state] || "";
|
|
86
|
+
const [state, verifier, nonce, encodedReturn] = value.split(".");
|
|
87
|
+
if (!state || !constantTimeEqual(state, url.searchParams.get("state") || "") || !verifier) {
|
|
88
|
+
if (options.getSession && await resolveUser(request, env)) return redirect(new URL(request.url).origin, [clearCookie(names.state)]);
|
|
89
|
+
return authError("The sign-in state was invalid or expired.", 400);
|
|
90
|
+
}
|
|
91
|
+
const config = await configuration(env, options);
|
|
92
|
+
const clientId = required(env, envName("clientId", "OIDC_CLIENT_ID"));
|
|
93
|
+
const token = await fetchWithTimeout(config.token_endpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: clientId, client_secret: required(env, envName("clientSecret", "OIDC_CLIENT_SECRET")), grant_type: "authorization_code", code: url.searchParams.get("code") || "", redirect_uri: callbackUrl(request, env, options), code_verifier: verifier }) }).then((response) => response.ok ? response.json() : Promise.reject(new Error("OIDC token exchange failed")));
|
|
94
|
+
const claims = await verify(token.id_token, config, clientId, nonce);
|
|
95
|
+
if (!claims.sub) return authError("The identity provider returned no subject.", 502);
|
|
96
|
+
const user = await resolveLoginUser({ ...(await (options.onLogin ? options.onLogin(claims, env) : normalizeUser(claims))), email_verified: claims.email_verified === true }, env, request);
|
|
97
|
+
const identity = sessionIdentity(user);
|
|
98
|
+
const signed = options.createSession ? await options.createSession(identity, env, request) : await sign(JSON.stringify({ ...identity, exp: Math.floor(Date.now() / 1000) + (options.sessionSeconds || 28800) }), env, envName("sessionSecret", "AUTH_SESSION_SECRET"));
|
|
99
|
+
await env.event?.("auth.login.succeeded", "auth", { userId: user.authUser.id });
|
|
100
|
+
return redirect(`${url.origin}${decodeReturn(encodedReturn)}`, [cookie(names.session, signed, options.sessionSeconds || 28800), clearCookie(names.state)]);
|
|
101
|
+
}
|
|
102
|
+
async function resolveUser(request, env) {
|
|
103
|
+
const user = await hydrateUser(await getUser(request, env, envName("sessionSecret", "AUTH_SESSION_SECRET"), names.session, options), env);
|
|
104
|
+
if (user && options.sessionAuthorize && !(await options.sessionAuthorize({ user, request, env }))) return null;
|
|
105
|
+
return user;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function resolveLoginUser(user, env, request) {
|
|
109
|
+
const hydrated = await hydrateUser(user, env);
|
|
110
|
+
if (options.loginAuthorize && !(await options.loginAuthorize({ user: hydrated, request, env }))) throw authError("Authentication is not currently permitted.", 403);
|
|
111
|
+
return hydrated;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function hydrateUser(user, env) {
|
|
116
|
+
if (!user) return user;
|
|
117
|
+
if (!env?.DB) throw new Error("OAUTH_SINGLE requires the base DB binding for canonical users");
|
|
118
|
+
const authUser = await ensureUser(env, user, { who: `user:${user.sub || "unknown"}` });
|
|
119
|
+
return { ...user, authUser };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function getUser(request, env, secretName = "AUTH_SESSION_SECRET", sessionName = "__Host-cfgenai_session", options = {}) {
|
|
123
|
+
const token = cookies(request)[sessionName];
|
|
124
|
+
if (!token) return null;
|
|
125
|
+
if (options.getSession) return (await options.getSession(token, env, request)) || null;
|
|
126
|
+
const [payload, signature] = token.split(".");
|
|
127
|
+
if (!payload || !signature || !constantTimeEqual(signature, await sign(payload, env, secretName, false))) return null;
|
|
128
|
+
try { const user = JSON.parse(decoder.decode(decode(payload))); return typeof user.exp === "number" && user.exp > Date.now() / 1000 ? user : null; } catch { return null; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function configuration(env, options = {}) {
|
|
132
|
+
const discoveryName = envNameFor(options, "discoveryUrl", "OIDC_DISCOVERY_URL");
|
|
133
|
+
const configuredDiscovery = env[discoveryName];
|
|
134
|
+
const discoveryUrl = configuredDiscovery ? (String(configuredDiscovery).endsWith("/.well-known/openid-configuration") ? configuredDiscovery : String(configuredDiscovery).replace(/\/+$/, "") + "/.well-known/openid-configuration") : normalizeIssuer(required(env, envNameFor(options, "issuer", "OIDC_ISSUER"))).slice(0, -1) + "/.well-known/openid-configuration";
|
|
135
|
+
if (new URL(discoveryUrl).protocol !== "https:") throw new Error("OIDC discovery URL must use HTTPS");
|
|
136
|
+
|
|
137
|
+
const cached = configurationCache.get(discoveryUrl); if (cached && cached.exp > Date.now()) return cached.value;
|
|
138
|
+
const existing = configurationRequests.get(discoveryUrl); if (existing) return existing;
|
|
139
|
+
const request = fetchWithTimeout(discoveryUrl).then(async (response) => {
|
|
140
|
+
if (!response.ok) throw new Error("Unable to load OIDC configuration");
|
|
141
|
+
const value = await response.json();
|
|
142
|
+
if (!value.issuer || new URL(value.issuer).protocol !== "https:") throw new Error("OIDC configuration returned an invalid issuer");
|
|
143
|
+
if (!value.jwks_uri || new URL(value.jwks_uri).protocol !== "https:") throw new Error("OIDC configuration returned an invalid JWKS URL");
|
|
144
|
+
configurationCache.set(discoveryUrl, { value, exp: Date.now() + OIDC_CACHE_MS }); return value;
|
|
145
|
+
}).finally(() => configurationRequests.delete(discoveryUrl));
|
|
146
|
+
configurationRequests.set(discoveryUrl, request); return request;
|
|
147
|
+
}
|
|
148
|
+
async function verify(token, config, clientId, expectedNonce) {
|
|
149
|
+
const [head, body, signature] = String(token || "").split("."); if (!head || !body || !signature) throw new Error("Malformed ID token");
|
|
150
|
+
const header = JSON.parse(decoder.decode(decode(head))); if (header.alg !== "RS256" || !header.kid) throw new Error("Unsupported ID token signature"); const keys = await getJwks(config.jwks_uri); const jwk = keys.keys.find((key) => key.kid === header.kid); if (!jwk) throw new Error("ID token signing key was not found");
|
|
151
|
+
const key = await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]);
|
|
152
|
+
if (!await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, decode(signature), encoder.encode(`${head}.${body}`))) throw new Error("Invalid ID token");
|
|
153
|
+
const claims = JSON.parse(decoder.decode(decode(body))); const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; const validAzp = !Array.isArray(claims.aud) || claims.aud.length < 2 || claims.azp === clientId;
|
|
154
|
+
if (normalizeIssuer(claims.iss || "") !== normalizeIssuer(config.issuer || "") || !aud.includes(clientId) || !validAzp || claims.exp <= Date.now() / 1000 || claims.nonce !== expectedNonce || !claims.sub) throw new Error("Invalid ID token claims"); return claims;
|
|
155
|
+
}
|
|
156
|
+
async function getJwks(uri) {
|
|
157
|
+
const cached = jwksCache.get(uri); if (cached && cached.exp > Date.now()) return cached.value;
|
|
158
|
+
const existing = jwksRequests.get(uri); if (existing) return existing;
|
|
159
|
+
const request = fetchWithTimeout(uri).then(async (response) => { if (!response.ok) throw new Error("Unable to load OIDC signing keys"); const value = await response.json(); jwksCache.set(uri, { value, exp: Date.now() + OIDC_CACHE_MS }); return value; }).finally(() => jwksRequests.delete(uri));
|
|
160
|
+
jwksRequests.set(uri, request); return request;
|
|
161
|
+
}
|
|
162
|
+
async function fetchWithTimeout(input, init = {}) { return fetch(input, { ...init, signal: AbortSignal.timeout(OIDC_TIMEOUT_MS) }); }
|
|
163
|
+
function normalizeIssuer(value) { return String(value).replace(/\/+$/, "") + "/"; }
|
|
164
|
+
function isMutation(request) { return ["POST", "PUT", "PATCH", "DELETE"].includes(request.method); }
|
|
165
|
+
function checkOrigin(request, allowedOrigins = []) {
|
|
166
|
+
let requestOrigin;
|
|
167
|
+
try { requestOrigin = new URL(request.url).origin; } catch { return authError("This request did not pass the same-origin check.", 403); }
|
|
168
|
+
return sameOrigin(request, [requestOrigin, ...allowedOrigins]) ? null : authError("A same-origin request is required.", 403);
|
|
169
|
+
}
|
|
170
|
+
async function sign(value, env, name, encoded = true) { const secret = required(env, name); if (name === "AUTH_SESSION_SECRET" && secret.length < 32) throw new Error("AUTH_SESSION_SECRET must be at least 32 characters"); const data = encoded ? base64url(encoder.encode(value)) : value; const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const sig = base64url(new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(data)))); return encoded ? `${data}.${sig}` : sig; }
|
|
171
|
+
function normalizeUser(claims) { return { sub: claims.sub, email: String(claims.email || "").toLowerCase(), name: claims.name || claims.email || claims.sub, email_verified: claims.email_verified === true, auth_strategy: OAUTH_SINGLE }; }
|
|
172
|
+
function envNameFor(options, key, fallback) { return options.env?.[key] || fallback; }
|
|
173
|
+
function required(env, key) { if (!env[key] || String(env[key]).startsWith("replace-with-")) throw new Error(`${key} is not configured`); return String(env[key]); }
|
|
174
|
+
function random() { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return base64url(bytes); }
|
|
175
|
+
function base64url(bytes) { let s = ""; for (const b of bytes) s += String.fromCharCode(b); return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); }
|
|
176
|
+
function decode(value) { return Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - value.length % 4) % 4)), (c) => c.charCodeAt(0)); }
|
|
177
|
+
function cookies(request) { return Object.fromEntries((request.headers.get("Cookie") || "").split(";").flatMap((part) => { const i = part.indexOf("="); return i < 0 ? [] : [[part.slice(0, i).trim(), part.slice(i + 1).trim()]]; })); }
|
|
178
|
+
function cookie(name, value, age) { return `${name}=${value}; Max-Age=${age}; Path=/; HttpOnly; Secure; SameSite=Lax`; }
|
|
179
|
+
function clearCookie(name) { return `${name}=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax`; }
|
|
180
|
+
function redirect(location, setCookies) { const response = new Response(null, { status: 302, headers: { Location: String(location), "Cache-Control": "no-store" } }); for (const value of setCookies) response.headers.append("Set-Cookie", value); return response; }
|
|
181
|
+
async function logout(request, env, name, options = {}) {
|
|
182
|
+
const token = cookies(request)[name];
|
|
183
|
+
if (token && options.revokeSession) await options.revokeSession(token, env, request);
|
|
184
|
+
await env.event?.("auth.logout", "auth");
|
|
185
|
+
return redirect(new URL(request.url).origin + "/", [clearCookie(name)]);
|
|
186
|
+
}
|
|
187
|
+
function authError(message, status) { return secureText(message, status); }
|
|
188
|
+
function callbackUrl(request, env, options) { const configuredOrigin = env[envNameFor(options, "publicOrigin", "PUBLIC_ORIGIN")]; return (configuredOrigin ? new URL(String(configuredOrigin)).origin : new URL(request.url).origin) + "/auth/callback"; }
|
|
189
|
+
function safeReturnTo(value) { return value?.startsWith("/") && !value.startsWith("//") && !value.startsWith("/auth/") ? value : "/"; }
|
|
190
|
+
function decodeReturn(value) { try { return safeReturnTo(decoder.decode(decode(value))); } catch { return "/"; } }
|
|
191
|
+
function constantTimeEqual(a, b) { const aa = encoder.encode(a), bb = encoder.encode(b); let n = aa.length ^ bb.length; for (let i = 0; i < Math.max(aa.length, bb.length); i++) n |= (aa[i] || 0) ^ (bb[i] || 0); return n === 0; }
|
|
192
|
+
function sessionIdentity(user) { const { authUser: _authUser, exp: _exp, ...identity } = user || {}; return identity; }
|
|
193
|
+
function publicUser(user) { return user ? { id: user.authUser.id, email: user.email || user.authUser.email, name: user.name || user.authUser.display_name, isAdmin: Boolean(user.authUser.is_admin) } : null; }
|
package/src/cli/cli.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { backupD1, checkConfig, checkD1, migrateD1, refreshLocalD1, refreshStagingD1, restoreD1, statusD1 } from "./d1.js";
|
|
2
|
+
import { runUserCommand } from "./users.js";
|
|
3
|
+
import { runTenantCommand } from "./tenants.js";
|
|
4
|
+
import { runOperationalCommand } from "./operations.js";
|
|
5
|
+
import { runProjectCommand } from "./project.js";
|
|
6
|
+
import { runVersionCommand } from "./version.js";
|
|
7
|
+
import { siteStatus } from "./status.js";
|
|
8
|
+
|
|
9
|
+
const usage = `Usage:
|
|
10
|
+
cf-genai check|test|build|ci|ci:lint
|
|
11
|
+
cf-genai lint data-access
|
|
12
|
+
cf-genai dev [options]
|
|
13
|
+
cf-genai upgrade <base|auth|llm|messaging> <latest|VERSION>
|
|
14
|
+
cf-genai release [--confirm] [--first] [--add-trust] [--dry-run] [--bypass-lint] [--type patch|minor|major] [--version MAJOR.MINOR]
|
|
15
|
+
cf-genai release-status [--wait MINUTES] [--json]
|
|
16
|
+
cf-genai status [--env local|staging|production] [--json]
|
|
17
|
+
cf-genai version
|
|
18
|
+
cf-genai d1 refresh|backup|restore|migrate|status|check local|staging|production [options]
|
|
19
|
+
cf-genai admin status|features|users|tenants|scopes|groups|healthchecks|circuit-breakers [options]
|
|
20
|
+
cf-genai config check [options]
|
|
21
|
+
cf-genai user list|get|update [username] [options]
|
|
22
|
+
cf-genai tenant list|get|create|update [tenant-id] [options]
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
--database NAME D1 binding or database name (default: DB)
|
|
26
|
+
--production-database NAME Production D1 binding or database name
|
|
27
|
+
--production-env NAME Wrangler production environment (default: none)
|
|
28
|
+
--staging-env NAME Wrangler staging environment (default: staging)
|
|
29
|
+
--config PATH Wrangler config path (default: wrangler.jsonc)
|
|
30
|
+
--env VALUE Environment alias: local, staging, or prod
|
|
31
|
+
--output PATH Backup output file
|
|
32
|
+
--file PATH Restore input file
|
|
33
|
+
--wrangler COMMAND Wrangler command (default: npx wrangler)
|
|
34
|
+
--yes Confirm a remote staging refresh
|
|
35
|
+
--confirm-production Explicitly permit production migration or breaker changes
|
|
36
|
+
--confirm Confirm a destructive or release operation
|
|
37
|
+
--dry-run Show release checks without changing Git or npm
|
|
38
|
+
--version MAJOR.MINOR Explicit release target; patch is assigned as .0
|
|
39
|
+
--bypass-lint Skip the release preflight lint (use sparingly)
|
|
40
|
+
--json Return machine-readable output
|
|
41
|
+
--help Show this help
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
function splitCommand(value = "npx wrangler") {
|
|
45
|
+
return value.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((part) => part.replace(/^(["'])(.*)\1$/, "$2")) ?? ["npx", "wrangler"];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseOptions(args, env = process.env) {
|
|
49
|
+
const options = {
|
|
50
|
+
config: env.CF_GENAI_WRANGLER_CONFIG || "wrangler.jsonc",
|
|
51
|
+
database: env.CF_GENAI_DATABASE || "DB",
|
|
52
|
+
productionEnv: env.CF_GENAI_PRODUCTION_ENV || "",
|
|
53
|
+
stagingEnv: env.CF_GENAI_STAGING_ENV || "staging",
|
|
54
|
+
wranglerCommand: splitCommand(env.CF_GENAI_WRANGLER || "npx wrangler"),
|
|
55
|
+
yes: false,
|
|
56
|
+
confirmProduction: false,
|
|
57
|
+
target: env.CF_GENAI_TARGET || "local",
|
|
58
|
+
roles: undefined,
|
|
59
|
+
scopes: undefined,
|
|
60
|
+
groups: undefined,
|
|
61
|
+
tenants: undefined,
|
|
62
|
+
name: undefined,
|
|
63
|
+
json: false,
|
|
64
|
+
};
|
|
65
|
+
const positional = [];
|
|
66
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
67
|
+
const arg = args[index];
|
|
68
|
+
if (arg === "--help" || arg === "-h") return { ...options, help: true, positional };
|
|
69
|
+
if (arg === "--yes") options.yes = true;
|
|
70
|
+
else if (arg === "--json") options.json = true
|
|
71
|
+
else if (arg === "--confirm-production") options.confirmProduction = true;
|
|
72
|
+
else if (arg.startsWith("--")) {
|
|
73
|
+
const [key, inline] = arg.slice(2).split("=", 2);
|
|
74
|
+
const names = {
|
|
75
|
+
database: "database", "production-database": "productionDatabase",
|
|
76
|
+
"production-env": "productionEnv", "staging-env": "stagingEnv",
|
|
77
|
+
config: "config", wrangler: "wrangler", target: "target", env: "target", roles: "roles", scopes: "scopes", groups: "groups", tenants: "tenants", name: "name", output: "output", file: "file",
|
|
78
|
+
};
|
|
79
|
+
const optionName = names[key];
|
|
80
|
+
if (!optionName) throw new Error(`Unknown option: ${arg}`);
|
|
81
|
+
const value = inline ?? args[++index];
|
|
82
|
+
if (value === undefined) throw new Error(`Missing value for ${arg}.`);
|
|
83
|
+
if (optionName === "wrangler") options.wranglerCommand = splitCommand(value);
|
|
84
|
+
else options[optionName] = value;
|
|
85
|
+
} else positional.push(arg);
|
|
86
|
+
}
|
|
87
|
+
return { ...options, positional, productionDatabase: options.productionDatabase || options.database };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function main(args = process.argv.slice(2), env = process.env) {
|
|
91
|
+
if (args[0]?.includes(":")) { const [domain, action] = args[0].split(":", 2); if (["user", "tenant", "healthcheck", "healthchecks", "circuit-breaker", "circuit-breakers", "circuit"].includes(domain)) args = [domain, action, ...args.slice(1)]; }
|
|
92
|
+
if (args[0] === "version") return runVersionCommand();
|
|
93
|
+
const projectCommand = args[0];
|
|
94
|
+
if (["check", "test", "build", "ci", "ci:lint", "lint", "dev", "upgrade", "release", "release-status"].includes(projectCommand)) {
|
|
95
|
+
const result = runProjectCommand(projectCommand, args.slice(1));
|
|
96
|
+
if (result === null) throw new Error(`Unknown command.\n\n${usage}`);
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
const options = parseOptions(args, env);
|
|
100
|
+
if (options.positional[0] === "status") return siteStatus(options);
|
|
101
|
+
if (options.help || options.positional.length === 0) {
|
|
102
|
+
console.log(usage);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
let [domain, action, target] = options.positional;
|
|
106
|
+
if (domain === "admin") { const adminAction = { user: "users", tenant: "tenants", healthcheck: "healthchecks", "circuit-breaker": "circuit-breakers", circuit: "circuit-breakers" }[action] || action; if (["local", "staging", "prod", "production"].includes(target)) options.target = target === "prod" ? "production" : target; if (adminAction === "tenants") return runTenantCommand({ action: options.positional[2] || "list", identifier: options.positional[3], name: options.name, options }); if (["healthchecks", "circuit-breakers"].includes(adminAction)) return runOperationalCommand({ domain: adminAction, action: options.positional[2] || "list", identifier: options.positional[3], value: options.positional[4], options }); return runOperationalCommand({ domain: "admin", action: adminAction, identifier: options.positional[3], value: options.positional[4], options }); }
|
|
107
|
+
if (domain === "backup" || domain === "restore") { target = action || options.target; action = domain; return runD1FileCommand(action, target, options); }
|
|
108
|
+
if (domain === "user") return runUserCommand({ action, username: target, options });
|
|
109
|
+
if (domain === "tenant") return runTenantCommand({ action, identifier: target, name: options.name, options });
|
|
110
|
+
if (["healthcheck", "healthchecks", "circuit-breaker", "circuit-breakers", "circuit"].includes(domain)) return runOperationalCommand({ domain, action, identifier: target, value: options.positional[3], options });
|
|
111
|
+
if (domain === "config" && action === "check") return checkConfig(options);
|
|
112
|
+
if (domain !== "d1" || !["refresh", "backup", "restore", "migrate", "status", "check"].includes(action)) throw new Error(`Unknown command.\n\n${usage}`);
|
|
113
|
+
target = target === "prod" ? "production" : target;
|
|
114
|
+
if (!["local", "staging", "production"].includes(target)) throw new Error("Target must be local, staging, or production.");
|
|
115
|
+
if (action === "backup" || action === "restore") return runD1FileCommand(action, target, options);
|
|
116
|
+
if (action === "refresh") {
|
|
117
|
+
if (target === "staging" && !options.yes) throw new Error("Staging refresh replaces remote data; rerun with --yes.");
|
|
118
|
+
if (target === "local") return refreshLocalD1({ database: options.database, productionDatabase: options.productionDatabase, options });
|
|
119
|
+
if (target === "staging") return refreshStagingD1({ database: options.database, productionDatabase: options.productionDatabase, options });
|
|
120
|
+
throw new Error(`Unknown command.\n\n${usage}`);
|
|
121
|
+
}
|
|
122
|
+
if (action === "migrate" && target === "production" && !options.confirmProduction) throw new Error("Production migration requires --confirm-production.");
|
|
123
|
+
if (action === "migrate") return migrateD1({ target, database: options.database, options });
|
|
124
|
+
if (action === "status") return statusD1({ target, database: options.database, options });
|
|
125
|
+
return checkD1({ target, database: options.database, options });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function runD1FileCommand(action, target, options) {
|
|
129
|
+
if (action === "backup") return backupD1({ target, database: options.database, options });
|
|
130
|
+
return restoreD1({ target, database: options.database, options });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export { parseOptions, usage };
|