├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.js ├── .vscode └── settings.json ├── .yarn └── releases │ └── yarn-4.9.2.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── eslint.config.js ├── package.json ├── scripts └── build.ts ├── src ├── __mocks__ │ └── inquirer.js ├── builders.json ├── collection.json └── schematics │ ├── ali-oss │ ├── config.ts │ ├── deploy.ts │ └── ng-add.ts │ ├── core │ ├── config.ts │ ├── load-esm.ts │ ├── types.ts │ └── utils.ts │ ├── deploy │ ├── builder.ts │ └── schema.json │ ├── index.ts │ ├── ng-add.spec.ts │ ├── ng-add.ts │ ├── public_api.ts │ ├── qiniu │ ├── config.ts │ ├── deploy.ts │ └── ng-add.ts │ ├── schema.json │ └── upyun │ ├── config.ts │ ├── deploy.ts │ └── ng-add.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [{*.html}] 14 | indent_style=space 15 | indent_size=2 16 | 17 | [{.babelrc,.stylelintrc,.eslintrc,*.json}] 18 | indent_style=space 19 | indent_size=2 20 | 21 | [*.md] 22 | insert_final_newline = false 23 | trim_trailing_whitespace = false 24 | 25 | [{*.ats,*.ts}] 26 | indent_style=space 27 | indent_size=2 28 | 29 | [*.js] 30 | indent_style=space 31 | indent_size=2 32 | 33 | [*.js.map] 34 | indent_style=space 35 | indent_size=2 36 | 37 | [*.less] 38 | indent_style=space 39 | indent_size=2 40 | 41 | [{.analysis_options,*.yml,*.yaml}] 42 | indent_style=space 43 | indent_size=2 44 | 45 | [tslint.json] 46 | indent_style=space 47 | indent_size=2 48 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: push 4 | 5 | jobs: 6 | ci: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: checkout 10 | uses: actions/checkout@master 11 | 12 | - uses: borales/actions-yarn@v4 13 | with: 14 | cmd: install 15 | 16 | - name: build 17 | uses: borales/actions-yarn@v4 18 | with: 19 | cmd: build 20 | 21 | - name: lint 22 | uses: borales/actions-yarn@v4 23 | with: 24 | cmd: lint 25 | 26 | - name: test 27 | uses: borales/actions-yarn@v4 28 | with: 29 | cmd: test:ci 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | *.ngfactory.ts 4 | *.ngsummary.json 5 | .DS_Store 6 | yarn.lock 7 | yarn-error.log 8 | *.bak 9 | package-lock.json 10 | test/ng-build/**/yarn.lock 11 | coverage 12 | *.log 13 | api-*.json 14 | scripts/build.js 15 | 16 | # Yarn 17 | yarn.lock 18 | yarn-error.log 19 | .yarn/* 20 | !.yarn/patches 21 | !.yarn/plugins 22 | !.yarn/releases 23 | !.yarn/sdks 24 | !.yarn/versions 25 | .pnp.* 26 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.spec.ts 2 | *.test.json 3 | __mocks__ 4 | __tests__ 5 | coverage/ 6 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.13.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .prettierrc 2 | 3 | node_modules/ 4 | coverage/ 5 | dist/ 6 | package.json 7 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | useTabs: false, 4 | printWidth: 120, 5 | tabWidth: 2, 6 | semi: true, 7 | htmlWhitespaceSensitivity: 'strict', 8 | arrowParens: 'avoid', 9 | bracketSpacing: true, 10 | proseWrap: 'preserve', 11 | trailingComma: 'none', 12 | endOfLine: 'lf' 13 | }; 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.watcherExclude": { 4 | "**/.git/objects/**": true, 5 | "**/.git/subtree-cache/**": true, 6 | "**/node_modules/*/**": true, 7 | "**/dist/*/**": true, 8 | "**/coverage/*/**": true 9 | }, 10 | "editor.formatOnSave": true, 11 | "editor.codeActionsOnSave": { 12 | "source.fixAll.eslint": "explicit", 13 | "source.fixAll.tslint": "explicit", 14 | "source.fixAll.stylelint": "explicit" 15 | }, 16 | "[markdown]": { 17 | "editor.formatOnSave": false 18 | }, 19 | "files.associations": { 20 | "package.json": "json", 21 | "*.json": "jsonc", 22 | ".prettierrc": "json", 23 | ".stylelintrc": "json" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableImmutableInstalls: false 2 | 3 | nodeLinker: node-modules 4 | 5 | yarnPath: .yarn/releases/yarn-4.9.2.cjs 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-present 卡色 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ng-deploy-oss 2 | 3 | [![npm](https://img.shields.io/npm/v/ng-deploy-oss)](https://www.npmjs.com/package/ng-deploy-oss) 4 | ![CI](https://github.com/cipchk/ng-deploy-oss/workflows/CI/badge.svg) 5 | [![The MIT License](https://img.shields.io/badge/license-MIT-orange.svg?color=blue&style=flat-square)](http://opensource.org/licenses/MIT) 6 | 7 | **使用 Angular CLI 发布 Angular 应用到阿里云 OSS、七牛云、又拍云 🚀** 8 | 9 | ## 快速入门 10 | 11 | 1、安装 Angular CLI (v8.3.0 以上) 并创建一个新项目 12 | 13 | ```bash 14 | npm install -g @angular/cli 15 | ng new hello-world 16 | cd hello-world 17 | ``` 18 | 19 | 2、添加 `ng-deploy-oss` 20 | 21 | ```bash 22 | ng add ng-deploy-oss 23 | ``` 24 | 25 | > 除 [通用参数](#通用参数) 以外,同时阿里云 OSS、七牛云、又拍云三种云存储,不同的类型需要的参数不同,更多细节请参考[不同参数](#不同参数)。 26 | 27 | 3、部署 28 | 29 | ```bash 30 | ng deploy 31 | ``` 32 | 33 | ## 参数 34 | 35 | ### 通用参数 36 | 37 | | 参数名 | 默认值 | 描述 | 38 | |-----|-----|----| 39 | | `noBuild` | `false` | 是否不执行构建命令 | 40 | | `buildCommand` | `-` | 自定义构建命令行 | 41 | | `baseHref` | `-` | 指定 `baseHref` 参数,赞同 `ng build --base-href=xx` 值 | 42 | | `preClean` | `true` | 是否预清除所有远程目录下的文件 | 43 | | `oneByOneUpload` | `false` | 是否逐个上传文件,并且将所有 html 放在最后上传 | 44 | 45 | ### 不同参数 46 | 47 | **阿里云 OSS** 48 | 49 | | 参数名 | 环境变量名 | 描述 | 50 | |-----|-------|----| 51 | | `region` | `ALIOSS_REGION` | OSS Region,完整列表请参考[OSS 开通 Region 和 Endpoint 对照表](https://help.aliyun.com/document_detail/31837.html) | 52 | | `ak` | `ALIOSS_AK` | 阿里云 AccessKeyId | 53 | | `sk` | `ALIOSS_SK` | 阿里云 AccessKeySecret | 54 | | `stsToken` | `ALIOSS_STSTOKEN` | 阿里云 STS Token | 55 | | `bucket` | `ALIOSS_BUCKET` | Bucket | 56 | | `prefix` | `ALIOSS_PREFIX` | 路径前缀,如果不指定表示放在根目录下 | 57 | | `buildCommand` | `ALIOSS_BUILDCOMMAND` | 构建生产环境的 NPM 命令行(例如:`npm run build`),若为空表示自动根据 `angular.json` 构建生成环境 | 58 | 59 | **七牛云** 60 | 61 | | 参数名 | 环境变量名 | 描述 | 62 | |-----|-------|----| 63 | | `ak` | `QINIU_AK` | 七牛云 AccessKey | 64 | | `sk` | `QINIU_SK` | 七牛云 SecretKey | 65 | | `zone` | `QINIU_ZONE` | 所在机房 | 66 | | `bucket` | `QINIU_BUCKET` | Bucket | 67 | | `prefix` | `QINIU_PREFIX` | 路径前缀,如果不指定表示放在根目录下 | 68 | | `buildCommand` | `QINIU_BUILDCOMMAND` | 构建生产环境的 NPM 命令行(例如:`npm run build`),若为空表示自动根据 `angular.json` 构建生成环境 | 69 | 70 | **又拍云** 71 | 72 | | 参数名 | 环境变量名 | 描述 | 73 | |-----|-------|----| 74 | | `name` | `UPYUN_NAME` | 服务名称 | 75 | | `operatorName` | `UPYUN_OPERATORNAME` | 操作员名称(确保可写入&可删除权限) | 76 | | `operatorPwd` | `UPYUN_OPERATORPWD` | 操作员密码 | 77 | | `prefix` | `UPYUN_PREFIX` | 路径前缀,如果不指定表示放在根目录下 | 78 | | `buildCommand` | `UPYUN_BUILDCOMMAND` | 构建生产环境的 NPM 命令行(例如:`npm run build`),若为空表示自动根据 `angular.json` 构建生成环境 | 79 | 80 | ### 使用环境变量 81 | 82 | 当运行 `ng add ng-deploy-oss` 时会根据所选的类型提示输入相应的参数,并把这些参数写入 `angular.json` 中。事实上,对于这些参数属于私密强的信息,这时候可以利用环境变量,来保护这些私密信息。 83 | 84 | 例如,当生产环境部署时,使用不同的 `ALIOSS_AK` 参数时: 85 | 86 | ```bash 87 | # Windows: 88 | set ALIOSS_AK=prod 89 | # on OS X or Linux: 90 | export ALIOSS_AK=prod 91 | ``` 92 | 93 | ### 使用命令行 94 | 95 | 命令行参数也可以改变其参数值,但它的优先级会低于环境变量方式,高于 `angular.json` 配置的信息。 96 | 97 | ```bash 98 | ng deploy --ak=prod 99 | ``` 100 | 101 | ## License 102 | 103 | The MIT License (see the [LICENSE](https://github.com/cipchk/ng-deploy-oss/blob/master/LICENSE) file for the full text) 104 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | const eslint = require("@eslint/js"); 3 | const tseslint = require("typescript-eslint"); 4 | 5 | module.exports = tseslint.config({ 6 | files: ["**/*.ts"], 7 | extends: [ 8 | eslint.configs.recommended, 9 | ...tseslint.configs.recommended, 10 | ...tseslint.configs.stylistic, 11 | ], 12 | rules: { 13 | "@typescript-eslint/no-explicit-any": "off" 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ng-deploy-oss", 3 | "version": "20.0.0", 4 | "description": "Deploy Angular apps to aliyun OSS, qiniu, upyun using the Angular CLI.", 5 | "keywords": [ 6 | "schematics", 7 | "angular", 8 | "aliyun oss", 9 | "qiniu", 10 | "upyun", 11 | "deploy" 12 | ], 13 | "author": { 14 | "name": "cipchk", 15 | "url": "https://www.zhihu.com/people/cipchk" 16 | }, 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/cipchk/ng-deploy-oss/issues" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/cipchk/ng-deploy-oss.git" 24 | }, 25 | "homepage": "https://github.com/cipchk/ng-deploy-oss", 26 | "main": "./schematics/index", 27 | "builders": "./builders.json", 28 | "schematics": "./collection.json", 29 | "ng-add": { 30 | "save": "devDependencies" 31 | }, 32 | "scripts": { 33 | "test": "jest --watch", 34 | "test:ci": "jest", 35 | "lint": "eslint . --ignore-pattern 'dist/**' --ignore-pattern '.yarn/**'", 36 | "build": "tsx scripts/build.ts", 37 | "build:test": "npm run build -- --test", 38 | "release": "npm run build -- --release" 39 | }, 40 | "dependencies": { 41 | "@angular-devkit/architect": "^0.2000.1", 42 | "@angular-devkit/build-angular": "20.0.1", 43 | "@angular-devkit/core": "^20.0.1", 44 | "@angular-devkit/schematics": "^20.0.1", 45 | "ali-oss": "^6.21.0", 46 | "chalk": "^5.3.0", 47 | "fs-extra": "^11.2.0", 48 | "inquirer": "^12.1.0", 49 | "inquirer-autocomplete-prompt": "^3.0.1", 50 | "qiniu": "^7.14.0", 51 | "typescript": "~5.8.3", 52 | "upyun": "^3.4.6" 53 | }, 54 | "devDependencies": { 55 | "@eslint/js": "^9.28.0", 56 | "@schematics/angular": "^20.0.1", 57 | "@types/ali-oss": "^6.16.11", 58 | "@types/fs-extra": "^11.0.4", 59 | "@types/inquirer": "^9.0.8", 60 | "@types/jest": "^29.5.14", 61 | "@types/node": "^20.19.0", 62 | "@types/rimraf": "^4.0.5", 63 | "@types/upyun": "^3.4.0", 64 | "eslint": "^9.28.0", 65 | "jest": "^29.7.0", 66 | "prettier": "^3.3.3", 67 | "rimraf": "^6.0.1", 68 | "ts-jest": "^29.2.5", 69 | "tsx": "^4.19.2", 70 | "typescript-eslint": "^8.33.1" 71 | }, 72 | "jest": { 73 | "roots": [ 74 | "/src" 75 | ], 76 | "transform": { 77 | "^.+\\.tsx?$": "ts-jest" 78 | } 79 | }, 80 | "packageManager": "yarn@4.9.2" 81 | } 82 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | import { copy, copySync, writeFile, existsSync, readFileSync, writeFileSync } from 'fs-extra'; 3 | import { join } from 'path'; 4 | import { execSync } from 'child_process'; 5 | 6 | import * as rimraf from 'rimraf'; 7 | 8 | const TEST = process.argv.includes('--test'); 9 | const RELEASE = process.argv.includes('--release'); 10 | const RELEASE_NEXT = process.argv.includes('--release-next'); 11 | const src = (...args: string[]) => join(process.cwd(), 'src', ...args); 12 | const dest = (...args: string[]) => join(process.cwd(), 'dist', ...args); 13 | const destPath = dest(''); 14 | 15 | function spawnPromise(command: string, args: string[]) { 16 | return new Promise(resolve => spawn(command, args, { stdio: 'inherit' }).on('close', resolve)); 17 | } 18 | 19 | async function fixPackage() { 20 | const path = dest('package.json'); 21 | const pkg = await import(path); 22 | ['scripts', 'devDependencies', 'jest', 'husky'].forEach(key => delete pkg[key]); 23 | // pkg.dependencies['@angular-devkit/architect'] = `^0.1100.0 || ^0.1200.0 || ^0.1300.0`; 24 | // ['@angular-devkit/core', '@angular-devkit/schematics'].forEach(name => { 25 | // pkg.dependencies[name] = `^11.0.0 || ^12.0.0 || ^13.0.0`; 26 | // }); 27 | const rootPackage = await import(dest('../package.json')); 28 | ['@angular-devkit/architect', '@angular-devkit/core', '@angular-devkit/schematics'].forEach(key => { 29 | pkg.dependencies[key] = rootPackage.dependencies[key]; 30 | }); 31 | return writeFile(path, JSON.stringify(pkg, null, 2)); 32 | } 33 | 34 | async function compileSchematics() { 35 | const tsc = ['tsc', '-p', 'tsconfig.json']; 36 | await spawnPromise(`npx`, tsc); 37 | return Promise.all([ 38 | copy(src('builders.json'), dest('builders.json')), 39 | copy(src('collection.json'), dest('collection.json')), 40 | copy(src('schematics', 'schema.json'), dest('schematics', 'schema.json')), 41 | copy(src('schematics', 'deploy', 'schema.json'), dest('schematics', 'deploy', 'schema.json')) 42 | ]); 43 | } 44 | 45 | async function replaceVersionNumber() { 46 | const pkg = await import(join(process.cwd(), 'package.json')); 47 | const utilsPath = dest('schematics', 'core', 'utils.js'); 48 | const content = readFileSync(utilsPath, { encoding: 'utf8' }).replace(`VERSIONPLACEHOLDER`, `~${pkg.version}`); 49 | writeFileSync(utilsPath, content); 50 | } 51 | 52 | async function buildLibrary() { 53 | if (existsSync(destPath)) { 54 | rimraf.sync(destPath); 55 | } 56 | ['package.json', 'README.md'].forEach(fileName => { 57 | copySync(join(process.cwd(), fileName), dest(fileName)); 58 | }); 59 | await Promise.all([compileSchematics(), await fixPackage()]); 60 | } 61 | 62 | Promise.all([buildLibrary()]) 63 | .then(async () => { 64 | await replaceVersionNumber(); 65 | if (!TEST) { 66 | return Promise.resolve(); 67 | } 68 | const projectName = `ng17`; 69 | console.info(`Test mode. Copy to [${projectName}] project`); 70 | const testProjectPath = join(process.cwd(), `../${projectName}/node_modules/ng-deploy-oss`); 71 | if (existsSync(testProjectPath)) { 72 | rimraf.sync(testProjectPath); 73 | } 74 | return copy(destPath, testProjectPath); 75 | }) 76 | .then(() => { 77 | 78 | const command = `cd dist & npm publish --access public --ignore-scripts`; 79 | if (RELEASE) { 80 | console.log('Release Mode'); 81 | execSync(command); 82 | } 83 | if (RELEASE_NEXT) { 84 | console.log('Release Next Mode'); 85 | execSync(`${command} --tag next`); 86 | } 87 | 88 | console.log('Success'); 89 | }); 90 | -------------------------------------------------------------------------------- /src/__mocks__/inquirer.js: -------------------------------------------------------------------------------- 1 | const inquirerMock = jest.genMockFromModule('inquirer'); 2 | 3 | inquirerMock.prompt = jest.fn(() => { 4 | return { 5 | sub: 'subMock' 6 | }; 7 | }); 8 | 9 | module.exports = inquirerMock; 10 | -------------------------------------------------------------------------------- /src/builders.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "@angular-devkit/architect/src/builders-schema.json", 3 | "builders": { 4 | "deploy": { 5 | "implementation": "./schematics/deploy/builder", 6 | "schema": "./schematics/deploy/schema.json", 7 | "description": "Deploy builder" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "schematics": { 3 | "ng-add": { 4 | "description": "Adds Angular Deploy aliyun OSS, qiniu, upyun to the application without affecting any templates", 5 | "factory": "./schematics/public_api#ngAdd", 6 | "schema": "./schematics/schema.json" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/schematics/ali-oss/config.ts: -------------------------------------------------------------------------------- 1 | import { EnvName } from '../core/types'; 2 | 3 | /** 4 | * https://help.aliyun.com/document_detail/31837.html 5 | * ```js 6 | * var a = []; document.querySelectorAll('.tbody tr').forEach(el => a.push({ name: el.querySelector('td:nth-child(1)').textContent.trim(), value: el.querySelector('td:nth-child(3)').textContent.trim() }));copy(JSON.stringify(a.filter(w=>w.name != '地域'))); 7 | * ``` 8 | */ 9 | export const REGIONS = [ 10 | { name: '华东1(杭州)', value: 'oss-cn-hangzhou' }, 11 | { name: '华东2(上海)', value: 'oss-cn-shanghai' }, 12 | { name: '华东5(南京-本地地域)', value: 'oss-cn-nanjing' }, 13 | { name: '华东6(福州-本地地域)', value: 'oss-cn-fuzhou' }, 14 | { name: '华中1(武汉-本地地域)', value: 'oss-cn-wuhan-lr' }, 15 | { name: '华北1(青岛)', value: 'oss-cn-qingdao' }, 16 | { name: '华北2(北京)', value: 'oss-cn-beijing' }, 17 | { name: '华北3(张家口)', value: 'oss-cn-zhangjiakou' }, 18 | { name: '华北5(呼和浩特)', value: 'oss-cn-huhehaote' }, 19 | { name: '华北6(乌兰察布)', value: 'oss-cn-wulanchabu' }, 20 | { name: '华南1(深圳)', value: 'oss-cn-shenzhen' }, 21 | { name: '华南2(河源)', value: 'oss-cn-heyuan' }, 22 | { name: '华南3(广州)', value: 'oss-cn-guangzhou' }, 23 | { name: '西南1(成都)', value: 'oss-cn-chengdu' }, 24 | { name: '中国香港', value: 'oss-cn-hongkong' }, 25 | { name: '日本(东京)', value: 'oss-ap-northeast-1' }, 26 | { name: '韩国(首尔)', value: 'oss-ap-northeast-2' }, 27 | { name: '新加坡', value: 'oss-ap-southeast-1' }, 28 | { name: '马来西亚(吉隆坡)', value: 'oss-ap-southeast-3' }, 29 | { name: '印度尼西亚(雅加达)', value: 'oss-ap-southeast-5' }, 30 | { name: '菲律宾(马尼拉)', value: 'oss-ap-southeast-6' }, 31 | { name: '泰国(曼谷)', value: 'oss-ap-southeast-7' }, 32 | { name: '德国(法兰克福)', value: 'oss-eu-central-1' }, 33 | { name: '英国(伦敦)', value: 'oss-eu-west-1' }, 34 | { name: '美国(硅谷)', value: 'oss-us-west-1' }, 35 | { name: '美国(弗吉尼亚)', value: 'oss-us-east-1' }, 36 | { name: '阿联酋(迪拜)', value: 'oss-me-east-1' }, 37 | { name: '华东1 金融云', value: 'oss-cn-hzjbp' }, 38 | { name: '华东2 金融云', value: 'oss-cn-shanghai-finance-1' }, 39 | { name: '华南1 金融云', value: 'oss-cn-shenzhen-finance-1' }, 40 | { name: '华北2 金融云(邀测)', value: 'oss-cn-beijing-finance-1' }, 41 | { name: '杭州金融云公网', value: 'oss-cn-hzfinance' }, 42 | { name: '上海金融云公网', value: 'oss-cn-shanghai-finance-1-pub' }, 43 | { name: '深圳金融云公网', value: 'oss-cn-szfinance' }, 44 | { name: '北京金融云公网', value: 'oss-cn-beijing-finance-1-pub' }, 45 | { name: '华北2 阿里政务云1', value: 'oss-cn-north-2-gov-1' } 46 | ]; 47 | 48 | export const ENV_NAMES: EnvName[] = [ 49 | { key: 'ALIOSS_REGION', name: 'region' }, 50 | { key: 'ALIOSS_AK', name: 'ak' }, 51 | { key: 'ALIOSS_SK', name: 'sk' }, 52 | { key: 'ALIOSS_STSTOKEN', name: 'stsToken' }, 53 | { key: 'ALIOSS_BUCKET', name: 'bucket' }, 54 | { key: 'ALIOSS_PREFIX', name: 'prefix' }, 55 | { key: 'ALIOSS_BUILDCOMMAND', name: 'buildCommand' } 56 | ]; 57 | -------------------------------------------------------------------------------- /src/schematics/ali-oss/deploy.ts: -------------------------------------------------------------------------------- 1 | import { BuilderContext } from '@angular-devkit/architect'; 2 | import OSS from 'ali-oss'; 3 | import { ENV_NAMES } from './config'; 4 | import { DeployBuilderSchema } from '../core/types'; 5 | import { fixEnvValues, readFiles, uploadFiles } from '../core/utils'; 6 | 7 | interface AliOSSDeployBuilderSchema extends DeployBuilderSchema { 8 | region: string; 9 | ak: string; 10 | sk: string; 11 | stsToken: string; 12 | bucket: string; 13 | prefix: string; 14 | } 15 | // 30分钟 16 | const TIMEOUT = 1000 * 60 * 30; 17 | 18 | function fixConfig(schema: AliOSSDeployBuilderSchema, context: BuilderContext) { 19 | fixEnvValues(schema, ENV_NAMES); 20 | schema.prefix = schema.prefix || ''; 21 | if (schema.prefix.length > 0 && !schema.prefix.endsWith('/')) { 22 | schema.prefix += '/'; 23 | } 24 | const logConfog: Record = { 25 | outputPath: schema.outputPath, 26 | region: schema.region, 27 | ak: schema.ak, 28 | sk: schema.sk, 29 | stsToken: schema.stsToken, 30 | bucket: schema.bucket, 31 | prefix: schema.prefix 32 | }; 33 | context.logger.info(`📦Current configuration:`); 34 | Object.keys(logConfog).forEach(key => { 35 | context.logger.info(` ${key} = ${logConfog[key]}`); 36 | }); 37 | } 38 | 39 | async function clear(schema: AliOSSDeployBuilderSchema, context: BuilderContext, client: OSS): Promise { 40 | context.logger.info(`🤣 Start checking pre-deleted files`); 41 | const resp = await client.list({ prefix: schema.prefix, 'max-keys': 1000 }, {}); 42 | if (resp.objects == null || resp.objects.length === 0) { 43 | context.logger.info(` No need to delete files`); 44 | return; 45 | } 46 | context.logger.info(` Check that you need to delete ${resp.objects.length} files`); 47 | const promises: Promise[] = []; 48 | for (const item of resp.objects) { 49 | promises.push(client.delete(item.name)); 50 | } 51 | if (promises.length > 0) { 52 | await Promise.all(promises); 53 | context.logger.info(` Successfully deleted`); 54 | } 55 | } 56 | 57 | async function upload(schema: AliOSSDeployBuilderSchema, context: BuilderContext, client: OSS) { 58 | const list = readFiles({ dirPath: schema.outputPath, stream: true }); 59 | const promises = list.map(item => { 60 | return () => { 61 | const key = `${schema.prefix}${item.key}`; 62 | context.logger.info(` Uploading "${item.filePath}" => "${key}`); 63 | return client.put(key, item.stream) as Promise; 64 | }; 65 | }); 66 | context.logger.info(`😀 Start uploading files`); 67 | await uploadFiles(schema, promises); 68 | context.logger.info(`✅ Complete all uploads`); 69 | } 70 | 71 | export async function ngDeployAliOSS(schema: AliOSSDeployBuilderSchema, context: BuilderContext) { 72 | fixConfig(schema, context); 73 | 74 | const client = new OSS({ 75 | region: schema.region, 76 | accessKeyId: schema.ak, 77 | accessKeySecret: schema.sk, 78 | bucket: schema.bucket, 79 | stsToken: schema.stsToken, 80 | timeout: TIMEOUT 81 | }); 82 | if (schema.preClean) { 83 | await clear(schema, context, client); 84 | } 85 | await upload(schema, context, client); 86 | 87 | context.logger.warn( 88 | `📌注意:阿里云OSS在未绑定域名的情况下直接打开 index.html 会以下载的形式出现,如何设置静态网站托管请参考:https://help.aliyun.com/document_detail/31899.html` 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /src/schematics/ali-oss/ng-add.ts: -------------------------------------------------------------------------------- 1 | import { Rule, Tree } from '@angular-devkit/schematics'; 2 | import { PluginOptions } from '../core/types'; 3 | import { input, addDeployArchitect, list } from '../core/utils'; 4 | import { REGIONS } from './config'; 5 | 6 | export function ngAddOSS(options: PluginOptions): Rule { 7 | return async (tree: Tree) => { 8 | const opt = { 9 | region: await list(`请选择 OSS Region:`, REGIONS), 10 | ak: await input(`请输入 AccessKeyId:`), 11 | sk: await input(`请输入 AccessKeySecret:`), 12 | bucket: await input(`请输入 Bucket:`), 13 | }; 14 | 15 | await addDeployArchitect(tree, options, opt); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/schematics/core/config.ts: -------------------------------------------------------------------------------- 1 | export const MESSAGES = { 2 | input_preDeletedFiles: `是否上传前预删除所有文件,否则以覆盖的形式上传`, 3 | input_prefix: `请输入路径前缀,如果不指定表示放在根目录下:`, 4 | input_buildCommand: `请输入构建生产环境的 NPM 命令行(例如:npm run build),若为空表示自动根据 angular.json 构建生成环境`, 5 | input_oneByOneUpload: `是否逐个上传文件,并且将所有 html 放在最后上传`, 6 | }; 7 | -------------------------------------------------------------------------------- /src/schematics/core/load-esm.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Lazily compiled dynamic import loader function. 3 | */ 4 | let load: ((modulePath: string | URL) => Promise) | undefined; 5 | 6 | /** 7 | * This uses a dynamic import to load a module which may be ESM. 8 | * CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript 9 | * will currently, unconditionally downlevel dynamic import into a require call. 10 | * require calls cannot load ESM code and will result in a runtime error. To workaround 11 | * this, a Function constructor is used to prevent TypeScript from changing the dynamic import. 12 | * Once TypeScript provides support for keeping the dynamic import this workaround can 13 | * be dropped. 14 | * 15 | * @param modulePath The path of the module to load. 16 | * @returns A Promise that resolves to the dynamically imported module. 17 | */ 18 | export function loadEsmModule(modulePath: string | URL): Promise { 19 | load ??= new Function('modulePath', `return import(modulePath);`) as Exclude; 20 | 21 | return load(modulePath); 22 | } 23 | -------------------------------------------------------------------------------- /src/schematics/core/types.ts: -------------------------------------------------------------------------------- 1 | export type NgAddType = 'qiniu' | 'upyun' | 'ali-oss'; 2 | 3 | export interface NgAddOptions { 4 | project: string; 5 | type: NgAddType; 6 | } 7 | 8 | export interface PluginOptions { 9 | ngAdd: NgAddOptions; 10 | projectName: string; 11 | workspaceSchema: any; 12 | outputPath: string; 13 | } 14 | 15 | export interface DeployBuilderSchema { 16 | type: NgAddType; 17 | outputPath: string; 18 | configuration: 'production'; 19 | noBuild: boolean; 20 | buildCommand: string; 21 | baseHref: string; 22 | /** 是否预清除所有远程目录下的文件 */ 23 | preClean: boolean; 24 | /** 是否逐个上传 */ 25 | oneByOneUpload: boolean; 26 | '--'?: string[]; 27 | } 28 | 29 | export interface EnvName { 30 | key: string; 31 | name: string; 32 | } 33 | -------------------------------------------------------------------------------- /src/schematics/core/utils.ts: -------------------------------------------------------------------------------- 1 | import { Tree, SchematicsException } from '@angular-devkit/schematics'; 2 | // import { addPackageJsonDependency, NodeDependencyType } from 'schematics-utilities'; 3 | import { readdirSync, statSync, createReadStream, ReadStream } from 'fs-extra'; 4 | import { join } from 'path'; 5 | import { PluginOptions, EnvName, DeployBuilderSchema } from './types'; 6 | import { MESSAGES } from './config'; 7 | import { loadEsmModule } from './load-esm'; 8 | import { parse } from 'jsonc-parser'; 9 | 10 | export function getPath(tree: Tree): string { 11 | const possibleFiles = ['/angular.json', '/.angular.json']; 12 | const path = possibleFiles.filter(file => tree.exists(file))[0]; 13 | return path; 14 | } 15 | 16 | export function getWorkspace(tree: Tree): any { 17 | const configBuffer = tree.read(getPath(tree)); 18 | if (configBuffer === null) { 19 | throw new SchematicsException('Could not find angular.json'); 20 | } 21 | 22 | return parse(configBuffer.toString()); 23 | } 24 | 25 | export function getProject(tree: Tree, projectName: string) { 26 | const workspace = getWorkspace(tree); 27 | const projectNames = Object.keys(workspace.projects); 28 | const name = projectName! || (projectNames.length > 0 ? projectNames[0] : null); 29 | if (!name) { 30 | throw new SchematicsException('No Angular project selected and no default project in the workspace'); 31 | } 32 | // Validating project name 33 | const project = workspace.projects[name]; 34 | if (!project) { 35 | throw new SchematicsException('The specified Angular project is not defined in this workspace'); 36 | } 37 | 38 | // Checking if it is application 39 | if (project.projectType !== 'application') { 40 | throw new SchematicsException(`Deploy requires an Angular project type of "application" in angular.json`); 41 | } 42 | 43 | // Getting output path from Angular.json 44 | if ( 45 | !project.architect || 46 | !project.architect.build || 47 | !project.architect.build.options || 48 | !project.architect.build.options.outputPath 49 | ) { 50 | throw new SchematicsException( 51 | `Cannot read the output path(architect.build.options.outputPath) of the Angular project "${projectName}" in angular.json` 52 | ); 53 | } 54 | return { name, workspace, outputPath: project.architect.build.options.outputPath }; 55 | } 56 | 57 | export async function addDeployArchitect(tree: Tree, options: PluginOptions, deployOptions: Record) { 58 | deployOptions = { 59 | outputPath: options.outputPath, 60 | type: options.ngAdd.type, 61 | ...deployOptions, 62 | prefix: await input(MESSAGES.input_prefix), 63 | buildCommand: await input(MESSAGES.input_buildCommand), 64 | preClean: await confirm(MESSAGES.input_preDeletedFiles, true), 65 | oneByOneUpload: await confirm(MESSAGES.input_oneByOneUpload, false) 66 | }; 67 | Object.keys(deployOptions) 68 | .filter(key => deployOptions[key] == null || deployOptions[key] === '') 69 | .forEach(key => delete deployOptions[key]); 70 | const project = options.workspaceSchema.projects[options.projectName]; 71 | project.architect!['deploy'] = { 72 | builder: 'ng-deploy-oss:deploy', 73 | options: deployOptions 74 | }; 75 | 76 | const workspacePath = getPath(tree); 77 | tree.overwrite(workspacePath, JSON.stringify(options.workspaceSchema, null, 2)); 78 | 79 | // addPackageJsonDependency(tree, { type: NodeDependencyType.Dev, version: 'VERSIONPLACEHOLDER', name: 'ng-deploy-oss' }); 80 | } 81 | 82 | export function fixAdditionalProperties(options: Record) { 83 | if (!Array.isArray(options['--'])) return; 84 | options['--'] 85 | .filter(w => w.startsWith('--')) 86 | .forEach(optStr => { 87 | const arr = optStr.substr(2).split('='); 88 | options[arr[0]] = arr[1]; 89 | }); 90 | } 91 | 92 | export function fixEnvValues(options: Record, envData: EnvName[]) { 93 | for (const envItem of envData) { 94 | const envValue = process.env[envItem.key]; 95 | if (envValue != null) { 96 | options[envItem.name] = envValue; 97 | } 98 | } 99 | } 100 | 101 | export function readFiles(options: { 102 | dirPath: string; 103 | stream?: boolean; 104 | }): { filePath: string; stream: ReadStream | null; key: string }[] { 105 | const startLen = options.dirPath.length + 1; 106 | const fileList: string[] = []; 107 | const fn = (p: string) => { 108 | readdirSync(p).forEach(filePath => { 109 | const fullPath = join(p, filePath); 110 | if (statSync(fullPath).isDirectory()) { 111 | fn(fullPath); 112 | return; 113 | } 114 | fileList.push(fullPath); 115 | }); 116 | }; 117 | fn(options.dirPath); 118 | 119 | // 将所有 `.html` 放置最后上传 120 | // https://github.com/cipchk/ng-deploy-oss/issues/13 121 | fileList.sort(a => (a.endsWith('.html') ? 1 : -1)); 122 | return fileList.map(fullPath => ({ 123 | filePath: fullPath, 124 | stream: options.stream === true ? createReadStream(fullPath) : null, 125 | // 修复 window 下的分隔符会引起 %5C 126 | key: fullPath.substr(startLen).replace(/\\/g, '/') 127 | })); 128 | } 129 | 130 | export async function uploadFiles(schema: DeployBuilderSchema, promises: (() => Promise)[]): Promise { 131 | if (!schema.oneByOneUpload) { 132 | return Promise.all(promises.map(fn => fn())); 133 | } 134 | for (const item of promises) { 135 | await item(); 136 | } 137 | } 138 | 139 | export function normalizePath(...args: string[]): string { 140 | if (args.length <= 1) return args.join(''); 141 | return args 142 | .map((val, idx) => { 143 | if (idx <= 0) return val; 144 | if (val.startsWith('/')) return val.substr(1); 145 | return val; 146 | }) 147 | .join('/'); 148 | } 149 | 150 | export async function input(message: string): Promise { 151 | const { default: inquirer } = await loadEsmModule('inquirer'); 152 | const { ok } = await inquirer.prompt<{ ok: any }>([ 153 | { 154 | type: 'input', 155 | name: 'ok', 156 | message 157 | } 158 | ]); 159 | return ok; 160 | } 161 | 162 | export async function list(message: string, choices: { name: string; value: any }[]): Promise { 163 | const { default: inquirer } = await loadEsmModule('inquirer'); 164 | const { ok } = await inquirer.prompt<{ ok: any }>([ 165 | { 166 | type: 'list', 167 | name: 'ok', 168 | message, 169 | choices 170 | } 171 | ]); 172 | return ok; 173 | } 174 | 175 | export async function confirm(message: string, confirmByDefault = false): Promise { 176 | const { default: inquirer } = await loadEsmModule('inquirer'); 177 | const { ok } = await inquirer.prompt<{ ok: any }>([ 178 | { 179 | type: 'confirm', 180 | name: 'ok', 181 | default: confirmByDefault, 182 | message 183 | } 184 | ]); 185 | return ok; 186 | } 187 | -------------------------------------------------------------------------------- /src/schematics/deploy/builder.ts: -------------------------------------------------------------------------------- 1 | import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect'; 2 | import { json } from '@angular-devkit/core'; 3 | import { DeployBuilderSchema } from '../core/types'; 4 | import { fixAdditionalProperties } from '../core/utils'; 5 | import { ngDeployQiniu } from '../qiniu/deploy'; 6 | import { ngDeployUpyun } from '../upyun/deploy'; 7 | import { ngDeployAliOSS } from '../ali-oss/deploy'; 8 | import { execSync } from 'child_process'; 9 | 10 | async function build(schema: DeployBuilderSchema, context: BuilderContext) { 11 | context.logger.info(`🥶Executing ${schema.type} deploy...`); 12 | if (schema.noBuild) { 13 | context.logger.info(`😀Skipping build`); 14 | return; 15 | } 16 | 17 | if (schema.buildCommand) { 18 | context.logger.info(`📦Building via "${schema.buildCommand}"`); 19 | 20 | execSync(schema.buildCommand); 21 | context.logger.info(`😍Build Completed`); 22 | return; 23 | } 24 | 25 | const configuration = schema.configuration || 'production'; 26 | 27 | const overrides = { 28 | // this is an example how to override the workspace set of options 29 | ...(schema.baseHref && { baseHref: schema.baseHref }) 30 | }; 31 | 32 | if (!context.target) { 33 | throw new Error('Cannot build the application without a target'); 34 | } 35 | 36 | const baseHref = schema.baseHref ? `Your base-href: "${schema.baseHref}` : ''; 37 | context.logger.info(`📦Building "${context.target.project}". Configuration: "${configuration}". ${baseHref}`); 38 | 39 | const buildTarget = await context.scheduleTarget( 40 | { 41 | target: 'build', 42 | project: context.target.project || '', 43 | configuration 44 | }, 45 | overrides as json.JsonObject 46 | ); 47 | 48 | const buildResult = await buildTarget.result; 49 | 50 | if (buildResult.success !== true) { 51 | context.logger.error(`❌Application build failed`); 52 | return { 53 | error: `❌Application build failed`, 54 | success: false 55 | }; 56 | } 57 | 58 | context.logger.info(`😍Build Completed`); 59 | } 60 | 61 | // Call the createBuilder() function to create a builder. This mirrors 62 | // createJobHandler() but add typings specific to Architect Builders. 63 | export default createBuilder( 64 | async (schema: DeployBuilderSchema, context: BuilderContext): Promise => { 65 | fixAdditionalProperties(schema); 66 | await build(schema, context); 67 | switch (schema.type) { 68 | case 'qiniu': 69 | await ngDeployQiniu(schema as any, context); 70 | break; 71 | case 'upyun': 72 | await ngDeployUpyun(schema as any, context); 73 | break; 74 | case 'ali-oss': 75 | await ngDeployAliOSS(schema as any, context); 76 | break; 77 | default: 78 | context.logger.error(`Invalid cloud type "${schema.type}"`); 79 | break; 80 | } 81 | return { success: true }; 82 | } 83 | ); 84 | -------------------------------------------------------------------------------- /src/schematics/deploy/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "ng-deploy-oss", 4 | "title": "ng-deploy-oss Schema", 5 | "properties": { 6 | "noBuild": { 7 | "type": "boolean", 8 | "default": false, 9 | "description": "Skip build process during deployment." 10 | } 11 | }, 12 | "additionalProperties": true 13 | } 14 | -------------------------------------------------------------------------------- /src/schematics/index.ts: -------------------------------------------------------------------------------- 1 | export * from './public_api'; 2 | -------------------------------------------------------------------------------- /src/schematics/ng-add.spec.ts: -------------------------------------------------------------------------------- 1 | import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; 2 | import { Schema as WorkspaceOptions } from '@schematics/angular/workspace/schema'; 3 | import { Schema as ApplicationOptions } from '@schematics/angular/application/schema'; 4 | // import { Tree } from '@angular-devkit/schematics'; 5 | 6 | const collectionPath = require.resolve('../collection.json'); 7 | const workspaceOptions: WorkspaceOptions = { 8 | name: 'workspace', 9 | newProjectRoot: 'tests', 10 | version: '8.0.0' 11 | }; 12 | const appOptions: ApplicationOptions = { name: 'test-app' }; 13 | 14 | describe('ng add ng-deploy-oss', () => { 15 | let tree: UnitTestTree; 16 | const testRunner = new SchematicTestRunner('schematics', collectionPath); 17 | beforeEach(async () => { 18 | const appTree = await testRunner.runExternalSchematic('@schematics/angular', 'workspace', workspaceOptions); 19 | tree = await testRunner.runExternalSchematic('@schematics/angular', 'application', appOptions, appTree); 20 | }); 21 | xdescribe('ng add', () => { 22 | it('should be working', async () => { 23 | tree = await testRunner.runSchematic('ng-add', {}, tree); 24 | const angularJson = JSON.parse(tree.readContent('/angular.json')); 25 | const deploy = angularJson.projects[appOptions.name].architect.deploy; 26 | expect(deploy).toBeDefined(); 27 | expect(deploy.builder).toBe(`ng-deploy-oss:deploy`); 28 | }); 29 | // ['ali-oss', 'upyun'].forEach((type) => { 30 | // it(`should be ${type} via type`, async () => { 31 | // tree = await testRunner.runSchematic('ng-add', { type }, tree); 32 | // const angularJson = JSON.parse(tree.readContent('/angular.json')); 33 | // const deploy = angularJson.projects[appOptions.name].architect.deploy; 34 | // expect(deploy).toBeDefined(); 35 | // expect(deploy.options.type).toBe(type); 36 | // }); 37 | // }); 38 | // it('should be throw error when is invalid type', async () => { 39 | // await expect(testRunner.runSchematic('ng-add', { type: 'INVALIDTYPE' }, Tree.empty())).rejects.toThrow(); 40 | // }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/schematics/ng-add.ts: -------------------------------------------------------------------------------- 1 | import { chain, Rule, SchematicContext, Tree, SchematicsException } from '@angular-devkit/schematics'; 2 | import { NgAddOptions, PluginOptions } from './core/types'; 3 | import { getProject } from './core/utils'; 4 | import { ngAddQiniu } from './qiniu/ng-add'; 5 | import { ngAddUpyun } from './upyun/ng-add'; 6 | import { ngAddOSS } from './ali-oss/ng-add'; 7 | import { join } from 'path'; 8 | 9 | export const ngAdd = (options: NgAddOptions): Rule => { 10 | return (tree: Tree, context: SchematicContext) => { 11 | const project = getProject(tree, options.project); 12 | const opt: PluginOptions = { 13 | ngAdd: options, 14 | projectName: project.name, 15 | workspaceSchema: project.workspace, 16 | outputPath: project.outputPath 17 | }; 18 | // angular17 will add additional browser 19 | opt.outputPath = join(opt.outputPath, 'browser'); 20 | 21 | const rules: Rule[] = []; 22 | switch (options.type) { 23 | case 'qiniu': 24 | rules.push(ngAddQiniu(opt)); 25 | break; 26 | case 'upyun': 27 | rules.push(ngAddUpyun(opt)); 28 | break; 29 | case 'ali-oss': 30 | rules.push(ngAddOSS(opt)); 31 | break; 32 | default: 33 | throw new SchematicsException(`Invalid cloud type "${options.type}"`); 34 | } 35 | return chain(rules)(tree, context); 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /src/schematics/public_api.ts: -------------------------------------------------------------------------------- 1 | export * from './ng-add'; 2 | export * from './deploy/builder'; 3 | -------------------------------------------------------------------------------- /src/schematics/qiniu/config.ts: -------------------------------------------------------------------------------- 1 | import { EnvName } from '../core/types'; 2 | 3 | /** 4 | * https://developer.qiniu.com/kodo/1671/region-endpoint-fq 5 | * ```js 6 | * var list = []; document.querySelectorAll('table tbody tr').forEach(el => { 7 | const regionID = el.querySelector('td:nth-child(2)').innerText.trim().replace(/-/g, '_'); 8 | list.push(`{ name: 'qiniu.zone.Zone_${regionID} - ${el.querySelector('td:nth-child(1)').innerText.trim()}', value: 'Zone_${regionID}' }`); 9 | });console.log(list.join('\n')); 10 | ``` 11 | */ 12 | export const ZONES = [ 13 | { name: 'qiniu.zone.Zone_z0 - 华东-浙江', value: 'Zone_z0' }, 14 | { name: 'qiniu.zone.Zone_cn_east_2 - 华东-浙江2', value: 'Zone_cn_east_2' }, 15 | { name: 'qiniu.zone.Zone_z1 - 华北-河北', value: 'Zone_z1' }, 16 | { name: 'qiniu.zone.Zone_z2 - 华南-广东', value: 'Zone_z2' }, 17 | { name: 'qiniu.zone.Zone_cn_northwest_1 - 西北-陕西1', value: 'Zone_cn_northwest_1' }, 18 | { name: 'qiniu.zone.Zone_na0 - 北美-洛杉矶', value: 'Zone_na0' }, 19 | { name: 'qiniu.zone.Zone_as0 - 亚太-新加坡(原东南亚)', value: 'Zone_as0' }, 20 | { name: 'qiniu.zone.Zone_ap_southeast_2 - 亚太-河内', value: 'Zone_ap_southeast_2' }, 21 | { name: 'qiniu.zone.Zone_ap_southeast_3 - 亚太-胡志明', value: 'Zone_ap_southeast_3' } 22 | ]; 23 | 24 | export const ENV_NAMES: EnvName[] = [ 25 | { key: 'QINIU_AK', name: 'ak' }, 26 | { key: 'QINIU_SK', name: 'sk' }, 27 | { key: 'QINIU_ZONE', name: 'zone' }, 28 | { key: 'QINIU_BUCKET', name: 'bucket' }, 29 | { key: 'QINIU_PREFIX', name: 'prefix' }, 30 | { key: 'QINIU_BUILDCOMMAND', name: 'buildCommand' } 31 | ]; 32 | -------------------------------------------------------------------------------- /src/schematics/qiniu/deploy.ts: -------------------------------------------------------------------------------- 1 | import { BuilderContext } from '@angular-devkit/architect'; 2 | import * as qiniu from 'qiniu'; 3 | import { ENV_NAMES } from './config'; 4 | import { DeployBuilderSchema } from '../core/types'; 5 | import { fixEnvValues, readFiles, uploadFiles } from '../core/utils'; 6 | 7 | interface QiniuDeployBuilderSchema extends DeployBuilderSchema { 8 | ak: string; 9 | sk: string; 10 | zone: keyof typeof qiniu.zone; 11 | bucket: string; 12 | prefix: string; 13 | } 14 | 15 | function fixConfig(schema: QiniuDeployBuilderSchema, context: BuilderContext) { 16 | fixEnvValues(schema, ENV_NAMES); 17 | schema.prefix = schema.prefix || ''; 18 | if (schema.prefix.length > 0 && !schema.prefix.endsWith('/')) { 19 | schema.prefix += '/'; 20 | } 21 | const logConfog: Record = { 22 | outputPath: schema.outputPath, 23 | ak: schema.ak, 24 | sk: schema.sk, 25 | zone: `qiniu.zone.${schema.zone}`, 26 | bucket: schema.bucket, 27 | prefix: schema.prefix 28 | }; 29 | context.logger.info(`📦Current configuration:`); 30 | Object.keys(logConfog).forEach(key => { 31 | context.logger.info(` ${key} = ${logConfog[key]}`); 32 | }); 33 | } 34 | 35 | async function listPrefix(schema: QiniuDeployBuilderSchema, bucketManager: qiniu.rs.BucketManager) { 36 | return new Promise((reslove, reject) => { 37 | bucketManager.listPrefix(schema.bucket, { prefix: schema.prefix, limit: 999999 }, (err, respBody, respInfo) => { 38 | if (err) { 39 | reject(err); 40 | return; 41 | } 42 | if (respInfo.statusCode !== 200) { 43 | reject(respBody); 44 | return; 45 | } 46 | 47 | reslove(respBody.items); 48 | }); 49 | }); 50 | } 51 | 52 | async function clear( 53 | schema: QiniuDeployBuilderSchema, 54 | context: BuilderContext, 55 | bucketManager: qiniu.rs.BucketManager 56 | ): Promise { 57 | context.logger.info(`🤣 Start checking pre-deleted files`); 58 | const items = (await listPrefix(schema, bucketManager)) as any[]; 59 | if (items.length === 0) { 60 | context.logger.info(` No need to delete files`); 61 | return; 62 | } 63 | context.logger.info(` Check that you need to delete ${items.length} files`); 64 | const promises: Promise[] = []; 65 | for (const item of items) { 66 | const p = new Promise((itemReslove, itemReject) => { 67 | bucketManager.delete(schema.bucket, item.key, (err, respBody, respInfo) => { 68 | if (err) { 69 | itemReject(err); 70 | return; 71 | } 72 | if (respInfo.statusCode !== 200) { 73 | itemReject(respBody); 74 | return; 75 | } 76 | 77 | itemReslove(); 78 | }); 79 | }); 80 | promises.push(p); 81 | } 82 | if (promises.length > 0) { 83 | await Promise.all(promises); 84 | context.logger.info(` Successfully deleted`); 85 | } 86 | } 87 | 88 | export async function ngDeployQiniu(schema: QiniuDeployBuilderSchema, context: BuilderContext) { 89 | fixConfig(schema, context); 90 | 91 | const mac = new qiniu.auth.digest.Mac(schema.ak, schema.sk); 92 | const config = new qiniu.conf.Config({ zone: qiniu.zone[schema.zone] }); 93 | // 删除文件 94 | if (schema.preClean) { 95 | const bucketManager = new qiniu.rs.BucketManager(mac, config); 96 | await clear(schema, context, bucketManager); 97 | } 98 | // 上传文件 99 | const uploadToken = new qiniu.rs.PutPolicy({ scope: schema.bucket }).uploadToken(mac); 100 | const formUploader = new qiniu.form_up.FormUploader(config); 101 | const list = readFiles({ dirPath: schema.outputPath }); 102 | const promises = list.map(item => { 103 | return () => { 104 | return new Promise((reslove, reject) => { 105 | const key = `${schema.prefix}${item.key}`; 106 | const putExtra = new qiniu.form_up.PutExtra(); 107 | formUploader.putFile(uploadToken, key, item.filePath, putExtra, (respErr, respBody, respInfo) => { 108 | if (respErr) { 109 | reject(respErr); 110 | return; 111 | } 112 | if (respInfo.statusCode !== 200) { 113 | reject(respBody); 114 | return; 115 | } 116 | context.logger.info(` Uploading "${item.filePath}" => "${key}`); 117 | reslove(); 118 | }); 119 | }) as Promise; 120 | }; 121 | }); 122 | 123 | context.logger.info(`😀 Start uploading files`); 124 | await uploadFiles(schema, promises); 125 | context.logger.info(`✅ Complete all uploads`); 126 | context.logger.warn( 127 | `📌注意:七牛云默认没有打开【默认首页设置】,不建议打开 Hash URL 路由策略,由于没有相应 API 接口只能手动对 404 页面设置,所有配置请至空间设置进行。` 128 | ); 129 | } 130 | -------------------------------------------------------------------------------- /src/schematics/qiniu/ng-add.ts: -------------------------------------------------------------------------------- 1 | import { Rule, Tree } from '@angular-devkit/schematics'; 2 | import { PluginOptions } from '../core/types'; 3 | import { input, addDeployArchitect, list } from '../core/utils'; 4 | import { ZONES } from './config'; 5 | 6 | export function ngAddQiniu(options: PluginOptions): Rule { 7 | return async (tree: Tree) => { 8 | const opt = { 9 | ak: await input(`请输入 AccessKey:`), 10 | sk: await input(`请输入 SecretKey:`), 11 | zone: await list(`所在机房:`, ZONES), 12 | bucket: await input(`请输入 Bucket:`), 13 | }; 14 | 15 | await addDeployArchitect(tree, options, opt); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/schematics/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "ng-deploy-oss-ng-add", 4 | "title": "ng-deploy-oss Add Options Schema", 5 | "type": "object", 6 | "properties": { 7 | "project": { 8 | "type": "string", 9 | "description": "The name of the project.", 10 | "$default": { 11 | "$source": "projectName" 12 | } 13 | }, 14 | "type": { 15 | "type": "string", 16 | "description": "指定云类型", 17 | "default": "qiniu", 18 | "x-prompt": { 19 | "message": "请选择云类型?", 20 | "type": "list", 21 | "items": [ 22 | { 23 | "value": "qiniu", 24 | "label": "七牛云" 25 | }, 26 | { 27 | "value": "upyun", 28 | "label": "又拍云" 29 | }, 30 | { 31 | "value": "ali-oss", 32 | "label": "阿里云" 33 | } 34 | ] 35 | } 36 | } 37 | }, 38 | "required": [], 39 | "additionalProperties": true 40 | } 41 | -------------------------------------------------------------------------------- /src/schematics/upyun/config.ts: -------------------------------------------------------------------------------- 1 | import { EnvName } from '../core/types'; 2 | 3 | export const ENV_NAMES: EnvName[] = [ 4 | { key: 'UPYUN_NAME', name: 'name' }, 5 | { key: 'UPYUN_OPERATORNAME', name: 'operatorName' }, 6 | { key: 'UPYUN_OPERATORPWD', name: 'operatorPwd' }, 7 | { key: 'UPYUN_PREFIX', name: 'prefix' }, 8 | { key: 'UPYUN_BUILDCOMMAND', name: 'buildCommand' }, 9 | ]; 10 | -------------------------------------------------------------------------------- /src/schematics/upyun/deploy.ts: -------------------------------------------------------------------------------- 1 | import { BuilderContext } from '@angular-devkit/architect'; 2 | import upyun from 'upyun'; 3 | import { ENV_NAMES } from './config'; 4 | import { DeployBuilderSchema } from '../core/types'; 5 | import { fixEnvValues, readFiles, uploadFiles } from '../core/utils'; 6 | 7 | interface UpyunDeployBuilderSchema extends DeployBuilderSchema { 8 | name: string; 9 | operatorName: string; 10 | operatorPwd: string; 11 | prefix: string; 12 | } 13 | 14 | function fixConfig(schema: UpyunDeployBuilderSchema, context: BuilderContext) { 15 | fixEnvValues(schema, ENV_NAMES); 16 | schema.prefix = schema.prefix || '/'; 17 | if (!schema.prefix.endsWith('/')) { 18 | schema.prefix += '/'; 19 | } 20 | const logConfog: Record = { 21 | outputPath: schema.outputPath, 22 | name: schema.name, 23 | operatorName: schema.operatorName, 24 | operatorPwd: schema.operatorPwd, 25 | prefix: schema.prefix 26 | }; 27 | context.logger.info(`📦Current configuration:`); 28 | Object.keys(logConfog).forEach(key => { 29 | context.logger.info(` ${key} = ${logConfog[key]}`); 30 | }); 31 | } 32 | 33 | async function clear(schema: UpyunDeployBuilderSchema, context: BuilderContext, client: any): Promise { 34 | context.logger.info(`🤣 Start checking pre-deleted files`); 35 | const listResp = await client.listDir(schema.prefix, { limit: 10000 }); 36 | if (listResp === false) { 37 | context.logger.info(` No need to delete files`); 38 | return; 39 | } 40 | context.logger.info(` Check that you need to delete ${listResp.files.length} files`); 41 | const promises: Promise[] = []; 42 | for (const item of listResp.files as { name: string; size: 'N' | 'F' }[]) { 43 | promises.push(client.deleteFile(item.name)); 44 | } 45 | if (promises.length > 0) { 46 | await Promise.all(promises); 47 | context.logger.info(` Successfully deleted`); 48 | } 49 | } 50 | 51 | async function upload(schema: UpyunDeployBuilderSchema, context: BuilderContext, client: any) { 52 | const list = readFiles({ dirPath: schema.outputPath, stream: true }); 53 | const promises = list.map(item => { 54 | return () => { 55 | const key = `${schema.prefix}${item.key}`; 56 | context.logger.info(` Uploading "${item.filePath}" => "${key}`); 57 | return client.putFile(key, item.stream) as Promise; 58 | }; 59 | }); 60 | context.logger.info(`😀 Start uploading files`); 61 | await uploadFiles(schema, promises); 62 | context.logger.info(`✅ Complete all uploads`); 63 | } 64 | 65 | export async function ngDeployUpyun(schema: UpyunDeployBuilderSchema, context: BuilderContext) { 66 | fixConfig(schema, context); 67 | 68 | const service = new upyun.Service(schema.name, schema.operatorName, schema.operatorPwd); 69 | const client = new upyun.Client(service); 70 | // 删除文件 71 | if (schema.preClean) { 72 | await clear(schema, context, client); 73 | } 74 | // 上传文件 75 | await upload(schema, context, client); 76 | } 77 | -------------------------------------------------------------------------------- /src/schematics/upyun/ng-add.ts: -------------------------------------------------------------------------------- 1 | import { Rule, Tree } from '@angular-devkit/schematics'; 2 | import { PluginOptions } from '../core/types'; 3 | import { input, addDeployArchitect } from '../core/utils'; 4 | 5 | export function ngAddUpyun(options: PluginOptions): Rule { 6 | return async (tree: Tree) => { 7 | const opt = { 8 | name: await input(`请输入服务名称:`), 9 | operatorName: await input(`请输入操作员名称(确保可写入&可删除权限):`), 10 | operatorPwd: await input(`请输入操作员密码:`), 11 | }; 12 | 13 | await addDeployArchitect(tree, options, opt); 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "tsconfig", 4 | "lib": ["es2018", "dom"], 5 | "outDir": "dist", 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "declaration": true, 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "noEmitOnError": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitAny": true, 14 | "noImplicitThis": true, 15 | "noUnusedParameters": false, 16 | "noUnusedLocals": true, 17 | "rootDir": "src/", 18 | "skipDefaultLibCheck": true, 19 | "skipLibCheck": true, 20 | "sourceMap": true, 21 | "strictNullChecks": true, 22 | "target": "es6", 23 | "types": ["jest", "node"], 24 | "resolveJsonModule": true, 25 | "esModuleInterop": true 26 | }, 27 | "include": ["src/**/*"], 28 | "exclude": ["**/*.spec.ts", "src/*/files/**/*", "**/__mocks__/*", "**/__tests__/*"] 29 | } 30 | --------------------------------------------------------------------------------