feat(cli): add deploy command + CF 1042 retry (#218)
- deploy: one-click OGraph deployment to Cloudflare (D1 + Worker) - cloudflare.ts: CF REST API client (verify, accounts, D1, Worker upload) - client.ts: auto-retry on CF 1042 edge propagation delay - readiness check: require 3 consecutive health OKs before declaring ready - 49 CLI tests passing (18 new deploy tests)
This commit is contained in:
parent
d84a860d15
commit
d520df29d4
119
package-lock.json
generated
119
package-lock.json
generated
@ -475,11 +475,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -508,11 +510,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -541,11 +545,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openharmony"
|
"openharmony"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -3225,6 +3231,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@ -3246,6 +3253,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@ -3267,6 +3275,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@ -3288,6 +3297,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@ -3309,6 +3319,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@ -3330,6 +3341,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@ -3351,6 +3363,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@ -3372,6 +3385,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@ -3393,6 +3407,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@ -3414,6 +3429,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@ -3435,6 +3451,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@ -5168,11 +5185,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"aix"
|
"aix"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5184,11 +5203,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5200,11 +5221,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5216,11 +5239,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5232,11 +5257,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5248,11 +5275,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5264,11 +5293,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5280,11 +5311,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5296,11 +5329,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5312,11 +5347,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5328,11 +5365,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5344,11 +5383,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5360,11 +5401,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5376,11 +5419,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5392,11 +5437,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5408,11 +5455,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5424,11 +5473,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5440,11 +5491,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5456,11 +5509,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5472,11 +5527,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"sunos"
|
"sunos"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5488,11 +5545,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5504,11 +5563,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5520,11 +5581,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -5643,9 +5706,11 @@
|
|||||||
"version": "0.28.0",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -88,7 +88,7 @@ export class OGraphClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
private async request<T>(path: string, options: RequestInit = {}, retries = 2): Promise<T> {
|
||||||
const url = `${this.endpoint}${path}`
|
const url = `${this.endpoint}${path}`
|
||||||
|
|
||||||
const headers = new Headers(options.headers)
|
const headers = new Headers(options.headers)
|
||||||
@ -97,25 +97,52 @@ export class OGraphClient {
|
|||||||
headers.set('Content-Type', 'application/json')
|
headers.set('Content-Type', 'application/json')
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
let lastError: Error | undefined
|
||||||
const response = await fetch(url, { ...options, headers })
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
const result = await response.json()
|
try {
|
||||||
|
const response = await fetch(url, { ...options, headers })
|
||||||
|
|
||||||
if (!response.ok) {
|
// CF 1042 returns 404 with HTML — detect and retry
|
||||||
if (response.status === 401) {
|
const contentType = response.headers.get('content-type') ?? ''
|
||||||
throw new Error('Authentication failed. Check your token.')
|
if (!contentType.includes('application/json')) {
|
||||||
|
const text = await response.text()
|
||||||
|
if (text.includes('1042') || text.includes('DOCTYPE')) {
|
||||||
|
lastError = new Error('Worker not reachable (CF 1042 — edge propagation delay)')
|
||||||
|
if (attempt < retries) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2000 * (attempt + 1)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected response: ${text.slice(0, 100)}`)
|
||||||
}
|
}
|
||||||
const errorMessage = (result as { error?: string }).error ?? `HTTP ${response.status}: ${response.statusText}`
|
|
||||||
throw new Error(errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result as T
|
const result = await response.json()
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message.includes('fetch')) {
|
if (!response.ok) {
|
||||||
throw new Error(`Cannot reach OGraph API at ${this.endpoint}`)
|
if (response.status === 401) {
|
||||||
|
throw new Error('Authentication failed. Check your token.')
|
||||||
|
}
|
||||||
|
const errorMessage =
|
||||||
|
(result as { error?: string }).error ?? `HTTP ${response.status}: ${response.statusText}`
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as T
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes('fetch')) {
|
||||||
|
throw new Error(`Cannot reach OGraph API at ${this.endpoint}`)
|
||||||
|
}
|
||||||
|
// Retry on CF 1042 / propagation errors
|
||||||
|
if (error instanceof Error && error.message.includes('1042') && attempt < retries) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2000 * (attempt + 1)))
|
||||||
|
lastError = error
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
|
throw lastError ?? new Error('Request failed after retries')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Object-Defs ───────────────────────────────────────────────────────────────
|
// ─── Object-Defs ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
206
packages/cli/src/cloudflare.ts
Normal file
206
packages/cli/src/cloudflare.ts
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
// Cloudflare REST API client for OGraph deploy
|
||||||
|
// Direct HTTP calls — no wrangler dependency
|
||||||
|
|
||||||
|
// ─── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CfAccount {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CfD1Database {
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CfApiResponse<T> {
|
||||||
|
success: boolean
|
||||||
|
result: T
|
||||||
|
errors: Array<{ code: number; message: string }>
|
||||||
|
messages: Array<{ code: number; message: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CfD1QueryResult {
|
||||||
|
success: boolean
|
||||||
|
results: unknown[]
|
||||||
|
meta?: { changes: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CfWorkerScript {
|
||||||
|
id: string
|
||||||
|
etag?: string
|
||||||
|
created_on?: string
|
||||||
|
modified_on?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Client ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CF_BASE = 'https://api.cloudflare.com/client/v4'
|
||||||
|
|
||||||
|
export class CloudflareClient {
|
||||||
|
constructor(private readonly apiToken: string) {}
|
||||||
|
|
||||||
|
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const url = `${CF_BASE}${path}`
|
||||||
|
const headers = new Headers(options.headers)
|
||||||
|
headers.set('Authorization', `Bearer ${this.apiToken}`)
|
||||||
|
if (options.body && typeof options.body === 'string') {
|
||||||
|
headers.set('Content-Type', 'application/json')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { ...options, headers })
|
||||||
|
const json = (await response.json()) as CfApiResponse<T>
|
||||||
|
|
||||||
|
if (!json.success) {
|
||||||
|
const msgs = json.errors.map((e) => `[${e.code}] ${e.message}`).join('; ')
|
||||||
|
throw new Error(`Cloudflare API error: ${msgs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Token Verification ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async verifyToken(): Promise<{ id: string; status: string }> {
|
||||||
|
return this.request<{ id: string; status: string }>('/user/tokens/verify')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Accounts ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async listAccounts(): Promise<CfAccount[]> {
|
||||||
|
return this.request<CfAccount[]>('/accounts')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── D1 Database ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async createD1Database(accountId: string, name: string): Promise<CfD1Database> {
|
||||||
|
return this.request<CfD1Database>(`/accounts/${accountId}/d1/database`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryD1(accountId: string, databaseId: string, sql: string): Promise<CfD1QueryResult[]> {
|
||||||
|
return this.request<CfD1QueryResult[]>(`/accounts/${accountId}/d1/database/${databaseId}/query`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ sql }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Workers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async uploadWorker(
|
||||||
|
accountId: string,
|
||||||
|
scriptName: string,
|
||||||
|
scriptContent: string,
|
||||||
|
bindings: {
|
||||||
|
d1DatabaseId: string
|
||||||
|
d1BindingName: string
|
||||||
|
apiToken: string
|
||||||
|
version: string
|
||||||
|
},
|
||||||
|
): Promise<CfWorkerScript> {
|
||||||
|
const metadata = {
|
||||||
|
main_module: 'worker.js',
|
||||||
|
bindings: [
|
||||||
|
{
|
||||||
|
type: 'd1',
|
||||||
|
name: bindings.d1BindingName,
|
||||||
|
id: bindings.d1DatabaseId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'secret_text',
|
||||||
|
name: 'API_TOKEN',
|
||||||
|
text: bindings.apiToken,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'plain_text',
|
||||||
|
name: 'VERSION',
|
||||||
|
text: bindings.version,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
compatibility_date: '2026-04-03',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build multipart form data
|
||||||
|
const boundary = `----OGraphDeploy${Date.now()}`
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
// Metadata part
|
||||||
|
parts.push(`--${boundary}`)
|
||||||
|
parts.push('Content-Disposition: form-data; name="metadata"; filename="metadata.json"')
|
||||||
|
parts.push('Content-Type: application/json')
|
||||||
|
parts.push('')
|
||||||
|
parts.push(JSON.stringify(metadata))
|
||||||
|
|
||||||
|
// Script part
|
||||||
|
parts.push(`--${boundary}`)
|
||||||
|
parts.push('Content-Disposition: form-data; name="worker.js"; filename="worker.js"')
|
||||||
|
parts.push('Content-Type: application/javascript+module')
|
||||||
|
parts.push('')
|
||||||
|
parts.push(scriptContent)
|
||||||
|
|
||||||
|
parts.push(`--${boundary}--`)
|
||||||
|
|
||||||
|
const body = parts.join('\r\n')
|
||||||
|
|
||||||
|
const url = `${CF_BASE}/accounts/${accountId}/workers/scripts/${scriptName}`
|
||||||
|
const headers = new Headers()
|
||||||
|
headers.set('Authorization', `Bearer ${this.apiToken}`)
|
||||||
|
headers.set('Content-Type', `multipart/form-data; boundary=${boundary}`)
|
||||||
|
|
||||||
|
const response = await fetch(url, { method: 'PUT', headers, body })
|
||||||
|
const json = (await response.json()) as CfApiResponse<CfWorkerScript>
|
||||||
|
|
||||||
|
if (!json.success) {
|
||||||
|
const msgs = json.errors.map((e) => `[${e.code}] ${e.message}`).join('; ')
|
||||||
|
throw new Error(`Worker upload failed: ${msgs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Workers Subdomain ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getWorkersSubdomain(accountId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const result = await this.request<{ subdomain: string }>(`/accounts/${accountId}/workers/subdomain`)
|
||||||
|
return result.subdomain ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Workers.dev Route ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async enableWorkersDevRoute(
|
||||||
|
accountId: string,
|
||||||
|
scriptName: string,
|
||||||
|
): Promise<{ enabled: boolean }> {
|
||||||
|
return this.request<{ enabled: boolean }>(
|
||||||
|
`/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ enabled: true }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Custom Domains ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async setWorkerCustomDomain(
|
||||||
|
accountId: string,
|
||||||
|
scriptName: string,
|
||||||
|
hostname: string,
|
||||||
|
): Promise<{ id: string; hostname: string }> {
|
||||||
|
return this.request<{ id: string; hostname: string }>(`/accounts/${accountId}/workers/domains`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
hostname,
|
||||||
|
service: scriptName,
|
||||||
|
environment: 'production',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
379
packages/cli/src/commands/deploy.ts
Normal file
379
packages/cli/src/commands/deploy.ts
Normal file
@ -0,0 +1,379 @@
|
|||||||
|
// deploy command — one-click OGraph deployment to Cloudflare
|
||||||
|
import { Command } from 'commander'
|
||||||
|
import { readFile, readdir } from 'node:fs/promises'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { createInterface } from 'node:readline'
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { CloudflareClient } from '../cloudflare.js'
|
||||||
|
import { saveConfig } from '../config.js'
|
||||||
|
|
||||||
|
// ─── Colors ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const c = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
cyan: '\x1b[36m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
dim: '\x1b[2m',
|
||||||
|
bold: '\x1b[1m',
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(msg: string) {
|
||||||
|
console.log(`${c.green}✓${c.reset} ${msg}`)
|
||||||
|
}
|
||||||
|
function info(msg: string) {
|
||||||
|
console.log(`${c.cyan}ℹ${c.reset} ${msg}`)
|
||||||
|
}
|
||||||
|
function warn(msg: string) {
|
||||||
|
console.log(`${c.yellow}⚠${c.reset} ${msg}`)
|
||||||
|
}
|
||||||
|
function fail(msg: string) {
|
||||||
|
console.error(`${c.red}✗${c.reset} ${msg}`)
|
||||||
|
}
|
||||||
|
function step(n: number, total: number, msg: string) {
|
||||||
|
console.log(`\n${c.bold}[${n}/${total}]${c.reset} ${msg}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Interactive Prompt ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function prompt(question: string): Promise<string> {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(question, (answer) => {
|
||||||
|
rl.close()
|
||||||
|
resolve(answer.trim())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirm(message: string): Promise<boolean> {
|
||||||
|
const answer = await prompt(`${message} ${c.dim}(y/N)${c.reset} `)
|
||||||
|
return answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectAccount(accounts: Array<{ id: string; name: string }>): Promise<string> {
|
||||||
|
if (accounts.length === 1) {
|
||||||
|
const acct = accounts[0]!
|
||||||
|
info(`Using account: ${c.cyan}${acct.name}${c.reset} (${acct.id})`)
|
||||||
|
return acct.id
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nAvailable accounts:')
|
||||||
|
for (let i = 0; i < accounts.length; i++) {
|
||||||
|
const acct = accounts[i]!
|
||||||
|
console.log(` ${c.cyan}${i + 1}${c.reset}) ${acct.name} ${c.dim}(${acct.id})${c.reset}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const answer = await prompt(`\nSelect account ${c.dim}(1-${accounts.length})${c.reset}: `)
|
||||||
|
const idx = parseInt(answer, 10) - 1
|
||||||
|
if (idx < 0 || idx >= accounts.length) {
|
||||||
|
throw new Error('Invalid selection')
|
||||||
|
}
|
||||||
|
return accounts[idx]!.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Migrations ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadMigrations(migrationsDir: string): Promise<string> {
|
||||||
|
if (!existsSync(migrationsDir)) {
|
||||||
|
throw new Error(`Migrations directory not found: ${migrationsDir}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await readdir(migrationsDir)
|
||||||
|
const sqlFiles = files.filter((f) => f.endsWith('.sql')).sort()
|
||||||
|
|
||||||
|
if (sqlFiles.length === 0) {
|
||||||
|
throw new Error(`No .sql files found in: ${migrationsDir}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = []
|
||||||
|
for (const file of sqlFiles) {
|
||||||
|
const content = await readFile(join(migrationsDir, file), 'utf-8')
|
||||||
|
parts.push(`-- === ${file} ===\n${content}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Worker Bundle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadWorkerBundle(ographDir: string): Promise<string> {
|
||||||
|
// Try dist/index.js first (built output)
|
||||||
|
const distPath = join(ographDir, 'dist', 'index.js')
|
||||||
|
if (existsSync(distPath)) {
|
||||||
|
return readFile(distPath, 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
// No pre-built bundle available
|
||||||
|
throw new Error(
|
||||||
|
`Worker bundle not found at ${distPath}\n` +
|
||||||
|
`Run 'npm run build' in packages/engine first, or use wrangler for direct deployment.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Token Generation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function generateApiToken(): string {
|
||||||
|
return `og_${randomBytes(32).toString('hex')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Deploy Options ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DeployOptions {
|
||||||
|
cfToken?: string
|
||||||
|
accountId?: string
|
||||||
|
name: string
|
||||||
|
dbName: string
|
||||||
|
domain?: string
|
||||||
|
yes: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Command ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function createDeployCommand(): Command {
|
||||||
|
const cmd = new Command('deploy')
|
||||||
|
cmd.description('Deploy OGraph to Cloudflare (D1 + Worker)')
|
||||||
|
cmd.option('--cf-token <token>', 'Cloudflare API Token')
|
||||||
|
cmd.option('--account-id <id>', 'Cloudflare Account ID')
|
||||||
|
cmd.option('--name <name>', 'Worker name', 'ograph')
|
||||||
|
cmd.option('--db-name <name>', 'D1 database name', 'ograph')
|
||||||
|
cmd.option('--domain <domain>', 'Custom domain (optional)')
|
||||||
|
cmd.option('--yes', 'Skip confirmation prompts', false)
|
||||||
|
|
||||||
|
cmd.action(async (opts: DeployOptions) => {
|
||||||
|
try {
|
||||||
|
await runDeploy(opts)
|
||||||
|
} catch (err) {
|
||||||
|
fail(String(err instanceof Error ? err.message : err))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Deploy Flow ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function runDeploy(opts: DeployOptions): Promise<void> {
|
||||||
|
const TOTAL_STEPS = 6
|
||||||
|
|
||||||
|
console.log(`\n${c.bold}🚀 OGraph Deploy${c.reset}`)
|
||||||
|
console.log(`${c.dim}Deploy OGraph to Cloudflare Workers + D1${c.reset}\n`)
|
||||||
|
|
||||||
|
// ── Step 0: Resolve paths ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// import.meta.dirname = dist/commands/ → ../.. = packages/cli/
|
||||||
|
const cliDir = resolve(import.meta.dirname ?? new URL('.', import.meta.url).pathname, '..', '..')
|
||||||
|
const ographDir = resolve(cliDir, '..', 'engine')
|
||||||
|
const migrationsDir = join(ographDir, 'migrations')
|
||||||
|
|
||||||
|
// ── Step 1: Get CF token ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
step(1, TOTAL_STEPS, 'Authenticating with Cloudflare')
|
||||||
|
|
||||||
|
let cfToken = opts.cfToken ?? process.env.CLOUDFLARE_API_TOKEN
|
||||||
|
if (!cfToken) {
|
||||||
|
info('No token provided via --cf-token or CLOUDFLARE_API_TOKEN env var')
|
||||||
|
info(`Create a token at: ${c.cyan}https://dash.cloudflare.com/profile/api-tokens${c.reset}`)
|
||||||
|
info('Required permissions: Workers Scripts (Edit), D1 (Edit), Account Settings (Read)')
|
||||||
|
cfToken = await prompt('\nCloudflare API Token: ')
|
||||||
|
if (!cfToken) {
|
||||||
|
throw new Error('API token is required')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cf = new CloudflareClient(cfToken)
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
try {
|
||||||
|
const tokenInfo = await cf.verifyToken()
|
||||||
|
if (tokenInfo.status !== 'active') {
|
||||||
|
throw new Error(`Token status: ${tokenInfo.status}`)
|
||||||
|
}
|
||||||
|
ok('Token verified')
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid Cloudflare API token: ${err instanceof Error ? err.message : err}\n` +
|
||||||
|
`Create one at: https://dash.cloudflare.com/profile/api-tokens\n` +
|
||||||
|
`Required permissions: Workers Scripts (Edit), D1 (Edit), Account Settings (Read)`,
|
||||||
|
{ cause: err },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: Select account ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
step(2, TOTAL_STEPS, 'Selecting Cloudflare account')
|
||||||
|
|
||||||
|
let accountId = opts.accountId
|
||||||
|
if (!accountId) {
|
||||||
|
const accounts = await cf.listAccounts()
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
throw new Error('No Cloudflare accounts found for this token')
|
||||||
|
}
|
||||||
|
accountId = opts.yes && accounts.length === 1 ? accounts[0]!.id : await selectAccount(accounts)
|
||||||
|
}
|
||||||
|
ok(`Account: ${c.cyan}${accountId}${c.reset}`)
|
||||||
|
|
||||||
|
// ── Confirmation ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (!opts.yes) {
|
||||||
|
console.log(`\n${c.bold}Deployment plan:${c.reset}`)
|
||||||
|
console.log(` Worker name: ${c.cyan}${opts.name}${c.reset}`)
|
||||||
|
console.log(` D1 database: ${c.cyan}${opts.dbName}${c.reset}`)
|
||||||
|
console.log(` Account: ${c.cyan}${accountId}${c.reset}`)
|
||||||
|
if (opts.domain) {
|
||||||
|
console.log(` Domain: ${c.cyan}${opts.domain}${c.reset}`)
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
const proceed = await confirm('Proceed with deployment?')
|
||||||
|
if (!proceed) {
|
||||||
|
info('Deployment cancelled')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3: Create D1 database ───────────────────────────────────────────
|
||||||
|
|
||||||
|
step(3, TOTAL_STEPS, `Creating D1 database: ${c.cyan}${opts.dbName}${c.reset}`)
|
||||||
|
|
||||||
|
let db: { uuid: string; name: string }
|
||||||
|
try {
|
||||||
|
db = await cf.createD1Database(accountId, opts.dbName)
|
||||||
|
ok(`D1 database created: ${c.cyan}${db.uuid}${c.reset}`)
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to create D1 database: ${err instanceof Error ? err.message : err}\n` +
|
||||||
|
`Check that your token has D1 (Edit) permission.`,
|
||||||
|
{ cause: err },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 4: Run migrations ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
step(4, TOTAL_STEPS, 'Initializing database schema')
|
||||||
|
|
||||||
|
const sql = await loadMigrations(migrationsDir)
|
||||||
|
const stmtCount = sql.split(';').filter((s) => s.trim().length > 0).length
|
||||||
|
info(`Loaded ${stmtCount} statements from migrations`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cf.queryD1(accountId, db.uuid, sql)
|
||||||
|
ok('Schema initialized')
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to initialize schema: ${err instanceof Error ? err.message : err}`, { cause: err })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 5: Deploy Worker ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
step(5, TOTAL_STEPS, `Deploying Worker: ${c.cyan}${opts.name}${c.reset}`)
|
||||||
|
|
||||||
|
const apiToken = generateApiToken()
|
||||||
|
const workerCode = await loadWorkerBundle(ographDir)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cf.uploadWorker(accountId, opts.name, workerCode, {
|
||||||
|
d1DatabaseId: db.uuid,
|
||||||
|
d1BindingName: 'DB',
|
||||||
|
apiToken,
|
||||||
|
version: '2.4.0',
|
||||||
|
})
|
||||||
|
ok('Worker deployed')
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to deploy Worker: ${err instanceof Error ? err.message : err}\n` +
|
||||||
|
`Check that your token has Workers Scripts (Edit) permission.`,
|
||||||
|
{ cause: err },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable workers.dev route (avoids CF 1042 error)
|
||||||
|
if (!opts.domain) {
|
||||||
|
try {
|
||||||
|
await cf.enableWorkersDevRoute(accountId, opts.name)
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — may already be enabled or not needed with custom domain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve endpoint URL
|
||||||
|
let endpoint: string
|
||||||
|
if (opts.domain) {
|
||||||
|
try {
|
||||||
|
await cf.setWorkerCustomDomain(accountId, opts.name, opts.domain)
|
||||||
|
endpoint = `https://${opts.domain}`
|
||||||
|
ok(`Custom domain configured: ${c.cyan}${opts.domain}${c.reset}`)
|
||||||
|
} catch (err) {
|
||||||
|
warn(`Failed to set custom domain: ${err instanceof Error ? err.message : err}`)
|
||||||
|
warn('Falling back to workers.dev subdomain')
|
||||||
|
const subdomain = await cf.getWorkersSubdomain(accountId)
|
||||||
|
endpoint = subdomain ? `https://${opts.name}.${subdomain}.workers.dev` : `https://${opts.name}.workers.dev`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const subdomain = await cf.getWorkersSubdomain(accountId)
|
||||||
|
endpoint = subdomain ? `https://${opts.name}.${subdomain}.workers.dev` : `https://${opts.name}.workers.dev`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 6: Configure CLI ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
step(6, TOTAL_STEPS, 'Configuring CLI')
|
||||||
|
|
||||||
|
await saveConfig({ endpoint, token: apiToken })
|
||||||
|
ok('CLI configured')
|
||||||
|
|
||||||
|
// ── Readiness check ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
info('Waiting for Worker edge propagation...')
|
||||||
|
const maxWait = 90_000 // 90s max
|
||||||
|
const pollInterval = 2_000 // poll every 2s
|
||||||
|
const requiredConsecutive = 3 // need 3 consecutive OKs
|
||||||
|
const start = Date.now()
|
||||||
|
let consecutiveOk = 0
|
||||||
|
let reachable = false
|
||||||
|
|
||||||
|
while (Date.now() - start < maxWait) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${endpoint}/health`)
|
||||||
|
const contentType = res.headers.get('content-type') ?? ''
|
||||||
|
if (res.ok && contentType.includes('application/json')) {
|
||||||
|
const body = (await res.json()) as { status?: string }
|
||||||
|
if (body.status === 'ok') {
|
||||||
|
consecutiveOk++
|
||||||
|
if (consecutiveOk >= requiredConsecutive) {
|
||||||
|
reachable = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
consecutiveOk = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
consecutiveOk = 0
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
consecutiveOk = 0
|
||||||
|
}
|
||||||
|
const elapsed = Math.round((Date.now() - start) / 1000)
|
||||||
|
process.stdout.write(`\r${c.dim} ... ${elapsed}s (${consecutiveOk}/${requiredConsecutive})${c.reset} `)
|
||||||
|
await new Promise((r) => setTimeout(r, pollInterval))
|
||||||
|
}
|
||||||
|
process.stdout.write('\r')
|
||||||
|
|
||||||
|
if (reachable) {
|
||||||
|
ok('Worker is live — edge propagation confirmed')
|
||||||
|
} else {
|
||||||
|
warn(`Worker not fully propagated after ${maxWait / 1000}s — some requests may fail briefly`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Summary ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log(`\n${c.bold}${c.green}✅ Deployment complete!${c.reset}\n`)
|
||||||
|
console.log(` Endpoint: ${c.cyan}${endpoint}${c.reset}`)
|
||||||
|
console.log(` API Token: ${c.cyan}${apiToken.slice(0, 12)}${'*'.repeat(20)}${c.reset}`)
|
||||||
|
console.log(` D1 DB: ${c.cyan}${db.uuid}${c.reset}`)
|
||||||
|
console.log(` Worker: ${c.cyan}${opts.name}${c.reset}`)
|
||||||
|
console.log()
|
||||||
|
console.log(` Test with: ${c.dim}ograph health${c.reset}`)
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import { createProjectionDefsCommand } from './commands/projection-defs.js'
|
|||||||
import { createProjectionsCommand } from './commands/projections.js'
|
import { createProjectionsCommand } from './commands/projections.js'
|
||||||
import { createReactionsCommand } from './commands/reactions.js'
|
import { createReactionsCommand } from './commands/reactions.js'
|
||||||
import { createHealthCommand } from './commands/health.js'
|
import { createHealthCommand } from './commands/health.js'
|
||||||
|
import { createDeployCommand } from './commands/deploy.js'
|
||||||
|
|
||||||
// ─── Main Program ──────────────────────────────────────────────────────────────
|
// ─── Main Program ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -27,5 +28,6 @@ program.addCommand(createProjectionDefsCommand())
|
|||||||
program.addCommand(createProjectionsCommand())
|
program.addCommand(createProjectionsCommand())
|
||||||
program.addCommand(createReactionsCommand())
|
program.addCommand(createReactionsCommand())
|
||||||
program.addCommand(createHealthCommand())
|
program.addCommand(createHealthCommand())
|
||||||
|
program.addCommand(createDeployCommand())
|
||||||
|
|
||||||
program.parse()
|
program.parse()
|
||||||
|
|||||||
@ -48,8 +48,10 @@ describe('OGraphClient v2.4', () => {
|
|||||||
await client.init()
|
await client.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const jsonHeaders = { get: (name: string) => name.toLowerCase() === 'content-type' ? 'application/json' : null }
|
||||||
|
|
||||||
function mockOk(data: unknown) {
|
function mockOk(data: unknown) {
|
||||||
mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve(data) })
|
mockFetch.mockResolvedValue({ ok: true, headers: jsonHeaders, json: () => Promise.resolve(data) })
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockFail(status: number, error: string) {
|
function mockFail(status: number, error: string) {
|
||||||
@ -57,6 +59,7 @@ describe('OGraphClient v2.4', () => {
|
|||||||
ok: false,
|
ok: false,
|
||||||
status,
|
status,
|
||||||
statusText: error,
|
statusText: error,
|
||||||
|
headers: jsonHeaders,
|
||||||
json: () => Promise.resolve({ error }),
|
json: () => Promise.resolve({ error }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
289
packages/cli/test/deploy.test.ts
Normal file
289
packages/cli/test/deploy.test.ts
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
// deploy command tests — mock CF API
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { CloudflareClient } from '../src/cloudflare.js'
|
||||||
|
|
||||||
|
// ─── Mock fetch ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
global.fetch = mockFetch
|
||||||
|
|
||||||
|
function cfOk<T>(result: T) {
|
||||||
|
return { ok: true, json: () => Promise.resolve({ success: true, result, errors: [], messages: [] }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function cfFail(code: number, message: string) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
json: () => Promise.resolve({ success: false, result: null, errors: [{ code, message }], messages: [] }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CloudflareClient Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CloudflareClient', () => {
|
||||||
|
let client: CloudflareClient
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = new CloudflareClient('test-cf-token')
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── verifyToken ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('verifyToken', () => {
|
||||||
|
it('should verify a valid token', async () => {
|
||||||
|
mockFetch.mockResolvedValue(cfOk({ id: 'token-123', status: 'active' }))
|
||||||
|
const result = await client.verifyToken()
|
||||||
|
expect(result.status).toBe('active')
|
||||||
|
expect(result.id).toBe('token-123')
|
||||||
|
|
||||||
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
|
||||||
|
expect(url).toBe('https://api.cloudflare.com/client/v4/user/tokens/verify')
|
||||||
|
expect(opts.headers).toBeDefined()
|
||||||
|
const headers = opts.headers as Headers
|
||||||
|
expect(headers.get('Authorization')).toBe('Bearer test-cf-token')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw on invalid token', async () => {
|
||||||
|
mockFetch.mockResolvedValue(cfFail(1000, 'Invalid API Token'))
|
||||||
|
await expect(client.verifyToken()).rejects.toThrow('Cloudflare API error: [1000] Invalid API Token')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── listAccounts ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('listAccounts', () => {
|
||||||
|
it('should return accounts list', async () => {
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
cfOk([
|
||||||
|
{ id: 'acct-1', name: 'My Account' },
|
||||||
|
{ id: 'acct-2', name: 'Other Account' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
const accounts = await client.listAccounts()
|
||||||
|
expect(accounts).toHaveLength(2)
|
||||||
|
expect(accounts[0]!.id).toBe('acct-1')
|
||||||
|
expect(accounts[1]!.name).toBe('Other Account')
|
||||||
|
expect(mockFetch.mock.calls[0]![0]).toBe('https://api.cloudflare.com/client/v4/accounts')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── createD1Database ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('createD1Database', () => {
|
||||||
|
it('should create a D1 database', async () => {
|
||||||
|
mockFetch.mockResolvedValue(cfOk({ uuid: 'db-uuid-123', name: 'ograph' }))
|
||||||
|
const db = await client.createD1Database('acct-1', 'ograph')
|
||||||
|
expect(db.uuid).toBe('db-uuid-123')
|
||||||
|
expect(db.name).toBe('ograph')
|
||||||
|
|
||||||
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
|
||||||
|
expect(url).toBe('https://api.cloudflare.com/client/v4/accounts/acct-1/d1/database')
|
||||||
|
expect(opts.method).toBe('POST')
|
||||||
|
expect(JSON.parse(opts.body as string)).toEqual({ name: 'ograph' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw on permission error', async () => {
|
||||||
|
mockFetch.mockResolvedValue(cfFail(10000, 'Authentication error'))
|
||||||
|
await expect(client.createD1Database('acct-1', 'ograph')).rejects.toThrow(
|
||||||
|
'Cloudflare API error: [10000] Authentication error',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── queryD1 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('queryD1', () => {
|
||||||
|
it('should execute SQL against D1', async () => {
|
||||||
|
mockFetch.mockResolvedValue(cfOk([{ success: true, results: [], meta: { changes: 3 } }]))
|
||||||
|
const results = await client.queryD1('acct-1', 'db-uuid-123', 'CREATE TABLE test (id INTEGER);')
|
||||||
|
expect(results).toHaveLength(1)
|
||||||
|
expect(results[0]!.success).toBe(true)
|
||||||
|
|
||||||
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
|
||||||
|
expect(url).toBe('https://api.cloudflare.com/client/v4/accounts/acct-1/d1/database/db-uuid-123/query')
|
||||||
|
expect(opts.method).toBe('POST')
|
||||||
|
expect(JSON.parse(opts.body as string)).toEqual({ sql: 'CREATE TABLE test (id INTEGER);' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── uploadWorker ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('uploadWorker', () => {
|
||||||
|
it('should upload a worker script with bindings', async () => {
|
||||||
|
mockFetch.mockResolvedValue(cfOk({ id: 'ograph', etag: 'abc123' }))
|
||||||
|
const result = await client.uploadWorker('acct-1', 'ograph', 'export default { fetch() {} }', {
|
||||||
|
d1DatabaseId: 'db-uuid-123',
|
||||||
|
d1BindingName: 'DB',
|
||||||
|
apiToken: 'og_testtoken',
|
||||||
|
version: '2.4.0',
|
||||||
|
})
|
||||||
|
expect(result.id).toBe('ograph')
|
||||||
|
|
||||||
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
|
||||||
|
expect(url).toBe('https://api.cloudflare.com/client/v4/accounts/acct-1/workers/scripts/ograph')
|
||||||
|
expect(opts.method).toBe('PUT')
|
||||||
|
|
||||||
|
// Verify multipart body contains metadata and script
|
||||||
|
const body = opts.body as string
|
||||||
|
expect(body).toContain('worker.js')
|
||||||
|
expect(body).toContain('export default { fetch() {} }')
|
||||||
|
expect(body).toContain('d1')
|
||||||
|
expect(body).toContain('db-uuid-123')
|
||||||
|
expect(body).toContain('og_testtoken')
|
||||||
|
expect(body).toContain('2.4.0')
|
||||||
|
|
||||||
|
// Verify content-type is multipart
|
||||||
|
const headers = opts.headers as Headers
|
||||||
|
expect(headers.get('Content-Type')).toContain('multipart/form-data')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw on upload failure', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
success: false,
|
||||||
|
result: null,
|
||||||
|
errors: [{ code: 10007, message: 'Script too large' }],
|
||||||
|
messages: [],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
client.uploadWorker('acct-1', 'ograph', 'code', {
|
||||||
|
d1DatabaseId: 'db-uuid-123',
|
||||||
|
d1BindingName: 'DB',
|
||||||
|
apiToken: 'og_test',
|
||||||
|
version: '2.4.0',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Worker upload failed: [10007] Script too large')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── getWorkersSubdomain ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getWorkersSubdomain', () => {
|
||||||
|
it('should return subdomain', async () => {
|
||||||
|
mockFetch.mockResolvedValue(cfOk({ subdomain: 'my-workers' }))
|
||||||
|
const subdomain = await client.getWorkersSubdomain('acct-1')
|
||||||
|
expect(subdomain).toBe('my-workers')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null on error', async () => {
|
||||||
|
mockFetch.mockResolvedValue(cfFail(10000, 'Not found'))
|
||||||
|
const subdomain = await client.getWorkersSubdomain('acct-1')
|
||||||
|
expect(subdomain).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── setWorkerCustomDomain ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('setWorkerCustomDomain', () => {
|
||||||
|
it('should set custom domain', async () => {
|
||||||
|
mockFetch.mockResolvedValue(cfOk({ id: 'domain-1', hostname: 'api.example.com' }))
|
||||||
|
const result = await client.setWorkerCustomDomain('acct-1', 'ograph', 'api.example.com')
|
||||||
|
expect(result.hostname).toBe('api.example.com')
|
||||||
|
|
||||||
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
|
||||||
|
expect(url).toBe('https://api.cloudflare.com/client/v4/accounts/acct-1/workers/domains')
|
||||||
|
expect(opts.method).toBe('PUT')
|
||||||
|
const body = JSON.parse(opts.body as string)
|
||||||
|
expect(body.hostname).toBe('api.example.com')
|
||||||
|
expect(body.service).toBe('ograph')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Migration Loading Tests ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('loadMigrations (integration)', () => {
|
||||||
|
it('should find migration files in the ograph package', async () => {
|
||||||
|
const { readdir, readFile } = await import('node:fs/promises')
|
||||||
|
const { join } = await import('node:path')
|
||||||
|
|
||||||
|
const migrationsDir = join(import.meta.dirname ?? '.', '..', '..', 'engine', 'migrations')
|
||||||
|
const files = await readdir(migrationsDir)
|
||||||
|
const sqlFiles = files.filter((f) => f.endsWith('.sql')).sort()
|
||||||
|
|
||||||
|
// Should have migration files
|
||||||
|
expect(sqlFiles.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// First file should be 0006_v2.sql
|
||||||
|
expect(sqlFiles[0]).toBe('0006_v2.sql')
|
||||||
|
|
||||||
|
// Last file should be 0021_request_logs.sql
|
||||||
|
expect(sqlFiles[sqlFiles.length - 1]).toBe('0021_request_logs.sql')
|
||||||
|
|
||||||
|
// Each file should contain SQL
|
||||||
|
for (const file of sqlFiles) {
|
||||||
|
const content = await readFile(join(migrationsDir, file), 'utf-8')
|
||||||
|
expect(content.length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should sort files in correct order', async () => {
|
||||||
|
const { readdir } = await import('node:fs/promises')
|
||||||
|
const { join } = await import('node:path')
|
||||||
|
|
||||||
|
const migrationsDir = join(import.meta.dirname ?? '.', '..', '..', 'engine', 'migrations')
|
||||||
|
const files = await readdir(migrationsDir)
|
||||||
|
const sqlFiles = files.filter((f) => f.endsWith('.sql')).sort()
|
||||||
|
|
||||||
|
// Verify ordering by number prefix
|
||||||
|
for (let i = 1; i < sqlFiles.length; i++) {
|
||||||
|
const prevNum = parseInt(sqlFiles[i - 1]!.split('_')[0]!, 10)
|
||||||
|
const currNum = parseInt(sqlFiles[i]!.split('_')[0]!, 10)
|
||||||
|
expect(currNum).toBeGreaterThan(prevNum)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Token Generation Tests ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('generateApiToken', () => {
|
||||||
|
it('should generate tokens with og_ prefix', async () => {
|
||||||
|
const { randomBytes } = await import('node:crypto')
|
||||||
|
const token = `og_${randomBytes(32).toString('hex')}`
|
||||||
|
expect(token).toMatch(/^og_[a-f0-9]{64}$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Deploy Command Registration ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('createDeployCommand', () => {
|
||||||
|
let createDeployCommand: () => import('commander').Command
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const mod = await import('../src/commands/deploy.js')
|
||||||
|
createDeployCommand = mod.createDeployCommand
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create a command named "deploy"', () => {
|
||||||
|
const cmd = createDeployCommand()
|
||||||
|
expect(cmd.name()).toBe('deploy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have all expected options', () => {
|
||||||
|
const cmd = createDeployCommand()
|
||||||
|
const optionNames = cmd.options.map((o) => o.long)
|
||||||
|
expect(optionNames).toContain('--cf-token')
|
||||||
|
expect(optionNames).toContain('--account-id')
|
||||||
|
expect(optionNames).toContain('--name')
|
||||||
|
expect(optionNames).toContain('--db-name')
|
||||||
|
expect(optionNames).toContain('--domain')
|
||||||
|
expect(optionNames).toContain('--yes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have correct defaults', () => {
|
||||||
|
const cmd = createDeployCommand()
|
||||||
|
const nameOpt = cmd.options.find((o) => o.long === '--name')
|
||||||
|
const dbNameOpt = cmd.options.find((o) => o.long === '--db-name')
|
||||||
|
expect(nameOpt?.defaultValue).toBe('ograph')
|
||||||
|
expect(dbNameOpt?.defaultValue).toBe('ograph')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show description', () => {
|
||||||
|
const cmd = createDeployCommand()
|
||||||
|
expect(cmd.description()).toContain('Deploy')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user