first commit
CI / typecheck (push) Successful in 1m8s
CI / format (push) Failing after 1m6s
CI / lint (push) Failing after 49s
CI / test (push) Failing after 1m8s

This commit is contained in:
SDI
2026-07-15 18:05:12 +09:00
commit 12e4f17b62
4633 changed files with 817125 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
config.toml
+49
View File
@@ -0,0 +1,49 @@
# ABC User Feedback CLI
The ABC User Feedback CLI helps you easily run web frontends and servers.
[ABC User Feedback](https://github.com/line/abc-user-feedback) is a standalone web application that manages Voice of Customer (VoC) data, allowing you to gather and organize feedback from your customers.
## Prerequisites
- [Node.js v22 or above](https://nodejs.org/en/download/)
- [Docker](https://docs.docker.com/desktop/)
## Running without dependency
You can run this cli with [npx](https://docs.npmjs.com/cli/v8/commands/npx), so there's no need to install additional dependencies or clone the repository. Regardless of your operating system or environment, you can run servers with just the prerequisites mentioned above.
There are `npx` commands for setting up the infrastructure, starting servers, and stopping servers.
## Initialization
The following command sets up the infrastructure(MySQL, SMTP, OpenSearch) based on your architecture(ARM/AMD).
It also creates a `config.toml` file where you can configure environment variables to start the servers.
```sh
npx auf-cli init
```
## Start Servers
Based on `config.toml` file created during the initialization phase, this command generates a Docker Compose file. Using this Docker Compose file, the following command starts the API and web servers.
```sh
npx auf-cli start
```
## Stop Servers
The following command stops the API and web servers.
```sh
npx auf-cli stop
```
## Clean Mounted Volumes
The following command clean the mounted docker volumes created during initialization.
```sh
npx auf-cli clean
```
+204
View File
@@ -0,0 +1,204 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import YAML from 'yaml';
import type { AppConfig } from './config';
export function generateComposeContent(cfg: AppConfig) {
const doc = {
name: 'abc-user-feedback',
services: {
web: {
image: 'line/abc-user-feedback-web:latest',
ports: [`${cfg.web.port}:3000`],
depends_on: { api: { condition: 'service_healthy' } },
restart: 'unless-stopped',
environment: [
`NEXT_PUBLIC_API_BASE_URL=http://localhost:${cfg.api.port}`,
],
},
api: {
image: 'line/abc-user-feedback-api:latest',
environment: [
`JWT_SECRET=${cfg.api.jwt_secret}`,
'MYSQL_PRIMARY_URL=mysql://userfeedback:userfeedback@mysql:3306/userfeedback',
`SMTP_HOST=${cfg.api.smtp.host}`,
`SMTP_PORT=${cfg.api.smtp.port}`,
`SMTP_SENDER=${cfg.api.smtp.sender}`,
],
ports: [`${cfg.api.port}:4000`],
depends_on: { mysql: { condition: 'service_healthy' } },
restart: 'unless-stopped',
healthcheck: {
test: [
'CMD-SHELL',
"node -e \"require('http').get('http://localhost:4000/api/health', res => process.exit(res.statusCode === 200 ? 0 : 1))\"",
],
interval: '10s',
timeout: '5s',
retries: '5',
},
},
smtp4dev: {
image: 'rnwood/smtp4dev:v3',
ports: ['5080:80', '25:25', '143:143'],
volumes: ['smtp4dev:/smtp4dev'],
restart: 'unless-stopped',
},
mysql: {
image: 'mysql:8.0',
command: [
'--default-authentication-plugin=mysql_native_password',
'--collation-server=utf8mb4_bin',
],
environment: {
MYSQL_ROOT_PASSWORD: 'userfeedback',
MYSQL_DATABASE: 'userfeedback',
MYSQL_USER: 'userfeedback',
MYSQL_PASSWORD: 'userfeedback',
TZ: 'UTC',
},
ports: [`${cfg.mysql?.port}:3306`],
volumes: ['mysql:/var/lib/mysql'],
restart: 'unless-stopped',
healthcheck: {
test: [
'CMD',
'mysqladmin',
'ping',
'-h',
'localhost',
'-uuserfeedback',
'-puserfeedback',
],
interval: '10s',
timeout: '5s',
retries: '5',
},
},
},
volumes: { mysql: {}, smtp4dev: {} } as Record<string, object>,
};
const apiEnvVariables = {
MASTER_API_KEY: cfg.api.master_api_key,
ACCESS_TOKEN_EXPIRED_TIME: cfg.api.access_token_expired_time,
REFRESH_TOKEN_EXPIRED_TIME: cfg.api.refresh_token_expired_time,
SMTP_USERNAME: cfg.api.smtp.username,
SMTP_PASSWORD: cfg.api.smtp.password,
SMTP_TLS: cfg.api.smtp.tls,
SMTP_CIPHER_SPEC: cfg.api.smtp.cipher_spec,
SMTP_OPPORTUNISTIC_TLS: cfg.api.smtp.opportunistic_tls,
AUTO_FEEDBACK_DELETION_ENABLED: cfg.api.auto_feedback_deletion?.enabled,
AUTO_FEEDBACK_DELETION_PERIOD_DAYS:
cfg.api.auto_feedback_deletion?.period_days,
OPENSEARCH_USE: cfg.api.opensearch?.enabled,
};
for (const [key, value] of Object.entries(apiEnvVariables)) {
if (value !== undefined) {
doc.services.api.environment.push(`${key}=${value}`);
}
if (key === 'OPENSEARCH_USE' && value === true) {
doc.services.api.environment.push(
`OPENSEARCH_NODE=http://opensearch-node:9200`,
);
}
}
if (cfg.mysql) {
doc.services.mysql = {
image: 'mysql:8.0',
command: [
'--default-authentication-plugin=mysql_native_password',
'--collation-server=utf8mb4_bin',
],
environment: {
MYSQL_ROOT_PASSWORD: 'userfeedback',
MYSQL_DATABASE: 'userfeedback',
MYSQL_USER: 'userfeedback',
MYSQL_PASSWORD: 'userfeedback',
TZ: 'UTC',
},
ports: [`${cfg.mysql.port}:3306`],
volumes: ['mysql:/var/lib/mysql'],
restart: 'unless-stopped',
healthcheck: {
test: [
'CMD',
'mysqladmin',
'ping',
'-h',
'localhost',
'-uuserfeedback',
'-puserfeedback',
],
interval: '10s',
timeout: '5s',
retries: '5',
},
};
}
if (cfg.api.opensearch) {
doc.services['opensearch-node'] = {
image: 'opensearchproject/opensearch:2.16.0',
restart: 'unless-stopped',
environment: [
'cluster.name=opensearch-cluster',
'node.name=opensearch-node',
'discovery.type=single-node',
'bootstrap.memory_lock=true',
'OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m',
'plugins.security.disabled=true',
'OPENSEARCH_INITIAL_ADMIN_PASSWORD=UserFeedback123!@#',
],
ulimits: {
memlock: { soft: -1, hard: -1 },
nofile: { soft: 65536, hard: 65536 },
},
volumes: ['opensearch:/usr/share/opensearch/data'],
ports: ['9200:9200', '9600:9600'],
healthcheck: {
test: ['CMD', 'curl', '-f', 'http://localhost:9200/_cluster/health'],
interval: '10s',
timeout: '5s',
retries: '5',
},
};
doc.services.api.depends_on['opensearch-node'] = {
condition: 'service_healthy',
};
doc.volumes.opensearch = {};
doc.services['opensearch-dashboards'] = {
image: 'opensearchproject/opensearch-dashboards:2.16.0',
restart: 'unless-stopped',
ports: ['5601:5601'],
environment: [
'OPENSEARCH_HOSTS=["http://opensearch-node:9200"]',
'DISABLE_SECURITY_DASHBOARDS_PLUGIN=true',
],
depends_on: {
'opensearch-node': {
condition: 'service_healthy',
},
},
};
}
const yml = YAML.stringify(doc);
return yml;
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import path from 'path';
import * as TOML from 'toml';
import { z } from 'zod';
import { exists, readFile } from './fsutil';
const ConfigSchema = z.object({
web: z.object({
port: z.number().default(3000),
api_base_url: z.string().default('http://localhost:4000'),
}),
api: z.object({
port: z.number().default(4000),
jwt_secret: z.string().min(32).default('jwtsecretjwtsecretjwtsecret'),
master_api_key: z.string().optional(),
access_token_expired_time: z.string().optional(),
refresh_token_expired_time: z.string().optional(),
auto_feedback_deletion: z
.object({
enabled: z.boolean().default(false),
period_days: z.number().optional(),
})
.optional(),
smtp: z
.object({
host: z.string(),
port: z.number(),
sender: z.string(),
username: z.string().optional(),
password: z.string().optional(),
tls: z.string().optional(),
cipher_spec: z.string().optional(),
opportunistic_tls: z.string().optional(),
})
.optional()
.default({ host: 'smtp4dev', port: 25, sender: 'user@feedback.com' }),
opensearch: z.object({ enabled: z.boolean().default(false) }).optional(),
}),
mysql: z.object({ port: z.number().default(13306) }).optional(),
});
export type AppConfig = z.infer<typeof ConfigSchema>;
export function loadConfig(cwd = process.cwd()): AppConfig {
const mainPath = path.join(cwd, 'config.toml');
if (!exists(mainPath))
throw new Error(
"config.toml 이 없습니다. 먼저 'mystack init'을 실행하세요.",
);
const main = TOML.parse(readFile(mainPath)) as unknown;
const parsed = ConfigSchema.safeParse(main);
if (!parsed.success)
throw new Error(
'config.toml 검증 실패:\n' +
JSON.stringify(parsed.error.format(), null, 2),
);
return parsed.data;
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import fs from 'fs';
import path from 'path';
export function ensureDir(dir: string) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
export function writeFile(p: string, content: string) {
ensureDir(path.dirname(p));
fs.writeFileSync(p, content, 'utf8');
}
export function exists(p: string) {
return fs.existsSync(p);
}
export function readFile(p: string) {
return fs.readFileSync(p, 'utf8');
}
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env node
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { Command } from 'commander';
import packageJson from '../package.json';
import { generateComposeContent } from './compose';
import { loadConfig } from './config';
import { exists, writeFile } from './fsutil';
import { run, runWithStdin } from './shell';
const program = new Command();
program
.name('auf-cli')
.description('Tiny stack CLI (config.toml only)')
.version(packageJson.version);
program
.command('init')
.description('Generate config.toml template — use without .env')
.option('--force', 'Overwrite existing file')
.action((opts: { force?: boolean }) => {
if (exists('config.toml') && !opts.force)
throw new Error('config.toml already exists. Use --force to overwrite.');
writeFile('config.toml', defaultConfigToml());
console.log('✅ Created: config.toml');
});
program
.command('start')
.description('Start services with docker compose up -d based on config.toml')
.action(async () => {
const cfg = loadConfig();
const composeContent = generateComposeContent(cfg);
await runWithStdin(
'docker',
['compose', '-f', '-', 'up', '-d', '--remove-orphans'],
composeContent,
);
console.log('🚀 Services started successfully!');
console.log('🔗 Available URLs:');
console.log(` 📱 Web: http://localhost:${cfg.web.port}`);
console.log(` 🔧 API: http://localhost:${cfg.api.port}`);
if (cfg.mysql) {
console.log(
` 🗄️ MySQL: mysql://userfeedback:userfeedback@localhost:${cfg.mysql.port}`,
);
}
if (cfg.api.opensearch?.enabled) {
console.log(` 🔍 OpenSearch: http://localhost:9200`);
}
if (cfg.api.smtp.host === 'smtp4dev') {
console.log(` 📧 SMTP Mail Web: http://localhost:5080`);
}
});
program
.command('stop')
.description('Stop services with docker compose down')
.action(async () => {
const cfg = loadConfig();
const composeContent = generateComposeContent(cfg);
await runWithStdin(
'docker',
['compose', '-f', '-', 'down'],
composeContent,
);
console.log('🛑 Services stopped successfully');
});
program
.command('clean')
.description('Clean up containers/networks/volumes')
.option('--images', 'Also prune images')
.action(async (opts: { images?: boolean }) => {
const cfg = loadConfig();
const composeContent = generateComposeContent(cfg);
await runWithStdin(
'docker',
['compose', '-f', '-', 'down', '--volumes', '--remove-orphans'],
composeContent,
);
if (opts.images) await run('docker', ['image', 'prune', '-f']);
console.log('🧹 Cleanup completed successfully');
});
program.parseAsync().catch((e: Error) => {
console.error('❌ Error:', e.message);
process.exit(1);
});
function defaultConfigToml() {
return `
[web]
port = 3000
# api_base_url = "http://localhost:4000"
[api]
port = 4000
jwt_secret = "jwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecret"
# master_api_key = "MASTER_KEY"
# access_token_expired_time = "10m"
# refresh_token_expired_time = "1h"
# [api.auto_feedback_deletion]
# enabled = true
# period_days = 365
# [api.smtp]
# host = "smtp4dev" # SMTP_HOST
# port = 25 # SMTP_PORT
# sender = "user@feedback.com"
# username=
# password=
# tls=
# ciper_spec=
# opportunitic_tls=
# [api.opensearch]
# enabled = true
[mysql]
port = 13306
`;
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { spawn } from 'child_process';
export function run(cmd: string, args: string[] = [], cwd?: string) {
return new Promise<void>((resolve, reject) => {
const p = spawn(cmd, args, {
stdio: 'inherit',
cwd,
shell: process.platform === 'win32',
});
p.on('close', (code) =>
code === 0 ? resolve() : (
reject(new Error(`${cmd} ${args.join(' ')} exited with ${code}`))
),
);
});
}
export function runWithStdin(
cmd: string,
args: string[] = [],
input: string,
cwd?: string,
) {
return new Promise<void>((resolve, reject) => {
const p = spawn(cmd, args, {
stdio: ['pipe', 'inherit', 'inherit'],
cwd,
shell: process.platform === 'win32',
});
p.stdin.write(input);
p.stdin.end();
p.on('close', (code) =>
code === 0 ? resolve() : (
reject(new Error(`${cmd} ${args.join(' ')} exited with ${code}`))
),
);
});
}
+8
View File
@@ -0,0 +1,8 @@
import baseConfig from '@ufb/eslint-config/base';
export default [
{
ignores: ['dist/**', '**/*.js'],
},
...baseConfig,
];
+46
View File
@@ -0,0 +1,46 @@
{
"name": "auf-cli",
"version": "1.0.11",
"description": "Command line interface for ABC User Feedback",
"repository": "https://github.com/line/abc-user-feedback/tree/main/apps/cli",
"bin": {
"auf-cli": "./dist/index.js"
},
"author": "ABC User Feedback",
"keywords": [
"auf",
"cli",
"command-line",
"tool",
"abc-user-feedback",
"VOC"
],
"scripts": {
"build": "tsc",
"clean": "git clean -xdf dist .turbo node_modules .cache",
"format": "prettier --check . --ignore-path ../../.gitignore",
"format:fix": "prettier --write --list-different .",
"lint": "eslint",
"start": "node dist/index.js",
"start:dev": "ts-node bin/index.ts",
"dev": "ts-node bin/index.ts"
},
"dependencies": {
"@types/prompts": "^2.4.9",
"child_process": "^1.0.2",
"commander": "^14.0.3",
"js-toml": "^1.0.3",
"toml": "^3.0.0",
"yaml": "^2.8.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "24.12.0",
"@ufb/eslint-config": "workspace:*",
"@ufb/prettier-config": "workspace:*",
"@ufb/tsconfig": "workspace:*",
"ts-node": "^10.9.2",
"typescript": "catalog:"
},
"prettier": "@ufb/prettier-config"
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "@ufb/tsconfig/nestjs.json",
"compilerOptions": {
"outDir": "./dist",
"baseUrl": "./",
"resolveJsonModule": true
},
"include": ["."],
"exclude": ["node_modules", "dist"]
}