@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.
@@ -0,0 +1,81 @@
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 { apiPostFormData } from '../utils/http.js'
7
+ import { chooseDomainAsync, chooseAppAsync } from '../utils/input.js'
8
+
9
+ export function setMigrate(program: Command) {
10
+ program
11
+ .command('migrate')
12
+ .description('データベースマイグレーションを実行する (prisma migrate deploy)')
13
+ .option('-s, --staging', 'ステージング環境に対して実行')
14
+ .option('-d, --domain <name>', 'ドメイン名を直接指定')
15
+ .option('-a, --app <name>', 'アプリ名を直接指定')
16
+ .action(async (options) => {
17
+ try {
18
+ requireLogin()
19
+ const config = loadConfig()
20
+
21
+ // --- ドメイン選択 ---
22
+ let domainName = options.domain
23
+ if (!domainName) {
24
+ const domain = await chooseDomainAsync()
25
+ if (!domain) {
26
+ console.log(chalk.yellow('キャンセルしました'))
27
+ return
28
+ }
29
+ domainName = domain.name
30
+ }
31
+
32
+ // --- アプリ選択 ---
33
+ let appName = options.app
34
+ if (!appName) {
35
+ const app = await chooseAppAsync(domainName)
36
+ if (!app) {
37
+ console.log(chalk.yellow('キャンセルしました'))
38
+ return
39
+ }
40
+ appName = app.name
41
+ }
42
+
43
+ const isStaging: boolean = options.staging ?? false
44
+ const stage = isStaging ? 'staging' : 'release'
45
+
46
+ console.log()
47
+ console.log(chalk.cyan('マイグレーション設定:'))
48
+ console.log(chalk.gray(` ドメイン : ${domainName}`))
49
+ console.log(chalk.gray(` アプリ : ${appName}`))
50
+ console.log(chalk.gray(` 環境 : ${stage}`))
51
+ console.log()
52
+
53
+ // --- FormData 構築 ---
54
+ const formData = new FormData()
55
+ const data = {
56
+ domain: domainName,
57
+ appName,
58
+ email: config.user?.email ?? '',
59
+ id: config.user?.id ?? '',
60
+ user: config.user?.name ?? '',
61
+ isStaging
62
+ }
63
+ formData.append('data', JSON.stringify(data))
64
+
65
+ // --- マイグレーション実行 ---
66
+ const spinner = ora('マイグレーション実行中...').start()
67
+ try {
68
+ await apiPostFormData('migrate', formData, { timeout: 300_000 })
69
+ spinner.succeed(chalk.green('マイグレーションが完了しました'))
70
+ } catch (error: any) {
71
+ spinner.fail(chalk.red('マイグレーションに失敗しました'))
72
+ const msg = error.response?.data?.message ?? error.message
73
+ console.error(chalk.red(` ${msg}`))
74
+ process.exit(1)
75
+ }
76
+ } catch (error: any) {
77
+ console.error(chalk.red(`✗ ${error.message}`))
78
+ process.exit(1)
79
+ }
80
+ })
81
+ }
package/src/config.ts ADDED
@@ -0,0 +1,86 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import os from 'os'
4
+
5
+ export interface OysterConfig {
6
+ api_key?: string
7
+ server_url?: string
8
+ user?: {
9
+ id: string
10
+ email: string
11
+ name: string
12
+ }
13
+ }
14
+
15
+ function getConfigDir(): string {
16
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config')
17
+ return path.join(xdgConfig, 'oyster')
18
+ }
19
+
20
+ const CONFIG_DIR = getConfigDir()
21
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
22
+
23
+ /**
24
+ * 設定を読み込む
25
+ */
26
+ export function loadConfig(): OysterConfig {
27
+ try {
28
+ if (!fs.existsSync(CONFIG_FILE)) {
29
+ return {}
30
+ }
31
+ const content = fs.readFileSync(CONFIG_FILE, 'utf-8')
32
+ return JSON.parse(content) as OysterConfig
33
+ } catch {
34
+ return {}
35
+ }
36
+ }
37
+
38
+ /**
39
+ * 設定を保存する(ファイル権限 600)
40
+ */
41
+ export function saveConfig(config: OysterConfig): void {
42
+ if (!fs.existsSync(CONFIG_DIR)) {
43
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })
44
+ }
45
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 })
46
+ }
47
+
48
+ /**
49
+ * API Key を取得する(環境変数 > 設定ファイル)
50
+ */
51
+ export function getApiKey(): string | null {
52
+ if (process.env.OYSTER_API_KEY) {
53
+ return process.env.OYSTER_API_KEY
54
+ }
55
+ const config = loadConfig()
56
+ return config.api_key ?? null
57
+ }
58
+
59
+ /**
60
+ * サーバー URL を取得する(環境変数 > 設定ファイル > デフォルト)
61
+ */
62
+ export function getServerUrl(): string {
63
+ if (process.env.OYSTER_SERVER_URL) {
64
+ return process.env.OYSTER_SERVER_URL
65
+ }
66
+ const config = loadConfig()
67
+ return config.server_url ?? 'https://oyster.craifapps.com'
68
+ }
69
+
70
+ /**
71
+ * CLI API のベース URL
72
+ */
73
+ export function getApiBaseUrl(): string {
74
+ return `${getServerUrl()}/api/v1/cli`
75
+ }
76
+
77
+ /**
78
+ * ログイン済みか確認
79
+ */
80
+ export function requireLogin(): string {
81
+ const apiKey = getApiKey()
82
+ if (!apiKey) {
83
+ throw new Error('ログインしていません。先に `oyster login` を実行してください。')
84
+ }
85
+ return apiKey
86
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander'
3
+ import { setLogin } from './commands/login.js'
4
+ import { setInit } from './commands/init.js'
5
+ import { setDeploy } from './commands/deploy.js'
6
+ import { setMigrate } from './commands/migrate.js'
7
+
8
+ program
9
+ .name('oyster')
10
+ .version('2.0.0')
11
+ .description('Oyster CLI v2 - deploy and manage your Oyster applications')
12
+
13
+ setLogin(program)
14
+ setInit(program)
15
+ setDeploy(program)
16
+ setMigrate(program)
17
+
18
+ program.parse(process.argv)
19
+
20
+ if (!process.argv.slice(2).length) {
21
+ program.outputHelp()
22
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * バックエンドテンプレートファイルを生成する
3
+ *
4
+ * Oyster プラットフォームが期待する以下の構造を生成:
5
+ * backend/
6
+ * ├── server.ts
7
+ * ├── package.json
8
+ * ├── tsconfig.json
9
+ * └── prisma/{domain}/schema.prisma
10
+ */
11
+
12
+ /**
13
+ * バックエンドのファイルマップを生成する
14
+ * @param domain ドメイン名(例: example.com)
15
+ * @returns ファイルパス -> ファイル内容 のマップ
16
+ */
17
+ export function generateBackendFiles(domain: string): Record<string, string> {
18
+ if (!domain) throw new Error('domain は必須です')
19
+
20
+ return {
21
+ 'backend/server.ts': generateServerTs(),
22
+ 'backend/package.json': generateBackendPackageJson(),
23
+ 'backend/tsconfig.json': generateBackendTsconfig(),
24
+ [`backend/prisma/${domain}/schema.prisma`]: generatePrismaSchema(domain)
25
+ }
26
+ }
27
+
28
+ function generateServerTs(): string {
29
+ return `import express from 'express'
30
+
31
+ const app = express()
32
+ const port = process.env.PORT ? parseInt(process.env.PORT) : 1616
33
+
34
+ app.use(express.json())
35
+
36
+ app.get('/health', (_req, res) => {
37
+ res.json({ status: 'ok' })
38
+ })
39
+
40
+ app.listen(port, () => {
41
+ console.log(\`Action Server listening on port \${port}\`)
42
+ })
43
+ `
44
+ }
45
+
46
+ function generateBackendPackageJson(): string {
47
+ const pkg = {
48
+ name: '@oyster-apps/backend',
49
+ version: '1.0.0',
50
+ private: true,
51
+ scripts: {
52
+ start: 'TS_NODE_TRANSPILE_ONLY=true node --max-old-space-size=2048 -r ts-node/register -r tsconfig-paths/register server.ts'
53
+ },
54
+ dependencies: {
55
+ 'express': '^4.21.0',
56
+ '@prisma/client': '^6.0.0'
57
+ },
58
+ devDependencies: {
59
+ 'typescript': '^5.8.0',
60
+ 'ts-node': '^10.9.0',
61
+ 'tsconfig-paths': '^4.2.0',
62
+ '@types/express': '^5.0.0',
63
+ '@types/node': '^22.0.0',
64
+ 'prisma': '^6.0.0'
65
+ }
66
+ }
67
+ return JSON.stringify(pkg, null, 2) + '\n'
68
+ }
69
+
70
+ function generateBackendTsconfig(): string {
71
+ const tsconfig = {
72
+ compilerOptions: {
73
+ target: 'ES2022',
74
+ module: 'CommonJS',
75
+ moduleResolution: 'node',
76
+ esModuleInterop: true,
77
+ strict: true,
78
+ skipLibCheck: true,
79
+ forceConsistentCasingInFileNames: true,
80
+ resolveJsonModule: true,
81
+ outDir: './dist',
82
+ rootDir: '.',
83
+ declaration: true,
84
+ baseUrl: '.',
85
+ paths: {
86
+ 'prisma/*': ['./prisma/*']
87
+ }
88
+ },
89
+ include: ['**/*.ts'],
90
+ exclude: ['node_modules', 'dist']
91
+ }
92
+ return JSON.stringify(tsconfig, null, 2) + '\n'
93
+ }
94
+
95
+ function generatePrismaSchema(domain: string): string {
96
+ return `// Prisma Schema for ${domain}
97
+ // https://www.prisma.io/docs/prisma-schema
98
+
99
+ generator client {
100
+ provider = "prisma-client-js"
101
+ output = "../../node_modules/.prisma/${domain}"
102
+ }
103
+
104
+ datasource db {
105
+ provider = "postgresql"
106
+ url = env("DATABASE_URL")
107
+ }
108
+
109
+ // Add your models here
110
+ // model Example {
111
+ // id Int @id @default(autoincrement())
112
+ // name String
113
+ // createdAt DateTime @default(now()) @map("created_at")
114
+ // updatedAt DateTime @updatedAt @map("updated_at")
115
+ //
116
+ // @@map("examples")
117
+ // }
118
+ `
119
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * フロントエンドテンプレートファイルを生成する
3
+ *
4
+ * Oyster プラットフォームが期待する以下の構造を生成:
5
+ * frontend/{domain}/
6
+ * ├── src/
7
+ * │ ├── apps/{appName}/index.ts
8
+ * │ ├── shared/index.ts
9
+ * │ └── index.css
10
+ * ├── package.json
11
+ * ├── tsconfig.json
12
+ * └── tailwind.config.js
13
+ */
14
+
15
+ /**
16
+ * フロントエンドのファイルマップを生成する
17
+ * @param domain ドメイン名(例: example.com)
18
+ * @param appName アプリ名(例: myapp)
19
+ * @returns ファイルパス -> ファイル内容 のマップ
20
+ */
21
+ export function generateFrontendFiles(
22
+ domain: string,
23
+ appName: string
24
+ ): Record<string, string> {
25
+ if (!domain) throw new Error('domain は必須です')
26
+ if (!appName) throw new Error('appName は必須です')
27
+
28
+ const base = `frontend/${domain}`
29
+
30
+ return {
31
+ [`${base}/src/apps/${appName}/index.ts`]: generateAppEntryPoint(appName),
32
+ [`${base}/src/shared/index.ts`]: generateSharedIndex(),
33
+ [`${base}/src/index.css`]: generateIndexCss(),
34
+ [`${base}/package.json`]: generateFrontendPackageJson(domain),
35
+ [`${base}/tsconfig.json`]: generateFrontendTsconfig(),
36
+ [`${base}/tailwind.config.js`]: generateTailwindConfig()
37
+ }
38
+ }
39
+
40
+ /**
41
+ * アプリのエントリーポイントを生成する(外部からも使用可能)
42
+ */
43
+ export function generateAppEntryPoint(appName: string): string {
44
+ const componentName = appName.charAt(0).toUpperCase() + appName.slice(1)
45
+ return `import React from 'react'
46
+
47
+ /**
48
+ * ${componentName} - フロントエンドエントリーポイント
49
+ */
50
+ export default function ${componentName}() {
51
+ return (
52
+ <div className="p-4">
53
+ <h1 className="text-2xl font-bold">${componentName}</h1>
54
+ <p>Welcome to ${appName}</p>
55
+ </div>
56
+ )
57
+ }
58
+ `
59
+ }
60
+
61
+ function generateSharedIndex(): string {
62
+ return `/**
63
+ * 共有モジュール
64
+ * アプリ間で共有するユーティリティや型をここからエクスポートする
65
+ */
66
+
67
+ export {}
68
+ `
69
+ }
70
+
71
+ function generateIndexCss(): string {
72
+ return `@import 'tailwindcss';
73
+
74
+ /* カスタムスタイルをここに追加 */
75
+ `
76
+ }
77
+
78
+ function generateFrontendPackageJson(domain: string): string {
79
+ const pkg = {
80
+ name: `@oyster-apps/${domain}`,
81
+ version: '1.0.0',
82
+ private: true,
83
+ dependencies: {
84
+ 'react': '^19.0.0',
85
+ 'react-dom': '^19.0.0',
86
+ '@oyster-lib/core': '*'
87
+ },
88
+ devDependencies: {
89
+ 'typescript': '^5.8.0',
90
+ '@types/react': '^19.0.0',
91
+ '@types/react-dom': '^19.0.0',
92
+ 'esbuild': '^0.24.0',
93
+ 'tailwindcss': '^4.0.0'
94
+ }
95
+ }
96
+ return JSON.stringify(pkg, null, 2) + '\n'
97
+ }
98
+
99
+ function generateFrontendTsconfig(): string {
100
+ const tsconfig = {
101
+ compilerOptions: {
102
+ target: 'ES2022',
103
+ module: 'ESNext',
104
+ moduleResolution: 'bundler',
105
+ jsx: 'react-jsx',
106
+ esModuleInterop: true,
107
+ strict: true,
108
+ skipLibCheck: true,
109
+ forceConsistentCasingInFileNames: true,
110
+ resolveJsonModule: true,
111
+ declaration: true,
112
+ outDir: './.build',
113
+ baseUrl: '.',
114
+ paths: {
115
+ 'components/*': ['./src/apps/*/index.ts'],
116
+ 'components': ['./src/apps/*/index.ts'],
117
+ 'shared/*': ['./src/shared/index.ts'],
118
+ 'shared': ['./src/shared/index.ts']
119
+ }
120
+ },
121
+ include: ['src/**/*'],
122
+ exclude: ['node_modules', '.build']
123
+ }
124
+ return JSON.stringify(tsconfig, null, 2) + '\n'
125
+ }
126
+
127
+ function generateTailwindConfig(): string {
128
+ return `/** @type {import('tailwindcss').Config} */
129
+ module.exports = {
130
+ content: [
131
+ './src/**/*.{js,ts,jsx,tsx}'
132
+ ],
133
+ theme: {
134
+ extend: {}
135
+ },
136
+ plugins: []
137
+ }
138
+ `
139
+ }
@@ -0,0 +1,149 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { generateFrontendFiles, generateAppEntryPoint } from './frontend.js'
4
+ import { generateBackendFiles } from './backend.js'
5
+
6
+ /**
7
+ * 初回初期化: 全ファイルを生成する(frontend + backend + 共通ファイル)
8
+ */
9
+ export function generateAllFiles(
10
+ domain: string,
11
+ appName: string
12
+ ): Record<string, string> {
13
+ if (!domain) throw new Error('domain は必須です')
14
+ if (!appName) throw new Error('appName は必須です')
15
+
16
+ return {
17
+ ...generateFrontendFiles(domain, appName),
18
+ ...generateBackendFiles(domain),
19
+ ...generateCommonFiles(domain, appName)
20
+ }
21
+ }
22
+
23
+ /**
24
+ * 追加アプリ: 新しいアプリのエントリーポイントのみ生成する
25
+ * 既存の package.json, tsconfig.json, backend 等は含まない
26
+ */
27
+ export function generateAppFiles(
28
+ domain: string,
29
+ appName: string
30
+ ): Record<string, string> {
31
+ if (!domain) throw new Error('domain は必須です')
32
+ if (!appName) throw new Error('appName は必須です')
33
+
34
+ return {
35
+ [`frontend/${domain}/src/apps/${appName}/index.ts`]: generateAppEntryPoint(appName)
36
+ }
37
+ }
38
+
39
+ /**
40
+ * 共通ファイル(.gitignore, README.md)を生成する
41
+ */
42
+ function generateCommonFiles(
43
+ domain: string,
44
+ appName: string
45
+ ): Record<string, string> {
46
+ return {
47
+ '.gitignore': generateGitignore(),
48
+ 'README.md': generateReadme(domain, appName)
49
+ }
50
+ }
51
+
52
+ function generateGitignore(): string {
53
+ return `# Dependencies
54
+ node_modules/
55
+
56
+ # Build output
57
+ .build/
58
+ dist/
59
+
60
+ # Environment
61
+ .env
62
+ .env.local
63
+ .env.*.local
64
+
65
+ # IDE
66
+ .vscode/
67
+ .idea/
68
+
69
+ # OS
70
+ .DS_Store
71
+ Thumbs.db
72
+
73
+ # Logs
74
+ *.log
75
+ npm-debug.log*
76
+ yarn-debug.log*
77
+ pnpm-debug.log*
78
+ `
79
+ }
80
+
81
+ function generateReadme(domain: string, appName: string): string {
82
+ return `# Oyster Apps - ${domain}
83
+
84
+ This repository contains the frontend and backend code for the **${domain}** domain on the Oyster platform.
85
+
86
+ ## Structure
87
+
88
+ \`\`\`
89
+ frontend/${domain}/ # Frontend applications
90
+ src/apps/${appName}/ # ${appName} app entry point
91
+ src/shared/ # Shared modules
92
+ backend/ # Backend (Action Server)
93
+ prisma/${domain}/ # Prisma schema
94
+ \`\`\`
95
+
96
+ ## Apps
97
+
98
+ - **${appName}** - \`frontend/${domain}/src/apps/${appName}/index.ts\`
99
+
100
+ ## Deploy
101
+
102
+ \`\`\`bash
103
+ oyster deploy -d ${domain} -a ${appName}
104
+ \`\`\`
105
+
106
+ ## Migrate
107
+
108
+ \`\`\`bash
109
+ oyster migrate -d ${domain} -a ${appName}
110
+ \`\`\`
111
+ `
112
+ }
113
+
114
+ /**
115
+ * ファイルマップをディスクに書き込む
116
+ * @param rootDir 書き込み先のルートディレクトリ
117
+ * @param files ファイルパス -> 内容のマップ
118
+ * @param force true の場合は既存ファイルを上書き
119
+ * @returns 作成されたファイルとスキップされたファイルのリスト
120
+ */
121
+ export function writeFiles(
122
+ rootDir: string,
123
+ files: Record<string, string>,
124
+ force: boolean
125
+ ): { created: string[]; skipped: string[] } {
126
+ const created: string[] = []
127
+ const skipped: string[] = []
128
+
129
+ for (const [relativePath, content] of Object.entries(files)) {
130
+ const fullPath = path.join(rootDir, relativePath)
131
+ const dir = path.dirname(fullPath)
132
+
133
+ // ディレクトリを再帰的に作成
134
+ if (!fs.existsSync(dir)) {
135
+ fs.mkdirSync(dir, { recursive: true })
136
+ }
137
+
138
+ // 既存ファイルのチェック
139
+ if (fs.existsSync(fullPath) && !force) {
140
+ skipped.push(relativePath)
141
+ continue
142
+ }
143
+
144
+ fs.writeFileSync(fullPath, content, 'utf-8')
145
+ created.push(relativePath)
146
+ }
147
+
148
+ return { created, skipped }
149
+ }