draftgo-cli 3.0.1 → 3.0.33

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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -124
  3. package/package.json +21 -8
  4. package/resources/skill/SKILL.md +62 -89
  5. package/resources/skill/init/SKILL.md +18 -67
  6. package/resources/skill/manifest.json +27 -0
  7. package/resources/skill/pull/SKILL.md +18 -44
  8. package/resources/skill/push/SKILL.md +30 -247
  9. package/resources/skill/references/aihub.md +86 -0
  10. package/resources/skill/references/api-endpoints.md +178 -0
  11. package/resources/skill/references/api.json +20248 -0
  12. package/resources/skill/{quickref → references}/app-api.md +44 -14
  13. package/resources/skill/{core → references}/architecture.md +6 -26
  14. package/resources/skill/references/chat-sdk.md +201 -0
  15. package/resources/skill/references/custom-services.md +308 -0
  16. package/resources/skill/references/data.md +298 -0
  17. package/resources/skill/references/db-relations.md +227 -0
  18. package/resources/skill/references/frontend.md +788 -0
  19. package/resources/skill/references/modules.md +66 -0
  20. package/resources/skill/references/parallel.md +48 -0
  21. package/resources/skill/{specs → references}/runtime.md +31 -1
  22. package/resources/skill/{specs → references}/security.md +3 -3
  23. package/resources/skill/references/ui-protocol.md +99 -0
  24. package/resources/skill/scripts/draftgo_delete.py +0 -2
  25. package/resources/skill/scripts/draftgo_init.py +15 -3
  26. package/resources/skill/scripts/draftgo_pull.py +154 -87
  27. package/resources/skill/scripts/draftgo_push.py +440 -183
  28. package/resources/skill/story/SKILL.md +13 -23
  29. package/src/cli.js +22 -7
  30. package/src/commandRegistry.js +34 -0
  31. package/src/commands/api.js +204 -0
  32. package/src/commands/autoPush.js +41 -0
  33. package/src/commands/check.js +27 -17
  34. package/src/commands/delete.js +6 -4
  35. package/src/commands/deploy.js +31 -0
  36. package/src/commands/help.js +41 -28
  37. package/src/commands/init.js +34 -20
  38. package/src/commands/local.js +9 -3
  39. package/src/commands/map.js +18 -7
  40. package/src/commands/sync.js +11 -4
  41. package/src/commands/update.js +39 -52
  42. package/src/commands/verifyUi.js +199 -0
  43. package/src/index.js +13 -46
  44. package/src/localdev/compose.js +48 -197
  45. package/src/localdev/index.js +116 -216
  46. package/src/localdev/mysqlClient.js +12 -9
  47. package/src/localdev/services.js +163 -0
  48. package/src/platforms.js +3 -3
  49. package/src/projectConfig.js +12 -2
  50. package/src/projectMap.js +240 -68
  51. package/src/skill.js +113 -29
  52. package/src/updateCheck.js +37 -15
  53. package/resources/skill/core/modules.md +0 -54
  54. package/resources/skill/practices/anti-patterns.md +0 -70
  55. package/resources/skill/practices/best-practices.md +0 -41
  56. package/resources/skill/practices/dev-declaration.md +0 -94
  57. package/resources/skill/quickref/api-endpoints.md +0 -130
  58. package/resources/skill/quickref/api.json +0 -17675
  59. package/resources/skill/quickref/dg-components.md +0 -198
  60. package/resources/skill/rules/dev-workflow.md +0 -652
  61. package/resources/skill/rules/frontend.md +0 -210
  62. package/resources/skill/rules/parallel.md +0 -263
  63. package/resources/skill/specs/data.md +0 -108
  64. package/resources/skill/specs/ui-protocol.md +0 -68
  65. package/src/commands/doctor.js +0 -54
  66. package/src/commands/new.js +0 -183
  67. package/src/commands/projectScript.js +0 -37
  68. /package/resources/skill/{rules → references}/debugging-syntax.md +0 -0
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+ const log = require('../logger');
7
+
8
+ const UI_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less', '.html', '.htm', '.jsx', '.tsx', '.vue', '.svelte']);
9
+
10
+ function isUiFile(file) {
11
+ const normalized = String(file || '').replace(/\\/g, '/').toLowerCase();
12
+ const ext = path.extname(normalized);
13
+ if (UI_EXTENSIONS.has(ext)) return true;
14
+ if (!['.js', '.ts'].includes(ext)) return false;
15
+ return /(^|\/)(frontend|web|ui|components|pages|views|client)(\/|$)/.test(normalized)
16
+ || /(^|\/)(app|main|client)\.(js|ts)$/.test(normalized)
17
+ || normalized.startsWith('.draftgo/pages/')
18
+ || normalized.startsWith('.draftgo/navigations/');
19
+ }
20
+
21
+ function gitChangedFiles(projectDir) {
22
+ const r = spawnSync('git', ['status', '--porcelain=v1', '--untracked-files=all'], {
23
+ cwd: projectDir,
24
+ encoding: 'utf8',
25
+ shell: false,
26
+ });
27
+ if (r.error || r.status !== 0) return null;
28
+ return String(r.stdout || '').split(/\r?\n/).filter(Boolean).map((line) => {
29
+ const raw = line.slice(3).trim();
30
+ const renamed = raw.includes(' -> ') ? raw.split(' -> ').pop() : raw;
31
+ return renamed.replace(/^"|"$/g, '');
32
+ });
33
+ }
34
+
35
+ function decideMobileCheck(projectDir, mode) {
36
+ if (mode === 'always') return { run: true, reason: 'mobile-check=always' };
37
+ if (mode === 'never') return { run: false, reason: 'mobile-check=never' };
38
+ const changed = gitChangedFiles(projectDir);
39
+ if (changed === null || changed.length === 0) {
40
+ return { run: true, reason: '无法从 Git diff 确认影响范围,执行单视口 smoke check' };
41
+ }
42
+ const uiFiles = changed.filter(isUiFile);
43
+ if (!uiFiles.length) return { run: false, reason: '本次变更未涉及页面、样式或前端组件' };
44
+ return { run: true, reason: `检测到 ${uiFiles.length} 个 UI 文件变更` };
45
+ }
46
+
47
+ function numberFlag(value, fallback, min, max) {
48
+ const n = Number(value);
49
+ return Number.isFinite(n) ? Math.max(min, Math.min(max, Math.round(n))) : fallback;
50
+ }
51
+
52
+ function executableCandidates() {
53
+ if (process.platform === 'win32') {
54
+ const roots = [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'], process.env.LOCALAPPDATA].filter(Boolean);
55
+ const rels = [
56
+ ['Microsoft', 'Edge', 'Application', 'msedge.exe'],
57
+ ['Google', 'Chrome', 'Application', 'chrome.exe'],
58
+ ['Chromium', 'Application', 'chrome.exe'],
59
+ ];
60
+ return roots.flatMap((root) => rels.map((parts) => path.join(root, ...parts)));
61
+ }
62
+ if (process.platform === 'darwin') {
63
+ return [
64
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
65
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
66
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
67
+ ];
68
+ }
69
+ return ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/microsoft-edge', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
70
+ }
71
+
72
+ async function launchBrowser(chromium, requested) {
73
+ const attempts = [];
74
+ if (requested && requested !== 'chromium') attempts.push({ channel: requested });
75
+ for (const executablePath of executableCandidates().filter((candidate) => fs.existsSync(candidate))) {
76
+ attempts.push({ executablePath });
77
+ }
78
+ if (!requested || requested === 'chromium') attempts.push({});
79
+ for (const channel of ['msedge', 'chrome']) {
80
+ if (!attempts.some((a) => a.channel === channel)) attempts.push({ channel });
81
+ }
82
+
83
+ let lastError = null;
84
+ for (const options of attempts) {
85
+ try {
86
+ return await chromium.launch({ headless: true, ...options });
87
+ } catch (err) {
88
+ lastError = err;
89
+ }
90
+ }
91
+ throw new Error(`未找到可用的 Chromium/Chrome/Edge。${lastError ? ` ${lastError.message.split('\n')[0]}` : ''}`);
92
+ }
93
+
94
+ async function verifyUi(projectDir, positional, flags = {}) {
95
+ const url = String(flags.url || positional[0] || '').trim();
96
+ if (!/^https?:\/\//i.test(url)) {
97
+ log.err('用法:draftgo verify-ui <http://localhost:port/path>');
98
+ return 1;
99
+ }
100
+
101
+ const mode = String(flags['mobile-check'] || 'auto').toLowerCase();
102
+ if (!['auto', 'always', 'never'].includes(mode)) {
103
+ log.err('--mobile-check 只支持 auto、always、never。');
104
+ return 1;
105
+ }
106
+ const decision = decideMobileCheck(projectDir, mode);
107
+ if (!decision.run) {
108
+ log.ok(`跳过 UI smoke check:${decision.reason}`);
109
+ return 0;
110
+ }
111
+
112
+ let chromium;
113
+ try {
114
+ ({ chromium } = require('playwright-core'));
115
+ } catch {
116
+ log.err('缺少 playwright-core,请重新安装或升级 draftgo-cli。');
117
+ return 1;
118
+ }
119
+
120
+ const width = numberFlag(flags.width, 390, 240, 3840);
121
+ const height = numberFlag(flags.height, 844, 320, 2160);
122
+ const waitMs = numberFlag(flags['wait-ms'], 500, 0, 10000);
123
+ const screenshotMode = String(flags.screenshot || 'on-failure').toLowerCase();
124
+ if (!['on-failure', 'always', 'never'].includes(screenshotMode)) {
125
+ log.err('--screenshot 只支持 on-failure、always、never。');
126
+ return 1;
127
+ }
128
+
129
+ let browser;
130
+ try {
131
+ browser = await launchBrowser(chromium, flags.browser && String(flags.browser));
132
+ const page = await browser.newPage({ viewport: { width, height } });
133
+ const consoleErrors = [];
134
+ const pageErrors = [];
135
+ page.on('console', (msg) => { if (msg.type() === 'error') consoleErrors.push(msg.text()); });
136
+ page.on('pageerror', (err) => pageErrors.push(err.message));
137
+
138
+ const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
139
+ if (waitMs) await page.waitForTimeout(waitMs);
140
+ const state = await page.evaluate(() => {
141
+ const body = document.body;
142
+ const root = document.documentElement;
143
+ return {
144
+ title: document.title,
145
+ bodyTextLength: body ? (body.innerText || '').trim().length : 0,
146
+ bodyHeight: body ? body.getBoundingClientRect().height : 0,
147
+ hasVisibleMedia: body ? Array.from(body.querySelectorAll('img,svg,canvas,video,iframe,input,button')).some((element) => {
148
+ const rect = element.getBoundingClientRect();
149
+ return rect.width > 1 && rect.height > 1;
150
+ }) : false,
151
+ horizontalOverflow: root.scrollWidth > window.innerWidth + 1,
152
+ scrollWidth: root.scrollWidth,
153
+ viewportWidth: window.innerWidth,
154
+ };
155
+ });
156
+
157
+ const issues = [];
158
+ if (response && response.status() >= 400) issues.push(`页面返回 HTTP ${response.status()}`);
159
+ if (!state.bodyHeight || (!state.bodyTextLength && !state.hasVisibleMedia)) issues.push('页面疑似空白');
160
+ if (state.horizontalOverflow) issues.push(`页面横向溢出:scrollWidth=${state.scrollWidth}, viewport=${state.viewportWidth}`);
161
+ if (consoleErrors.length) issues.push(`console error ${consoleErrors.length} 条`);
162
+ if (pageErrors.length) issues.push(`page error ${pageErrors.length} 条`);
163
+ if (flags.selector) {
164
+ const visible = await page.locator(String(flags.selector)).first().isVisible().catch(() => false);
165
+ if (!visible) issues.push(`关键元素不可见:${flags.selector}`);
166
+ }
167
+
168
+ const shouldScreenshot = screenshotMode === 'always' || (screenshotMode === 'on-failure' && issues.length > 0);
169
+ let screenshotPath = null;
170
+ if (shouldScreenshot) {
171
+ const dir = path.join(projectDir, '.draftgo', 'artifacts');
172
+ fs.mkdirSync(dir, { recursive: true });
173
+ screenshotPath = path.join(dir, `ui-check-${process.pid}-${Date.now()}.png`);
174
+ await page.screenshot({ path: screenshotPath, fullPage: true });
175
+ }
176
+
177
+ if (issues.length) {
178
+ issues.forEach((issue) => log.err(issue));
179
+ if (consoleErrors.length) consoleErrors.slice(0, 5).forEach((msg) => log.dim(` console: ${msg}`));
180
+ if (pageErrors.length) pageErrors.slice(0, 5).forEach((msg) => log.dim(` page: ${msg}`));
181
+ if (screenshotPath) log.info(`失败截图:${screenshotPath}`);
182
+ return 1;
183
+ }
184
+
185
+ log.ok(`UI smoke check 通过:${width}x${height},${decision.reason}`);
186
+ if (screenshotPath) log.info(`截图:${screenshotPath}`);
187
+ return 0;
188
+ } catch (err) {
189
+ log.err(`UI smoke check 失败:${err.message}`);
190
+ return 1;
191
+ } finally {
192
+ if (browser) await browser.close().catch(() => {});
193
+ }
194
+ }
195
+
196
+ module.exports = verifyUi;
197
+ module.exports.decideMobileCheck = decideMobileCheck;
198
+ module.exports.gitChangedFiles = gitChangedFiles;
199
+ module.exports.isUiFile = isUiFile;
package/src/index.js CHANGED
@@ -2,10 +2,17 @@
2
2
 
3
3
  const path = require('path');
4
4
  const { parse } = require('./cli');
5
+ const { resolveCommand } = require('./commandRegistry');
5
6
  const log = require('./logger');
6
7
 
7
8
  async function run(argv) {
8
- const { command, positional, flags } = parse(argv);
9
+ const { command, positional, flags, errors } = parse(argv);
10
+
11
+ if (errors.length) {
12
+ for (const error of errors) log.err(error);
13
+ require('./commands/help')();
14
+ return 1;
15
+ }
9
16
 
10
17
  // --version / --help shortcuts
11
18
  if (flags.version || flags.v || command === 'version') {
@@ -22,51 +29,11 @@ async function run(argv) {
22
29
  : process.cwd();
23
30
 
24
31
  try {
25
- switch (command) {
26
- case 'init':
27
- return await require('./commands/init')(projectDir, positional, flags);
28
- case 'update':
29
- case 'upgrade':
30
- return await require('./commands/update')(projectDir, positional, flags);
31
- case 'uninstall':
32
- case 'remove':
33
- return await require('./commands/uninstall')(projectDir, positional, flags);
34
- case 'status':
35
- return require('./commands/status')(projectDir);
36
- case 'doctor':
37
- return await require('./commands/doctor')(projectDir, flags);
38
- case 'map':
39
- return require('./commands/map')(projectDir, flags);
40
- case 'check':
41
- return require('./commands/check')(projectDir, flags);
42
- case 'dev':
43
- case 'build':
44
- return require('./commands/projectScript')(projectDir, command, flags);
45
- case 'new':
46
- return require('./commands/new')(projectDir, positional, flags);
47
- case 'delete':
48
- case 'del':
49
- case 'rm':
50
- return await require('./commands/delete')(projectDir, positional, flags);
51
- case 'pull':
52
- case 'push':
53
- return require('./commands/sync')(projectDir, command, positional, flags);
54
- case 'local':
55
- return require('./commands/local')(projectDir, positional, flags);
56
- case 'list-targets':
57
- case 'targets':
58
- return require('./commands/listTargets')();
59
- case 'local-dev':
60
- case 'localdev':
61
- return await require('./commands/localDev')(projectDir, positional, flags);
62
- case 'connect':
63
- case 'login':
64
- return await require('./commands/connect')(projectDir, positional, flags);
65
- default:
66
- log.err(`未知命令:${command}`);
67
- require('./commands/help')();
68
- return 1;
69
- }
32
+ const definition = resolveCommand(command);
33
+ if (definition) return await definition.run(projectDir, positional, flags);
34
+ log.err(`未知命令:${command}`);
35
+ require('./commands/help')();
36
+ return 1;
70
37
  } catch (e) {
71
38
  log.err(e.message || String(e));
72
39
  if (process.env.DEBUG) console.error(e.stack);
@@ -1,29 +1,5 @@
1
1
  'use strict';
2
2
 
3
- // Generate a docker-compose.yaml for the local-dev DraftGo stack.
4
- //
5
- // Stored *inside the project* at:
6
- // <project>/.draftgo/docker/
7
- // ├── docker-compose.yaml
8
- // ├── .env # random secrets, kept across re-runs
9
- // └── data/ # bind-mounted volumes
10
- //
11
- // Each project gets its own stack — no cross-project sharing — so multiple
12
- // DraftGo projects can run in parallel without container/volume collisions.
13
- //
14
- // Stack composition is dynamic:
15
- // - Always: the `app` service (cabinai/draftgo:latest)
16
- // - Optional: `mysql` service (omitted when user wires up an existing MySQL)
17
- // - Optional: `redis` service (omitted when user wires up an existing Redis)
18
- // The app talks to in-stack services by service-name, and to host services via
19
- // `host.docker.internal` (works on Windows/macOS natively; `host-gateway` extra
20
- // host is added so it also works on Linux).
21
- //
22
- // Container naming: every service gets an explicit `container_name:` derived
23
- // from the user-supplied compose project name, so the actual containers are
24
- // named `<projectName>` / `<projectName>-mysql` / `<projectName>-redis`
25
- // instead of compose's default `<projectName>-<service>-1`.
26
-
27
3
  const crypto = require('crypto');
28
4
  const path = require('path');
29
5
  const fs = require('fs');
@@ -33,225 +9,100 @@ function stackDir(projectDir) { return path.join(projectDir, '.draftgo', 'docker
33
9
  function composePath(projectDir) { return path.join(stackDir(projectDir), 'docker-compose.yaml'); }
34
10
  function envPath(projectDir) { return path.join(stackDir(projectDir), '.env'); }
35
11
 
36
- function randomAlnum(len) {
37
- // URL-safe alphanumeric (no ambiguous shell metachars).
38
- const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
39
- const bytes = crypto.randomBytes(len);
40
- let out = '';
41
- for (let i = 0; i < len; i++) out += charset[bytes[i] % charset.length];
42
- return out;
43
- }
44
-
45
- function randomHex(bytes) {
46
- return crypto.randomBytes(bytes).toString('hex');
47
- }
12
+ function randomHex(bytes) { return crypto.randomBytes(bytes).toString('hex'); }
48
13
 
49
14
  function readEnv(projectDir) {
50
- // Parse existing .env into a flat object so we can preserve previously
51
- // generated secrets across re-runs of the wizard.
52
15
  if (!exists(envPath(projectDir))) return {};
53
16
  const out = {};
54
17
  for (const line of readText(envPath(projectDir)).split(/\r?\n/)) {
55
- const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
56
- if (m) out[m[1]] = m[2];
18
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
19
+ if (match) out[match[1]] = match[2];
57
20
  }
58
21
  return out;
59
22
  }
60
23
 
61
24
  function writeEnv(projectDir, env) {
62
- const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
63
- writeText(envPath(projectDir), lines.join('\n') + '\n');
25
+ writeText(envPath(projectDir), `${Object.entries(env).map(([key, value]) => `${key}=${value}`).join('\n')}\n`);
64
26
  }
65
27
 
66
- // Compose project names must match [a-z0-9][a-z0-9_-]*.
67
- // Trim/normalise user input so we never write an invalid compose file.
68
28
  function sanitizeProjectName(raw, fallback = 'draftgo') {
69
- let v = String(raw || '').trim().toLowerCase();
70
- v = v.replace(/[^a-z0-9_-]/g, '-').replace(/^[-_]+/, '');
71
- return v || fallback;
29
+ const value = String(raw || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^[-_]+/, '');
30
+ return value || fallback;
31
+ }
32
+
33
+ function defaultRedisKeyPrefix(projectName, mysql) {
34
+ return (mysql && mysql.database) || projectName;
35
+ }
36
+
37
+ function dockerHost(host) {
38
+ return host === 'localhost' || host === '127.0.0.1' ? 'host.docker.internal' : host;
72
39
  }
73
40
 
74
- // `opts`:
75
- // projectName: docker compose project name (also drives container_name)
76
- // appPort: host port for the app (default 3000)
77
- // mysql: { useContainer, host, port, user, password, database }
78
- // redis: { useContainer, host, port, password }
79
41
  function generate(projectDir, opts) {
80
42
  const root = stackDir(projectDir);
81
43
  ensureDir(root);
82
44
  const env = readEnv(projectDir);
83
45
  const projectName = sanitizeProjectName(opts.projectName, 'draftgo');
46
+ const database = opts.mysql.database;
47
+ const milvus = opts.milvus || { host: '127.0.0.1', port: 19530, username: '', password: '' };
84
48
 
85
- const appPort = Number(opts.appPort || 3000);
86
- env.APP_PORT = String(appPort);
49
+ env.APP_PORT = String(opts.appPort || 3000);
87
50
  env.SECRET_KEY = env.SECRET_KEY || randomHex(32);
51
+ env.DATABASE_URL = `mysql://${encodeURIComponent(opts.mysql.user)}:${encodeURIComponent(opts.mysql.password)}@${dockerHost(opts.mysql.host)}:${opts.mysql.port}/${encodeURIComponent(database)}`;
52
+ env.REDIS_HOST = dockerHost(opts.redis.host);
53
+ env.REDIS_PORT = String(opts.redis.port);
54
+ env.REDIS_PASSWORD = opts.redis.password || '';
55
+ env.REDIS_KEY_PREFIX = database;
56
+ env.MILVUS_ADDRESS = `${dockerHost(milvus.host)}:${milvus.port}`;
57
+ env.MILVUS_USERNAME = milvus.username || '';
58
+ env.MILVUS_PASSWORD = milvus.password || '';
59
+ env.MILVUS_DATABASE = 'default';
60
+ env.MILVUS_COLLECTION_PREFIX = database;
88
61
 
89
- const services = {};
90
- const dependsOn = [];
91
-
92
- // ── MySQL ────────────────────────────────────────────────────────────
93
- let dbUrl;
94
- if (opts.mysql.useContainer) {
95
- env.MYSQL_ROOT_PASSWORD = env.MYSQL_ROOT_PASSWORD || randomAlnum(24);
96
- env.MYSQL_DATABASE = opts.mysql.database || env.MYSQL_DATABASE || 'draftgo';
97
- env.MYSQL_USER = opts.mysql.user || env.MYSQL_USER || 'draftgo';
98
- env.MYSQL_PASSWORD = env.MYSQL_PASSWORD || randomAlnum(24);
99
- services.mysql = [
100
- ' mysql:',
101
- ' image: mysql:8.0',
102
- ` container_name: ${projectName}-mysql`,
103
- ' restart: unless-stopped',
104
- ' logging:',
105
- ' driver: json-file',
106
- ' options:',
107
- ' max-size: "10m"',
108
- ' max-file: "3"',
109
- ' environment:',
110
- ' MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}',
111
- ' MYSQL_DATABASE: ${MYSQL_DATABASE}',
112
- ' MYSQL_USER: ${MYSQL_USER}',
113
- ' MYSQL_PASSWORD: ${MYSQL_PASSWORD}',
114
- ' command:',
115
- ' - --character-set-server=utf8mb4',
116
- ' - --collation-server=utf8mb4_unicode_ci',
117
- ' volumes:',
118
- ' - ./data/mysql:/var/lib/mysql',
119
- ' healthcheck:',
120
- ' test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]',
121
- ' interval: 10s',
122
- ' timeout: 5s',
123
- ' retries: 12',
124
- ' start_period: 30s',
125
- ].join('\n');
126
- dependsOn.push(['mysql', 'service_healthy']);
127
- dbUrl = 'mysql+pymysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DATABASE}';
128
- } else {
129
- const u = encodeURIComponent(opts.mysql.user);
130
- const p = encodeURIComponent(opts.mysql.password);
131
- const host = opts.mysql.host === 'localhost' || opts.mysql.host === '127.0.0.1'
132
- ? 'host.docker.internal'
133
- : opts.mysql.host;
134
- dbUrl = `mysql+pymysql://${u}:${p}@${host}:${opts.mysql.port}/${opts.mysql.database}`;
135
- }
136
-
137
- // ── Redis ────────────────────────────────────────────────────────────
138
- let redisHost, redisPort, redisPass;
139
- if (opts.redis.useContainer) {
140
- env.REDIS_PASSWORD = env.REDIS_PASSWORD || randomAlnum(24);
141
- services.redis = [
142
- ' redis:',
143
- ' image: redis:7-alpine',
144
- ` container_name: ${projectName}-redis`,
145
- ' restart: unless-stopped',
146
- ' logging:',
147
- ' driver: json-file',
148
- ' options:',
149
- ' max-size: "10m"',
150
- ' max-file: "3"',
151
- ' command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes"]',
152
- ' volumes:',
153
- ' - ./data/redis:/data',
154
- ' healthcheck:',
155
- ' test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]',
156
- ' interval: 10s',
157
- ' timeout: 3s',
158
- ' retries: 10',
159
- ' start_period: 10s',
160
- ].join('\n');
161
- dependsOn.push(['redis', 'service_healthy']);
162
- redisHost = 'redis';
163
- redisPort = '6379';
164
- redisPass = '${REDIS_PASSWORD}';
165
- } else {
166
- redisHost = (opts.redis.host === 'localhost' || opts.redis.host === '127.0.0.1')
167
- ? 'host.docker.internal'
168
- : opts.redis.host;
169
- redisPort = String(opts.redis.port);
170
- redisPass = opts.redis.password || '';
171
- }
172
-
173
- // ── App ──────────────────────────────────────────────────────────────
174
- const appLines = [
62
+ const yaml = [
63
+ '# Generated by draftgo-cli. Shared MySQL, Redis, and Milvus stay on the host.',
64
+ `name: ${projectName}`,
65
+ 'services:',
175
66
  ' app:',
176
67
  ' image: cabinai/draftgo:latest',
177
68
  ` container_name: ${projectName}`,
178
69
  ' restart: unless-stopped',
179
- ' logging:',
180
- ' driver: json-file',
181
- ' options:',
182
- ' max-size: "10m"',
183
- ' max-file: "3"',
184
- ` ports:`,
185
- ` - "${appPort}:3000"`,
70
+ ' ports:',
71
+ ' - "${APP_PORT}:3000"',
186
72
  ' environment:',
187
73
  ' APP_HOST: 0.0.0.0',
188
74
  ' APP_PORT: "3000"',
189
- ` PUBLIC_BASE_URL: http://localhost:${appPort}`,
190
- ' DATABASE_TYPE: mysql',
191
- ` DATABASE_URL: "${dbUrl}"`,
75
+ ' PUBLIC_BASE_URL: http://localhost:${APP_PORT}',
76
+ ' DATABASE_URL: ${DATABASE_URL}',
192
77
  ' SECRET_KEY: ${SECRET_KEY}',
193
- ` REDIS_HOST: ${redisHost}`,
194
- ` REDIS_PORT: "${redisPort}"`,
78
+ ' REDIS_HOST: ${REDIS_HOST}',
79
+ ' REDIS_PORT: ${REDIS_PORT}',
80
+ ' REDIS_PASSWORD: ${REDIS_PASSWORD}',
195
81
  ' REDIS_DB: "0"',
196
- ` REDIS_PASSWORD: "${redisPass}"`,
197
- ' LOG_DIR: /app/backend/logs',
198
- ' LOG_FILE_NAME: app.log',
199
- ' LOG_LEVEL: INFO',
200
- ' LOG_MAX_BYTES: "5242880"',
201
- ' LOG_BACKUP_COUNT: "5"',
202
- ' LOG_TO_CONSOLE: "true"',
203
- ' LOG_CONSOLE_JSON: "true"',
204
- ' LOG_UVICORN_ACCESS: "true"',
205
- ' LOG_RUNTIME_HEARTBEAT_INTERVAL_SECONDS: "300"',
206
- ' UPLOAD_PROVIDER: local',
207
- ' UPLOAD_MAX_SIZE: "10485760"',
82
+ ' REDIS_KEY_PREFIX: ${REDIS_KEY_PREFIX}',
83
+ ' MILVUS_ADDRESS: ${MILVUS_ADDRESS}',
84
+ ' MILVUS_USERNAME: ${MILVUS_USERNAME}',
85
+ ' MILVUS_PASSWORD: ${MILVUS_PASSWORD}',
86
+ ' MILVUS_DATABASE: ${MILVUS_DATABASE}',
87
+ ' MILVUS_COLLECTION_PREFIX: ${MILVUS_COLLECTION_PREFIX}',
208
88
  ' LOCAL_UPLOAD_DIR: /app/backend/storage/uploads',
209
- ' UPLOAD_URL_PREFIX: /uploads',
210
89
  ' volumes:',
211
90
  ' - ./data/uploads:/app/backend/storage/uploads',
212
91
  ' - ./data/logs:/app/backend/logs',
213
92
  ' - ./data/db:/app/backend/db',
214
93
  ' extra_hosts:',
215
94
  ' - "host.docker.internal:host-gateway"',
216
- ];
217
- if (dependsOn.length > 0) {
218
- appLines.push(' depends_on:');
219
- for (const [name, cond] of dependsOn) {
220
- appLines.push(` ${name}:`);
221
- appLines.push(` condition: ${cond}`);
222
- }
223
- }
224
- services.app = appLines.join('\n');
225
-
226
- const order = ['app', 'mysql', 'redis'].filter((k) => services[k]);
227
- const yaml = [
228
- '# Auto-generated by draftgo-cli (local-dev wizard).',
229
- '# Secrets live in the sibling .env file. Re-running the wizard preserves them.',
230
- `name: ${projectName}`,
231
- 'services:',
232
- ...order.map((k) => services[k]),
233
95
  '',
234
96
  ].join('\n');
235
-
236
97
  writeText(composePath(projectDir), yaml);
237
98
  writeEnv(projectDir, env);
238
-
239
- // Pre-create bind-mount dirs so the Docker daemon does not create them as root on Linux.
240
- for (const sub of ['data/uploads', 'data/logs', 'data/db', 'data/mysql', 'data/redis']) {
241
- try { fs.mkdirSync(path.join(root, sub), { recursive: true }); } catch {}
99
+ for (const subdir of ['data/uploads', 'data/logs', 'data/db']) {
100
+ try { fs.mkdirSync(path.join(root, subdir), { recursive: true }); } catch {}
242
101
  }
243
-
244
- return {
245
- dir: root,
246
- composeFile: composePath(projectDir),
247
- envFile: envPath(projectDir),
248
- projectName,
249
- env,
250
- };
102
+ return { dir: root, composeFile: composePath(projectDir), envFile: envPath(projectDir), projectName, env };
251
103
  }
252
104
 
253
105
  module.exports = {
254
106
  stackDir, composePath, envPath,
255
- generate, sanitizeProjectName,
256
- randomAlnum, randomHex,
107
+ generate, sanitizeProjectName, defaultRedisKeyPrefix, randomHex,
257
108
  };