@oyster-lib/cli 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -0
- package/dist/index.js +25507 -0
- package/jest.config.ts +23 -0
- package/package.json +33 -0
- package/src/__tests__/templates.test.ts +283 -0
- package/src/commands/deploy.ts +109 -0
- package/src/commands/init.ts +154 -0
- package/src/commands/login.ts +78 -0
- package/src/commands/migrate.ts +81 -0
- package/src/config.ts +86 -0
- package/src/index.ts +22 -0
- package/src/templates/backend.ts +119 -0
- package/src/templates/frontend.ts +139 -0
- package/src/templates/index.ts +149 -0
- package/src/utils/http.ts +131 -0
- package/src/utils/input.ts +89 -0
- package/tsconfig.json +16 -0
package/jest.config.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Config } from 'jest'
|
|
2
|
+
|
|
3
|
+
const config: Config = {
|
|
4
|
+
preset: 'ts-jest/presets/default-esm',
|
|
5
|
+
testEnvironment: 'node',
|
|
6
|
+
roots: ['<rootDir>/src'],
|
|
7
|
+
testMatch: ['**/__tests__/**/*.test.ts'],
|
|
8
|
+
moduleNameMapper: {
|
|
9
|
+
'^(\\.{1,2}/.*)\\.js$': '$1'
|
|
10
|
+
},
|
|
11
|
+
transform: {
|
|
12
|
+
'^.+\\.tsx?$': [
|
|
13
|
+
'ts-jest',
|
|
14
|
+
{
|
|
15
|
+
useESM: true,
|
|
16
|
+
tsconfig: 'tsconfig.json'
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
extensionsToTreatAsEsm: ['.ts']
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default config
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oyster-lib/cli",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"oyster": "dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "NODE_ENV=production bun build src/index.ts --target=node --outdir=./dist",
|
|
13
|
+
"dev": "NODE_ENV=development bun build src/index.ts --target=node --outdir=./dist",
|
|
14
|
+
"test": "NODE_OPTIONS='--experimental-vm-modules' jest"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"axios": "^1.7.0",
|
|
18
|
+
"chalk": "^5.3.0",
|
|
19
|
+
"commander": "^12.1.0",
|
|
20
|
+
"form-data": "^4.0.0",
|
|
21
|
+
"ora": "^8.0.1",
|
|
22
|
+
"prompts": "^2.4.2"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/bun": "latest",
|
|
26
|
+
"@types/jest": "^30.0.0",
|
|
27
|
+
"@types/node": "^25.2.3",
|
|
28
|
+
"@types/prompts": "^2.4.9",
|
|
29
|
+
"jest": "^30.2.0",
|
|
30
|
+
"ts-jest": "^29.4.6",
|
|
31
|
+
"typescript": "^5.8.3"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import os from 'os'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { generateFrontendFiles } from '../templates/frontend.js'
|
|
5
|
+
import { generateBackendFiles } from '../templates/backend.js'
|
|
6
|
+
import { generateAllFiles, generateAppFiles, writeFiles } from '../templates/index.js'
|
|
7
|
+
|
|
8
|
+
// テスト用一時ディレクトリ
|
|
9
|
+
let tmpDir: string
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oyster-cli-test-'))
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// ============================================================
|
|
20
|
+
// 1. フロントエンドテンプレート生成(正常系)
|
|
21
|
+
// ============================================================
|
|
22
|
+
describe('generateFrontendFiles', () => {
|
|
23
|
+
// FE-N-01: 正しいファイルパスのマップが返る
|
|
24
|
+
test('FE-N-01: returns correct file path map', () => {
|
|
25
|
+
const files = generateFrontendFiles('example.com', 'myapp')
|
|
26
|
+
const paths = Object.keys(files)
|
|
27
|
+
|
|
28
|
+
expect(paths).toContain('frontend/example.com/src/apps/myapp/index.ts')
|
|
29
|
+
expect(paths).toContain('frontend/example.com/src/shared/index.ts')
|
|
30
|
+
expect(paths).toContain('frontend/example.com/src/index.css')
|
|
31
|
+
expect(paths).toContain('frontend/example.com/package.json')
|
|
32
|
+
expect(paths).toContain('frontend/example.com/tsconfig.json')
|
|
33
|
+
expect(paths).toContain('frontend/example.com/tailwind.config.js')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// FE-N-02: index.ts が React コンポーネントをエクスポート
|
|
37
|
+
test('FE-N-02: app index.ts exports a React component', () => {
|
|
38
|
+
const files = generateFrontendFiles('example.com', 'myapp')
|
|
39
|
+
const content = files['frontend/example.com/src/apps/myapp/index.ts']
|
|
40
|
+
|
|
41
|
+
expect(content).toContain('export')
|
|
42
|
+
expect(content).toContain('React')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// FE-N-03: shared/index.ts が空のエクスポートファイル
|
|
46
|
+
test('FE-N-03: shared/index.ts is an empty export file', () => {
|
|
47
|
+
const files = generateFrontendFiles('example.com', 'myapp')
|
|
48
|
+
const content = files['frontend/example.com/src/shared/index.ts']
|
|
49
|
+
|
|
50
|
+
expect(content).toBeDefined()
|
|
51
|
+
expect(content.length).toBeGreaterThan(0)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// FE-N-04: index.css に @import 'tailwindcss' が含まれる
|
|
55
|
+
test("FE-N-04: index.css contains @import 'tailwindcss'", () => {
|
|
56
|
+
const files = generateFrontendFiles('example.com', 'myapp')
|
|
57
|
+
const content = files['frontend/example.com/src/index.css']
|
|
58
|
+
|
|
59
|
+
expect(content).toContain("@import 'tailwindcss'")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// FE-N-05: package.json が正しい JSON で react, react-dom を含む
|
|
63
|
+
test('FE-N-05: package.json is valid JSON with react dependencies', () => {
|
|
64
|
+
const files = generateFrontendFiles('example.com', 'myapp')
|
|
65
|
+
const content = files['frontend/example.com/package.json']
|
|
66
|
+
|
|
67
|
+
const pkg = JSON.parse(content)
|
|
68
|
+
expect(pkg.dependencies).toBeDefined()
|
|
69
|
+
expect(pkg.dependencies['react']).toBeDefined()
|
|
70
|
+
expect(pkg.dependencies['react-dom']).toBeDefined()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// FE-N-06: tsconfig.json が正しい JSON で compilerOptions.paths を含む
|
|
74
|
+
test('FE-N-06: tsconfig.json is valid JSON with compilerOptions.paths', () => {
|
|
75
|
+
const files = generateFrontendFiles('example.com', 'myapp')
|
|
76
|
+
const content = files['frontend/example.com/tsconfig.json']
|
|
77
|
+
|
|
78
|
+
const tsconfig = JSON.parse(content)
|
|
79
|
+
expect(tsconfig.compilerOptions).toBeDefined()
|
|
80
|
+
expect(tsconfig.compilerOptions.paths).toBeDefined()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// FE-N-07: tailwind.config.js が正しい内容で生成される
|
|
84
|
+
test('FE-N-07: tailwind.config.js is generated with correct content', () => {
|
|
85
|
+
const files = generateFrontendFiles('example.com', 'myapp')
|
|
86
|
+
const content = files['frontend/example.com/tailwind.config.js']
|
|
87
|
+
|
|
88
|
+
expect(content).toContain('module.exports')
|
|
89
|
+
expect(content).toContain('content')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// ============================================================
|
|
94
|
+
// 2. バックエンドテンプレート生成(正常系)
|
|
95
|
+
// ============================================================
|
|
96
|
+
describe('generateBackendFiles', () => {
|
|
97
|
+
// BE-N-01: 正しいファイルパスのマップが返る
|
|
98
|
+
test('BE-N-01: returns correct file path map', () => {
|
|
99
|
+
const files = generateBackendFiles('example.com')
|
|
100
|
+
const paths = Object.keys(files)
|
|
101
|
+
|
|
102
|
+
expect(paths).toContain('backend/server.ts')
|
|
103
|
+
expect(paths).toContain('backend/package.json')
|
|
104
|
+
expect(paths).toContain('backend/tsconfig.json')
|
|
105
|
+
expect(paths).toContain('backend/prisma/example.com/schema.prisma')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// BE-N-02: server.ts が Express ベースのスケルトン
|
|
109
|
+
test('BE-N-02: server.ts is an Express-based skeleton', () => {
|
|
110
|
+
const files = generateBackendFiles('example.com')
|
|
111
|
+
const content = files['backend/server.ts']
|
|
112
|
+
|
|
113
|
+
expect(content).toContain('express')
|
|
114
|
+
expect(content).toContain('listen')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// BE-N-03: package.json が正しい JSON で必要な依存を含む
|
|
118
|
+
test('BE-N-03: package.json has required dependencies', () => {
|
|
119
|
+
const files = generateBackendFiles('example.com')
|
|
120
|
+
const content = files['backend/package.json']
|
|
121
|
+
|
|
122
|
+
const pkg = JSON.parse(content)
|
|
123
|
+
expect(pkg.dependencies).toBeDefined()
|
|
124
|
+
expect(pkg.dependencies['express']).toBeDefined()
|
|
125
|
+
expect(pkg.devDependencies).toBeDefined()
|
|
126
|
+
expect(pkg.devDependencies['typescript']).toBeDefined()
|
|
127
|
+
expect(pkg.devDependencies['ts-node']).toBeDefined()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// BE-N-04: tsconfig.json が正しい JSON
|
|
131
|
+
test('BE-N-04: tsconfig.json is valid JSON', () => {
|
|
132
|
+
const files = generateBackendFiles('example.com')
|
|
133
|
+
const content = files['backend/tsconfig.json']
|
|
134
|
+
|
|
135
|
+
const tsconfig = JSON.parse(content)
|
|
136
|
+
expect(tsconfig.compilerOptions).toBeDefined()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// BE-N-05: schema.prisma が正しいフォーマット
|
|
140
|
+
test('BE-N-05: schema.prisma has correct format', () => {
|
|
141
|
+
const files = generateBackendFiles('example.com')
|
|
142
|
+
const content = files['backend/prisma/example.com/schema.prisma']
|
|
143
|
+
|
|
144
|
+
expect(content).toContain('generator client')
|
|
145
|
+
expect(content).toContain('datasource db')
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// ============================================================
|
|
150
|
+
// 3. 共通ファイル生成(正常系)
|
|
151
|
+
// ============================================================
|
|
152
|
+
describe('generateAllFiles - common files', () => {
|
|
153
|
+
// CM-N-01: .gitignore が正しい内容
|
|
154
|
+
test('CM-N-01: .gitignore includes node_modules, .build, .env, dist', () => {
|
|
155
|
+
const files = generateAllFiles('example.com', 'myapp')
|
|
156
|
+
|
|
157
|
+
expect(files['.gitignore']).toContain('node_modules')
|
|
158
|
+
expect(files['.gitignore']).toContain('.build')
|
|
159
|
+
expect(files['.gitignore']).toContain('.env')
|
|
160
|
+
expect(files['.gitignore']).toContain('dist')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// CM-N-02: README.md がドメイン名とアプリ名を含む
|
|
164
|
+
test('CM-N-02: README.md includes domain and app name', () => {
|
|
165
|
+
const files = generateAllFiles('example.com', 'myapp')
|
|
166
|
+
|
|
167
|
+
expect(files['README.md']).toContain('example.com')
|
|
168
|
+
expect(files['README.md']).toContain('myapp')
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// ============================================================
|
|
173
|
+
// 4. テンプレート生成(異常系・境界値)
|
|
174
|
+
// ============================================================
|
|
175
|
+
describe('template generation - edge cases', () => {
|
|
176
|
+
// TE-E-01: ドメインに特殊文字を含む場合
|
|
177
|
+
test('TE-E-01: domain with dots and hyphens generates correct paths', () => {
|
|
178
|
+
const files = generateFrontendFiles('my-site.example.com', 'myapp')
|
|
179
|
+
const paths = Object.keys(files)
|
|
180
|
+
|
|
181
|
+
expect(paths).toContain('frontend/my-site.example.com/src/apps/myapp/index.ts')
|
|
182
|
+
expect(paths).toContain('frontend/my-site.example.com/package.json')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
// TE-E-02: appName が空文字の場合エラー
|
|
186
|
+
test('TE-E-02: empty appName throws error', () => {
|
|
187
|
+
expect(() => generateFrontendFiles('example.com', '')).toThrow()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// TE-E-03: domain が空文字の場合エラー
|
|
191
|
+
test('TE-E-03: empty domain throws error', () => {
|
|
192
|
+
expect(() => generateFrontendFiles('', 'myapp')).toThrow()
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// ============================================================
|
|
197
|
+
// 5. 2つ目以降のアプリ追加(正常系)
|
|
198
|
+
// ============================================================
|
|
199
|
+
describe('generateAppFiles - additional app', () => {
|
|
200
|
+
// AD-N-01: 新しいアプリ用の index.ts のみ返る
|
|
201
|
+
test('AD-N-01: returns only new app index.ts', () => {
|
|
202
|
+
const files = generateAppFiles('example.com', 'secondapp')
|
|
203
|
+
const paths = Object.keys(files)
|
|
204
|
+
|
|
205
|
+
expect(paths).toContain('frontend/example.com/src/apps/secondapp/index.ts')
|
|
206
|
+
// 1ファイルのみ
|
|
207
|
+
expect(paths.length).toBe(1)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// AD-N-02: 既存の package.json 等のパスは含まれない
|
|
211
|
+
test('AD-N-02: does not include existing config files', () => {
|
|
212
|
+
const files = generateAppFiles('example.com', 'secondapp')
|
|
213
|
+
const paths = Object.keys(files)
|
|
214
|
+
|
|
215
|
+
expect(paths).not.toContain('frontend/example.com/package.json')
|
|
216
|
+
expect(paths).not.toContain('frontend/example.com/tsconfig.json')
|
|
217
|
+
expect(paths).not.toContain('backend/server.ts')
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
// ============================================================
|
|
222
|
+
// 6. ファイル書き込みロジック
|
|
223
|
+
// ============================================================
|
|
224
|
+
describe('writeFiles', () => {
|
|
225
|
+
// WR-N-01: 空のディレクトリにすべてのファイルが書き込まれる
|
|
226
|
+
test('WR-N-01: writes all files to empty directory', () => {
|
|
227
|
+
const files: Record<string, string> = {
|
|
228
|
+
'frontend/example.com/src/apps/myapp/index.ts': 'export default {}',
|
|
229
|
+
'backend/server.ts': 'console.log("hello")'
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const result = writeFiles(tmpDir, files, false)
|
|
233
|
+
|
|
234
|
+
expect(result.created).toContain('frontend/example.com/src/apps/myapp/index.ts')
|
|
235
|
+
expect(result.created).toContain('backend/server.ts')
|
|
236
|
+
expect(fs.existsSync(path.join(tmpDir, 'frontend/example.com/src/apps/myapp/index.ts'))).toBe(true)
|
|
237
|
+
expect(fs.existsSync(path.join(tmpDir, 'backend/server.ts'))).toBe(true)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
// WR-N-02: 既存ファイルがある場合、force=false ではスキップ
|
|
241
|
+
test('WR-N-02: skips existing files when force=false', () => {
|
|
242
|
+
// 事前にファイルを作成
|
|
243
|
+
const filePath = path.join(tmpDir, 'backend/server.ts')
|
|
244
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
245
|
+
fs.writeFileSync(filePath, 'original content')
|
|
246
|
+
|
|
247
|
+
const files: Record<string, string> = {
|
|
248
|
+
'backend/server.ts': 'new content'
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const result = writeFiles(tmpDir, files, false)
|
|
252
|
+
|
|
253
|
+
expect(result.skipped).toContain('backend/server.ts')
|
|
254
|
+
expect(fs.readFileSync(filePath, 'utf-8')).toBe('original content')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// WR-N-03: 既存ファイルがある場合、force=true では上書き
|
|
258
|
+
test('WR-N-03: overwrites existing files when force=true', () => {
|
|
259
|
+
const filePath = path.join(tmpDir, 'backend/server.ts')
|
|
260
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
261
|
+
fs.writeFileSync(filePath, 'original content')
|
|
262
|
+
|
|
263
|
+
const files: Record<string, string> = {
|
|
264
|
+
'backend/server.ts': 'new content'
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const result = writeFiles(tmpDir, files, true)
|
|
268
|
+
|
|
269
|
+
expect(result.created).toContain('backend/server.ts')
|
|
270
|
+
expect(fs.readFileSync(filePath, 'utf-8')).toBe('new content')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
// WR-N-04: ディレクトリが存在しない場合、再帰的に作成
|
|
274
|
+
test('WR-N-04: creates directories recursively', () => {
|
|
275
|
+
const files: Record<string, string> = {
|
|
276
|
+
'deep/nested/dir/file.ts': 'content'
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
writeFiles(tmpDir, files, false)
|
|
280
|
+
|
|
281
|
+
expect(fs.existsSync(path.join(tmpDir, 'deep/nested/dir/file.ts'))).toBe(true)
|
|
282
|
+
})
|
|
283
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import ora from 'ora'
|
|
4
|
+
import FormData from 'form-data'
|
|
5
|
+
import { loadConfig, requireLogin } from '../config.js'
|
|
6
|
+
import { apiPostStream } from '../utils/http.js'
|
|
7
|
+
import { chooseDomainAsync, chooseAppAsync } from '../utils/input.js'
|
|
8
|
+
|
|
9
|
+
export function setDeploy(program: Command) {
|
|
10
|
+
program
|
|
11
|
+
.command('deploy')
|
|
12
|
+
.description('アプリケーションをデプロイする')
|
|
13
|
+
.option('-f, --frontend', 'フロントエンドのみデプロイ')
|
|
14
|
+
.option('-b, --backend', 'バックエンドのみデプロイ')
|
|
15
|
+
.option('-s, --staging', 'ステージング環境にデプロイ')
|
|
16
|
+
.option('-d, --domain <name>', 'ドメイン名を直接指定')
|
|
17
|
+
.option('-a, --app <name>', 'アプリ名を直接指定')
|
|
18
|
+
.action(async (options) => {
|
|
19
|
+
try {
|
|
20
|
+
requireLogin()
|
|
21
|
+
const config = loadConfig()
|
|
22
|
+
|
|
23
|
+
// --- ドメイン選択 ---
|
|
24
|
+
let domainName = options.domain
|
|
25
|
+
if (!domainName) {
|
|
26
|
+
const domain = await chooseDomainAsync()
|
|
27
|
+
if (!domain) {
|
|
28
|
+
console.log(chalk.yellow('キャンセルしました'))
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
domainName = domain.name
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- アプリ選択 ---
|
|
35
|
+
let appName = options.app
|
|
36
|
+
if (!appName) {
|
|
37
|
+
const app = await chooseAppAsync(domainName)
|
|
38
|
+
if (!app) {
|
|
39
|
+
console.log(chalk.yellow('キャンセルしました'))
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
appName = app.name
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- デプロイ対象の決定 ---
|
|
46
|
+
const specifiedOperations: string[] = []
|
|
47
|
+
if (options.frontend) specifiedOperations.push('frontend')
|
|
48
|
+
if (options.backend) specifiedOperations.push('backend')
|
|
49
|
+
// --frontend も --backend も指定しなければ両方
|
|
50
|
+
const targets =
|
|
51
|
+
specifiedOperations.length > 0
|
|
52
|
+
? specifiedOperations.join(' + ')
|
|
53
|
+
: 'frontend + backend'
|
|
54
|
+
|
|
55
|
+
const isStaging: boolean = options.staging ?? false
|
|
56
|
+
const stage = isStaging ? 'staging' : 'release'
|
|
57
|
+
|
|
58
|
+
console.log()
|
|
59
|
+
console.log(chalk.cyan('デプロイ設定:'))
|
|
60
|
+
console.log(chalk.gray(` ドメイン : ${domainName}`))
|
|
61
|
+
console.log(chalk.gray(` アプリ : ${appName}`))
|
|
62
|
+
console.log(chalk.gray(` 環境 : ${stage}`))
|
|
63
|
+
console.log(chalk.gray(` 対象 : ${targets}`))
|
|
64
|
+
console.log()
|
|
65
|
+
|
|
66
|
+
// --- FormData 構築 ---
|
|
67
|
+
const formData = new FormData()
|
|
68
|
+
const data: Record<string, any> = {
|
|
69
|
+
domain: domainName,
|
|
70
|
+
appName,
|
|
71
|
+
email: config.user?.email ?? '',
|
|
72
|
+
id: config.user?.id ?? '',
|
|
73
|
+
user: config.user?.name ?? '',
|
|
74
|
+
isStaging
|
|
75
|
+
}
|
|
76
|
+
if (specifiedOperations.length > 0) {
|
|
77
|
+
data.specifiedOperations = specifiedOperations
|
|
78
|
+
}
|
|
79
|
+
formData.append('data', JSON.stringify(data))
|
|
80
|
+
|
|
81
|
+
// --- デプロイ実行(SSEストリーム受信) ---
|
|
82
|
+
const spinner = ora(`デプロイ中... (${targets})`).start()
|
|
83
|
+
try {
|
|
84
|
+
await apiPostStream('deploy', formData, (event) => {
|
|
85
|
+
if (event.type === 'complete') {
|
|
86
|
+
// 完了イベントは apiPostStream が正常終了して処理される
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
// 進捗メッセージでスピナーテキストを更新
|
|
90
|
+
if (event.message) {
|
|
91
|
+
const prefix = event.step === 'frontend' ? '[Frontend] '
|
|
92
|
+
: event.step === 'backend' ? '[Backend] '
|
|
93
|
+
: ''
|
|
94
|
+
spinner.text = `${prefix}${event.message}`
|
|
95
|
+
}
|
|
96
|
+
}, { timeout: 600_000 })
|
|
97
|
+
spinner.succeed(chalk.green('デプロイが完了しました'))
|
|
98
|
+
} catch (error: any) {
|
|
99
|
+
spinner.fail(chalk.red('デプロイに失敗しました'))
|
|
100
|
+
const msg = error.message ?? 'Unknown error'
|
|
101
|
+
console.error(chalk.red(` ${msg}`))
|
|
102
|
+
process.exit(1)
|
|
103
|
+
}
|
|
104
|
+
} catch (error: any) {
|
|
105
|
+
console.error(chalk.red(`✗ ${error.message}`))
|
|
106
|
+
process.exit(1)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import prompts from 'prompts'
|
|
4
|
+
import { execSync } from 'child_process'
|
|
5
|
+
import { requireLogin } from '../config.js'
|
|
6
|
+
import { generateAllFiles, generateAppFiles, writeFiles } from '../templates/index.js'
|
|
7
|
+
|
|
8
|
+
export function setInit(program: Command) {
|
|
9
|
+
program
|
|
10
|
+
.command('init')
|
|
11
|
+
.description('リポジトリを Oyster アプリ構成で初期化する')
|
|
12
|
+
.option('-d, --domain <name>', 'ドメイン名を指定')
|
|
13
|
+
.option('-a, --app <name>', 'アプリ名を指定')
|
|
14
|
+
.option('--force', '既存ファイルを上書きする', false)
|
|
15
|
+
.option('--add-app', '既存リポジトリに新しいアプリを追加する', false)
|
|
16
|
+
.action(async (options) => {
|
|
17
|
+
try {
|
|
18
|
+
// ログイン確認
|
|
19
|
+
requireLogin()
|
|
20
|
+
|
|
21
|
+
// git リポジトリかチェック
|
|
22
|
+
try {
|
|
23
|
+
execSync('git rev-parse --is-inside-work-tree', {
|
|
24
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
25
|
+
})
|
|
26
|
+
} catch {
|
|
27
|
+
console.error(
|
|
28
|
+
chalk.red('✗ カレントディレクトリは git リポジトリではありません。')
|
|
29
|
+
)
|
|
30
|
+
console.error(
|
|
31
|
+
chalk.gray(' 先に `git init` を実行してください。')
|
|
32
|
+
)
|
|
33
|
+
process.exit(1)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ドメイン名の取得
|
|
37
|
+
let domainName = options.domain
|
|
38
|
+
if (!domainName) {
|
|
39
|
+
const { value } = await prompts({
|
|
40
|
+
type: 'text',
|
|
41
|
+
name: 'value',
|
|
42
|
+
message: 'ドメイン名:',
|
|
43
|
+
validate: (v: string) =>
|
|
44
|
+
v.length > 0 ? true : 'ドメイン名は必須です'
|
|
45
|
+
})
|
|
46
|
+
if (!value) {
|
|
47
|
+
console.log(chalk.yellow('キャンセルしました'))
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
domainName = value
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// アプリ名の取得
|
|
54
|
+
let appName = options.app
|
|
55
|
+
if (!appName) {
|
|
56
|
+
const { value } = await prompts({
|
|
57
|
+
type: 'text',
|
|
58
|
+
name: 'value',
|
|
59
|
+
message: 'アプリ名:',
|
|
60
|
+
validate: (v: string) =>
|
|
61
|
+
v.length > 0 ? true : 'アプリ名は必須です'
|
|
62
|
+
})
|
|
63
|
+
if (!value) {
|
|
64
|
+
console.log(chalk.yellow('キャンセルしました'))
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
appName = value
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ファイル生成
|
|
71
|
+
const isAddApp: boolean = options.addApp ?? false
|
|
72
|
+
const files = isAddApp
|
|
73
|
+
? generateAppFiles(domainName, appName)
|
|
74
|
+
: generateAllFiles(domainName, appName)
|
|
75
|
+
|
|
76
|
+
const cwd = process.cwd()
|
|
77
|
+
const force: boolean = options.force ?? false
|
|
78
|
+
|
|
79
|
+
console.log()
|
|
80
|
+
console.log(chalk.cyan('初期化設定:'))
|
|
81
|
+
console.log(chalk.gray(` ドメイン : ${domainName}`))
|
|
82
|
+
console.log(chalk.gray(` アプリ : ${appName}`))
|
|
83
|
+
console.log(
|
|
84
|
+
chalk.gray(` モード : ${isAddApp ? 'アプリ追加' : '初回初期化'}`)
|
|
85
|
+
)
|
|
86
|
+
console.log(chalk.gray(` 上書き : ${force ? 'はい' : 'いいえ'}`))
|
|
87
|
+
console.log()
|
|
88
|
+
|
|
89
|
+
// ファイル書き込み
|
|
90
|
+
const result = writeFiles(cwd, files, force)
|
|
91
|
+
|
|
92
|
+
// 結果表示
|
|
93
|
+
if (result.created.length > 0) {
|
|
94
|
+
console.log(chalk.green('作成されたファイル:'))
|
|
95
|
+
for (const f of result.created) {
|
|
96
|
+
console.log(chalk.green(` + ${f}`))
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (result.skipped.length > 0) {
|
|
101
|
+
console.log()
|
|
102
|
+
console.log(chalk.yellow('スキップされたファイル(既存):'))
|
|
103
|
+
for (const f of result.skipped) {
|
|
104
|
+
console.log(chalk.yellow(` ~ ${f}`))
|
|
105
|
+
}
|
|
106
|
+
console.log(
|
|
107
|
+
chalk.gray(
|
|
108
|
+
' 上書きするには --force オプションを使用してください'
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// git add
|
|
114
|
+
if (result.created.length > 0) {
|
|
115
|
+
console.log()
|
|
116
|
+
try {
|
|
117
|
+
// 各ファイルを個別に git add(shell injection 防御)
|
|
118
|
+
for (const f of result.created) {
|
|
119
|
+
execSync('git add -- ' + JSON.stringify(f), {
|
|
120
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
console.log(
|
|
124
|
+
chalk.green(
|
|
125
|
+
`✓ ${result.created.length} ファイルを git に追加しました`
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
} catch {
|
|
129
|
+
console.log(
|
|
130
|
+
chalk.yellow(
|
|
131
|
+
'⚠ git add に失敗しました。手動で追加してください。'
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
console.log()
|
|
136
|
+
console.log(chalk.cyan('次のステップ:'))
|
|
137
|
+
console.log(
|
|
138
|
+
chalk.gray(
|
|
139
|
+
` git commit -m "Initialize oyster app: ${appName}"`
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
console.log(chalk.gray(' git push'))
|
|
143
|
+
console.log(
|
|
144
|
+
chalk.gray(
|
|
145
|
+
` oyster deploy -d ${domainName} -a ${appName}`
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
} catch (error: any) {
|
|
150
|
+
console.error(chalk.red(`✗ ${error.message}`))
|
|
151
|
+
process.exit(1)
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import prompts from 'prompts'
|
|
4
|
+
import axios from 'axios'
|
|
5
|
+
import { getServerUrl, loadConfig, saveConfig } from '../config.js'
|
|
6
|
+
|
|
7
|
+
export function setLogin(program: Command) {
|
|
8
|
+
program
|
|
9
|
+
.command('login')
|
|
10
|
+
.description('Oyster にログインして API キーを取得する')
|
|
11
|
+
.option('-s, --server <url>', 'サーバー URL を指定')
|
|
12
|
+
.action(async (options) => {
|
|
13
|
+
try {
|
|
14
|
+
// サーバー URL の設定(オプションで指定された場合は保存)
|
|
15
|
+
if (options.server) {
|
|
16
|
+
const config = loadConfig()
|
|
17
|
+
config.server_url = options.server
|
|
18
|
+
saveConfig(config)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const serverUrl = options.server ?? getServerUrl()
|
|
22
|
+
console.log(chalk.cyan(`サーバー: ${serverUrl}`))
|
|
23
|
+
console.log()
|
|
24
|
+
|
|
25
|
+
// メールアドレス入力
|
|
26
|
+
const { email } = await prompts({
|
|
27
|
+
type: 'text',
|
|
28
|
+
name: 'email',
|
|
29
|
+
message: 'メールアドレス:'
|
|
30
|
+
})
|
|
31
|
+
if (!email) {
|
|
32
|
+
console.log(chalk.yellow('キャンセルしました'))
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// パスワード入力
|
|
37
|
+
const { password } = await prompts({
|
|
38
|
+
type: 'password',
|
|
39
|
+
name: 'password',
|
|
40
|
+
message: 'パスワード:'
|
|
41
|
+
})
|
|
42
|
+
if (!password) {
|
|
43
|
+
console.log(chalk.yellow('キャンセルしました'))
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ログインリクエスト
|
|
48
|
+
const loginUrl = `${serverUrl}/api/v1/cli/login`
|
|
49
|
+
const response = await axios.post(loginUrl, { email, password })
|
|
50
|
+
const { api_key, user } = response.data
|
|
51
|
+
|
|
52
|
+
// 設定ファイルに保存
|
|
53
|
+
const config = loadConfig()
|
|
54
|
+
config.api_key = api_key
|
|
55
|
+
config.server_url = serverUrl
|
|
56
|
+
config.user = user
|
|
57
|
+
saveConfig(config)
|
|
58
|
+
|
|
59
|
+
console.log()
|
|
60
|
+
console.log(chalk.green('✓ ログインに成功しました'))
|
|
61
|
+
console.log(chalk.gray(` ユーザー: ${user.name} (${user.email})`))
|
|
62
|
+
console.log(chalk.gray(` 設定は ~/.config/oyster/config.json に保存されました`))
|
|
63
|
+
} catch (error: any) {
|
|
64
|
+
if (axios.isAxiosError(error) && error.response) {
|
|
65
|
+
const status = error.response.status
|
|
66
|
+
const message = error.response.data?.message ?? 'Unknown error'
|
|
67
|
+
if (status === 401) {
|
|
68
|
+
console.error(chalk.red('✗ メールアドレスまたはパスワードが正しくありません'))
|
|
69
|
+
} else {
|
|
70
|
+
console.error(chalk.red(`✗ ログイン失敗: ${message} (HTTP ${status})`))
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
console.error(chalk.red(`✗ ログイン失敗: ${error.message}`))
|
|
74
|
+
}
|
|
75
|
+
process.exit(1)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
}
|