draftgo-cli 1.0.4
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/LICENSE +21 -0
- package/README.md +249 -0
- package/bin/draftgo.js +9 -0
- package/package.json +70 -0
- package/resources/project-design/README.md +42 -0
- package/resources/skill/SKILL.md +62 -0
- package/resources/skill/init/SKILL.md +41 -0
- package/resources/skill/manifest.json +35 -0
- package/resources/skill/references/ai.md +41 -0
- package/resources/skill/references/app-api.md +97 -0
- package/resources/skill/references/architecture.md +13 -0
- package/resources/skill/references/chat-sdk.md +205 -0
- package/resources/skill/references/checkout.md +140 -0
- package/resources/skill/references/data.md +49 -0
- package/resources/skill/references/db-relations.md +29 -0
- package/resources/skill/references/delivery.md +33 -0
- package/resources/skill/references/development.md +41 -0
- package/resources/skill/references/diagnostics.md +50 -0
- package/resources/skill/references/frontend.md +158 -0
- package/resources/skill/references/mcp.md +110 -0
- package/resources/skill/references/methods.md +143 -0
- package/resources/skill/references/modules.md +75 -0
- package/resources/skill/references/runtime.md +109 -0
- package/resources/skill/references/services.md +32 -0
- package/src/apiContractCache.js +120 -0
- package/src/cli.js +100 -0
- package/src/commandRegistry.js +46 -0
- package/src/commands/api.js +244 -0
- package/src/commands/apiKey.js +30 -0
- package/src/commands/autoPush.js +36 -0
- package/src/commands/capabilities.js +100 -0
- package/src/commands/check.js +82 -0
- package/src/commands/checkout.js +18 -0
- package/src/commands/clean.js +72 -0
- package/src/commands/commit.js +47 -0
- package/src/commands/components.js +554 -0
- package/src/commands/conflict.js +30 -0
- package/src/commands/conflicts.js +16 -0
- package/src/commands/connect.js +91 -0
- package/src/commands/delete.js +95 -0
- package/src/commands/deploy.js +77 -0
- package/src/commands/diff.js +39 -0
- package/src/commands/group.js +37 -0
- package/src/commands/help.js +190 -0
- package/src/commands/init.js +126 -0
- package/src/commands/listTargets.js +13 -0
- package/src/commands/local.js +79 -0
- package/src/commands/map.js +395 -0
- package/src/commands/mcp.js +150 -0
- package/src/commands/reconcile.js +20 -0
- package/src/commands/role.js +31 -0
- package/src/commands/status.js +98 -0
- package/src/commands/uninstall.js +52 -0
- package/src/commands/update.js +79 -0
- package/src/commands/verify.js +188 -0
- package/src/commands/visualVerify.js +281 -0
- package/src/commands/worklog.js +117 -0
- package/src/consoleEncoding.js +34 -0
- package/src/contractCompatibility.js +65 -0
- package/src/detect.js +25 -0
- package/src/diffReport.js +106 -0
- package/src/fsx.js +67 -0
- package/src/index.js +46 -0
- package/src/localRuntime/compose.js +119 -0
- package/src/localRuntime/detect.js +77 -0
- package/src/localRuntime/index.js +211 -0
- package/src/localRuntime/mysqlClient.js +155 -0
- package/src/localRuntime/services.js +117 -0
- package/src/logger.js +37 -0
- package/src/mcp/client.js +558 -0
- package/src/mcp/hosts.js +520 -0
- package/src/mcp/parallel.js +54 -0
- package/src/mcp/protocol.js +223 -0
- package/src/mcp/stdio.js +300 -0
- package/src/mcp/tools.js +51 -0
- package/src/paths.js +32 -0
- package/src/platforms.js +110 -0
- package/src/projectConfig.js +139 -0
- package/src/projectDesign.js +19 -0
- package/src/projectHealth.js +33 -0
- package/src/projectMap.js +220 -0
- package/src/prompt.js +94 -0
- package/src/releaseInstall.js +105 -0
- package/src/runtimeFiles.js +45 -0
- package/src/skill.js +295 -0
- package/src/targets.js +43 -0
- package/src/timeout.js +18 -0
- package/src/updateCheck.js +100 -0
- package/src/worklog.js +276 -0
- package/src/worktree/backend.js +438 -0
- package/src/worktree/errors.js +28 -0
- package/src/worktree/index.js +751 -0
- package/src/worktree/inlineScripts.js +99 -0
- package/src/worktree/locks.js +52 -0
- package/src/worktree/manifest.js +89 -0
- package/src/worktree/status.js +124 -0
- package/src/worktree/streams.js +200 -0
- package/src/worktree/types.js +103 -0
- package/src/worktree/validate.js +37 -0
package/src/fsx.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
function exists(p) {
|
|
7
|
+
try { fs.accessSync(p); return true; } catch { return false; }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function ensureDir(dir) {
|
|
11
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readText(p) {
|
|
15
|
+
return fs.readFileSync(p, 'utf8');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function writeText(p, content) {
|
|
19
|
+
ensureDir(path.dirname(p));
|
|
20
|
+
fs.writeFileSync(p, content, 'utf8');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function copyFile(src, dest) {
|
|
24
|
+
ensureDir(path.dirname(dest));
|
|
25
|
+
fs.copyFileSync(src, dest);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function copyDir(src, dest) {
|
|
29
|
+
if (!exists(src)) return;
|
|
30
|
+
ensureDir(dest);
|
|
31
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
32
|
+
const s = path.join(src, entry.name);
|
|
33
|
+
const d = path.join(dest, entry.name);
|
|
34
|
+
if (entry.isSymbolicLink()) {
|
|
35
|
+
ensureDir(path.dirname(d));
|
|
36
|
+
const target = fs.readlinkSync(s);
|
|
37
|
+
try { fs.symlinkSync(target, d); }
|
|
38
|
+
catch (e) { if (e.code !== 'EEXIST') throw e; }
|
|
39
|
+
} else if (entry.isDirectory()) copyDir(s, d);
|
|
40
|
+
else if (entry.isFile()) copyFile(s, d);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function removePath(p) {
|
|
45
|
+
if (!exists(p)) return false;
|
|
46
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function appendGitignoreLine(projectDir, line) {
|
|
51
|
+
const gi = path.join(projectDir, '.gitignore');
|
|
52
|
+
const content = exists(gi) ? readText(gi) : '';
|
|
53
|
+
if (content.split(/\r?\n/).some((l) => l.trim() === line)) return;
|
|
54
|
+
const sep = content && !content.endsWith('\n') ? '\n' : '';
|
|
55
|
+
fs.appendFileSync(gi, `${sep}${line}\n`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = {
|
|
59
|
+
exists,
|
|
60
|
+
ensureDir,
|
|
61
|
+
readText,
|
|
62
|
+
writeText,
|
|
63
|
+
copyFile,
|
|
64
|
+
copyDir,
|
|
65
|
+
removePath,
|
|
66
|
+
appendGitignoreLine,
|
|
67
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { parse } = require('./cli');
|
|
5
|
+
const { resolveCommand } = require('./commandRegistry');
|
|
6
|
+
const { configureConsoleUtf8 } = require('./consoleEncoding');
|
|
7
|
+
const log = require('./logger');
|
|
8
|
+
|
|
9
|
+
async function run(argv) {
|
|
10
|
+
configureConsoleUtf8();
|
|
11
|
+
const { command, positional, flags, errors } = parse(argv);
|
|
12
|
+
|
|
13
|
+
if (errors.length) {
|
|
14
|
+
for (const error of errors) log.err(error);
|
|
15
|
+
require('./commands/help')();
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// --version / --help shortcuts
|
|
20
|
+
if (flags.version || flags.v || command === 'version') {
|
|
21
|
+
console.log(require('./skill').getPackageVersion());
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
if (!command || flags.help || flags.h || command === 'help') {
|
|
25
|
+
require('./commands/help')();
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const projectDir = flags.project
|
|
30
|
+
? path.resolve(String(flags.project))
|
|
31
|
+
: process.cwd();
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const definition = resolveCommand(command);
|
|
35
|
+
if (definition) return await definition.run(projectDir, positional, flags);
|
|
36
|
+
log.err(`未知命令:${command}`);
|
|
37
|
+
require('./commands/help')();
|
|
38
|
+
return 1;
|
|
39
|
+
} catch (e) {
|
|
40
|
+
log.err(e.message || String(e));
|
|
41
|
+
if (process.env.DEBUG) console.error(e.stack);
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { run };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const { ensureDir, exists, readText, writeText } = require('../fsx');
|
|
7
|
+
|
|
8
|
+
function stackDir(projectDir) { return path.join(projectDir, '.draftgo', 'docker'); }
|
|
9
|
+
function composePath(projectDir) { return path.join(stackDir(projectDir), 'docker-compose.yaml'); }
|
|
10
|
+
function envPath(projectDir) { return path.join(stackDir(projectDir), '.env'); }
|
|
11
|
+
|
|
12
|
+
function randomHex(bytes) { return crypto.randomBytes(bytes).toString('hex'); }
|
|
13
|
+
|
|
14
|
+
function readEnv(projectDir) {
|
|
15
|
+
if (!exists(envPath(projectDir))) return {};
|
|
16
|
+
const out = {};
|
|
17
|
+
for (const line of readText(envPath(projectDir)).split(/\r?\n/)) {
|
|
18
|
+
const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
19
|
+
if (match) out[match[1]] = match[2];
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeEnv(projectDir, env) {
|
|
25
|
+
writeText(envPath(projectDir), `${Object.entries(env).map(([key, value]) => `${key}=${value}`).join('\n')}\n`);
|
|
26
|
+
fs.chmodSync(envPath(projectDir), 0o600);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sanitizeProjectName(raw, fallback = 'draftgo') {
|
|
30
|
+
const value = String(raw || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^[-_]+/, '');
|
|
31
|
+
return value || fallback;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function dockerHost(host) {
|
|
35
|
+
return host === 'localhost' || host === '127.0.0.1' ? 'host.docker.internal' : host;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function generate(projectDir, opts) {
|
|
39
|
+
const root = stackDir(projectDir);
|
|
40
|
+
if (exists(composePath(projectDir)) && readText(composePath(projectDir)).includes('./data/storage:/app/data/storage')) {
|
|
41
|
+
throw new Error('Existing runtime uses legacy storage. Back up and migrate its data to the final DraftGo /app/data layout before regenerating Compose; existing files were preserved.');
|
|
42
|
+
}
|
|
43
|
+
ensureDir(root);
|
|
44
|
+
const env = readEnv(projectDir);
|
|
45
|
+
const projectName = sanitizeProjectName(opts.projectName, 'draftgo');
|
|
46
|
+
const database = opts.mysql.database;
|
|
47
|
+
const qdrant = opts.qdrant || { host: '127.0.0.1', port: 26333, apiKey: '' };
|
|
48
|
+
|
|
49
|
+
env.APP_PORT = String(opts.appPort || 7777);
|
|
50
|
+
env.SECRET_KEY = env.SECRET_KEY || randomHex(32);
|
|
51
|
+
env.SYSTEM_CONFIG_MASTER_KEY = env.SYSTEM_CONFIG_MASTER_KEY || randomHex(32);
|
|
52
|
+
env.DATABASE_URL = `mysql://${encodeURIComponent(opts.mysql.user)}:${encodeURIComponent(opts.mysql.password)}@${dockerHost(opts.mysql.host)}:${opts.mysql.port}/${encodeURIComponent(database)}`;
|
|
53
|
+
env.REDIS_URL = 'redis://redis:6379/0';
|
|
54
|
+
for (const key of ['REDIS_HOST', 'REDIS_PORT', 'REDIS_PASSWORD', 'REDIS_KEY_PREFIX']) delete env[key];
|
|
55
|
+
env.QDRANT_URL = qdrant ? `http://${dockerHost(qdrant.host)}:${qdrant.port}` : '';
|
|
56
|
+
env.QDRANT_API_KEY = qdrant ? (qdrant.apiKey || '') : '';
|
|
57
|
+
env.QDRANT_COLLECTION_PREFIX = database;
|
|
58
|
+
|
|
59
|
+
const yaml = [
|
|
60
|
+
'# Generated by draftgo-cli. MySQL and Qdrant are shared; Redis is project-isolated.',
|
|
61
|
+
`name: ${projectName}`,
|
|
62
|
+
'services:',
|
|
63
|
+
' app:',
|
|
64
|
+
` image: ${opts.image || 'cabinai/draftgo:latest'}`,
|
|
65
|
+
` container_name: ${projectName}`,
|
|
66
|
+
' restart: unless-stopped',
|
|
67
|
+
' ports:',
|
|
68
|
+
' - "${APP_PORT}:3000"',
|
|
69
|
+
' environment:',
|
|
70
|
+
' APP_ENV: production',
|
|
71
|
+
' APP_HOST: 0.0.0.0',
|
|
72
|
+
' APP_PORT: "3000"',
|
|
73
|
+
' PUBLIC_BASE_URL: http://localhost:${APP_PORT}',
|
|
74
|
+
' DATABASE_URL: ${DATABASE_URL}',
|
|
75
|
+
' SECRET_KEY: ${SECRET_KEY}',
|
|
76
|
+
' SYSTEM_CONFIG_MASTER_KEY: ${SYSTEM_CONFIG_MASTER_KEY}',
|
|
77
|
+
' REDIS_URL: ${REDIS_URL}',
|
|
78
|
+
' QDRANT_URL: ${QDRANT_URL}',
|
|
79
|
+
' QDRANT_API_KEY: ${QDRANT_API_KEY}',
|
|
80
|
+
' QDRANT_COLLECTION_PREFIX: ${QDRANT_COLLECTION_PREFIX}',
|
|
81
|
+
' FILE_STORAGE_ROOT: /app/data/files',
|
|
82
|
+
' volumes:',
|
|
83
|
+
' - app_data:/app/data',
|
|
84
|
+
' healthcheck:',
|
|
85
|
+
' test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/api/system/health"]',
|
|
86
|
+
' interval: 10s',
|
|
87
|
+
' timeout: 5s',
|
|
88
|
+
' start_period: 10s',
|
|
89
|
+
' retries: 6',
|
|
90
|
+
' depends_on:',
|
|
91
|
+
' redis:',
|
|
92
|
+
' condition: service_healthy',
|
|
93
|
+
' extra_hosts:',
|
|
94
|
+
' - "host.docker.internal:host-gateway"',
|
|
95
|
+
' redis:',
|
|
96
|
+
' image: redis:7.4-alpine',
|
|
97
|
+
' restart: unless-stopped',
|
|
98
|
+
' command: ["redis-server", "--appendonly", "yes"]',
|
|
99
|
+
' volumes:',
|
|
100
|
+
' - redis_data:/data',
|
|
101
|
+
' healthcheck:',
|
|
102
|
+
' test: ["CMD", "redis-cli", "ping"]',
|
|
103
|
+
' interval: 5s',
|
|
104
|
+
' timeout: 5s',
|
|
105
|
+
' retries: 20',
|
|
106
|
+
'volumes:',
|
|
107
|
+
' app_data:',
|
|
108
|
+
' redis_data:',
|
|
109
|
+
'',
|
|
110
|
+
].join('\n');
|
|
111
|
+
writeText(composePath(projectDir), yaml);
|
|
112
|
+
writeEnv(projectDir, env);
|
|
113
|
+
return { dir: root, composeFile: composePath(projectDir), envFile: envPath(projectDir), projectName, env };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
stackDir, composePath, envPath,
|
|
118
|
+
generate, sanitizeProjectName, randomHex,
|
|
119
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Environment detection helpers for the local stack setup wizard.
|
|
4
|
+
// - probePort: TCP probe (used to find existing MySQL/Redis)
|
|
5
|
+
// - probeHttp: HTTP GET probe with retry (used to wait for the app)
|
|
6
|
+
// - detectDocker: Find a working `docker` + `docker compose` (or `docker-compose`)
|
|
7
|
+
|
|
8
|
+
const net = require('net');
|
|
9
|
+
const http = require('http');
|
|
10
|
+
const { spawnSync } = require('child_process');
|
|
11
|
+
|
|
12
|
+
function probePort(host, port, timeoutMs = 800) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const socket = new net.Socket();
|
|
15
|
+
let done = false;
|
|
16
|
+
const finish = (ok) => {
|
|
17
|
+
if (done) return;
|
|
18
|
+
done = true;
|
|
19
|
+
try { socket.destroy(); } catch {}
|
|
20
|
+
resolve(ok);
|
|
21
|
+
};
|
|
22
|
+
socket.setTimeout(timeoutMs);
|
|
23
|
+
socket.once('connect', () => finish(true));
|
|
24
|
+
socket.once('timeout', () => finish(false));
|
|
25
|
+
socket.once('error', () => finish(false));
|
|
26
|
+
try { socket.connect(port, host); } catch { finish(false); }
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function probeHttpOnce(url, timeoutMs = 2000) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
try {
|
|
33
|
+
const req = http.get(url, { timeout: timeoutMs }, (res) => {
|
|
34
|
+
res.resume();
|
|
35
|
+
resolve(res.statusCode >= 200 && res.statusCode < 300);
|
|
36
|
+
});
|
|
37
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
38
|
+
req.on('error', () => resolve(false));
|
|
39
|
+
} catch {
|
|
40
|
+
resolve(false);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function probeHttp(url, { totalMs = 90000, intervalMs = 1500 } = {}) {
|
|
46
|
+
const start = Date.now();
|
|
47
|
+
while (Date.now() - start < totalMs) {
|
|
48
|
+
if (await probeHttpOnce(url)) return true;
|
|
49
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function tryCmd(cmd, args) {
|
|
55
|
+
try {
|
|
56
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', shell: false });
|
|
57
|
+
return r.status === 0;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function detectDocker() {
|
|
64
|
+
const hasDocker = tryCmd('docker', ['--version']);
|
|
65
|
+
if (!hasDocker) return { ok: false, reason: 'docker-missing' };
|
|
66
|
+
if (!tryCmd('docker', ['info', '--format', '{{.ServerVersion}}'])) return { ok: false, reason: 'docker-not-running' };
|
|
67
|
+
// Prefer `docker compose` (plugin); fall back to legacy `docker-compose`.
|
|
68
|
+
if (tryCmd('docker', ['compose', 'version'])) {
|
|
69
|
+
return { ok: true, composeCmd: 'docker', composeArgs: ['compose'] };
|
|
70
|
+
}
|
|
71
|
+
if (tryCmd('docker-compose', ['version'])) {
|
|
72
|
+
return { ok: true, composeCmd: 'docker-compose', composeArgs: [] };
|
|
73
|
+
}
|
|
74
|
+
return { ok: false, reason: 'compose-missing' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { probePort, probeHttp, probeHttpOnce, detectDocker };
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const log = require('../logger');
|
|
6
|
+
const { askRequired, askPassword, confirm } = require('../prompt');
|
|
7
|
+
const { probePort, probeHttp, detectDocker } = require('./detect');
|
|
8
|
+
const compose = require('./compose');
|
|
9
|
+
const { ensureDatabase, testConnection, describeClient } = require('./mysqlClient');
|
|
10
|
+
const { defaults, portOpen, probeQdrant, startService } = require('./services');
|
|
11
|
+
const { writeProjectConfig } = require('../projectConfig');
|
|
12
|
+
const { appendGitignoreLine, exists, readText } = require('../fsx');
|
|
13
|
+
|
|
14
|
+
const DEFAULT_APP_PORT = 7777;
|
|
15
|
+
|
|
16
|
+
async function promptMysqlConnection() {
|
|
17
|
+
while (true) {
|
|
18
|
+
const host = await askRequired(' MySQL host', { default: '127.0.0.1' });
|
|
19
|
+
const port = Number(await askRequired(' MySQL port', { default: '3306' }));
|
|
20
|
+
const user = await askRequired(' MySQL user', { default: 'draftgo' });
|
|
21
|
+
const password = await askPassword(' MySQL password', { default: 'draftgo' });
|
|
22
|
+
const conn = { host, port, user, password };
|
|
23
|
+
const result = testConnection(conn);
|
|
24
|
+
if (result.ok) return conn;
|
|
25
|
+
log.err(` MySQL connection failed: ${(result.detail || result.reason || 'unknown error').split('\n')[0]}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function ensureProjectDatabase(conn, database, interactive = true) {
|
|
30
|
+
while (true) {
|
|
31
|
+
const result = await ensureDatabase({ ...conn, database }, {
|
|
32
|
+
rootPromptFn: async () => {
|
|
33
|
+
if (!interactive) return null;
|
|
34
|
+
log.warn(` ${conn.user} cannot create database ${database}.`);
|
|
35
|
+
if (!await confirm(' Create it with another MySQL account?', { default: true })) return null;
|
|
36
|
+
return {
|
|
37
|
+
user: await askRequired(' MySQL admin user', { default: 'root' }),
|
|
38
|
+
password: await askPassword(' MySQL admin password'),
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
if (result.ok) return { ...conn, database };
|
|
43
|
+
log.err(` Database setup failed: ${(result.detail || result.reason || 'unknown error').split('\n')[0]}`);
|
|
44
|
+
if (!interactive) throw new Error('Database setup needs credentials. Run `draftgo local setup` in a terminal to continue.');
|
|
45
|
+
const retry = await confirm(' Re-enter MySQL connection settings?', { default: true });
|
|
46
|
+
if (!retry) throw new Error('MySQL database was not prepared.');
|
|
47
|
+
conn = await promptMysqlConnection();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function planMysql(docker, defaultDatabase, interactive = true) {
|
|
52
|
+
log.step('Checking MySQL');
|
|
53
|
+
let conn = { ...defaults.mysql };
|
|
54
|
+
if (!await portOpen(conn.host, conn.port)) {
|
|
55
|
+
log.dim(' MySQL is not running locally; starting the shared local service.');
|
|
56
|
+
if (!startService(docker, 'mysql')) throw new Error('Unable to start shared MySQL.');
|
|
57
|
+
}
|
|
58
|
+
if (!testConnection(conn).ok) {
|
|
59
|
+
log.warn(' Default MySQL credentials were not accepted.');
|
|
60
|
+
if (!interactive) throw new Error('MySQL needs credentials. Run `draftgo local setup` in a terminal to continue.');
|
|
61
|
+
conn = await promptMysqlConnection();
|
|
62
|
+
}
|
|
63
|
+
const database = interactive ? await askRequired(' Project database name', { default: defaultDatabase }) : defaultDatabase;
|
|
64
|
+
const out = await ensureProjectDatabase(conn, database, interactive);
|
|
65
|
+
log.ok(` MySQL ready: ${out.host}:${out.port}/${out.database} (${describeClient()})`);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function promptQdrantConnection() {
|
|
70
|
+
while (true) {
|
|
71
|
+
const host = await askRequired(' Qdrant host', { default: '127.0.0.1' });
|
|
72
|
+
const port = Number(await askRequired(' Qdrant port', { default: '26333' }));
|
|
73
|
+
const apiKey = await askPassword(' Qdrant API key (empty for none)', { default: '' });
|
|
74
|
+
const conn = { host, port, apiKey };
|
|
75
|
+
if (await probeQdrant(conn)) return conn;
|
|
76
|
+
log.err(' Qdrant REST health check failed. Check the address and API key.');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function planQdrant(docker, interactive = true) {
|
|
81
|
+
log.step('Checking Qdrant');
|
|
82
|
+
let conn = { ...defaults.qdrant };
|
|
83
|
+
if (!await portOpen(conn.host, conn.port)) {
|
|
84
|
+
log.dim(' Qdrant is not running locally; starting the shared local service.');
|
|
85
|
+
if (!startService(docker, 'qdrant')) throw new Error('Unable to start shared Qdrant.');
|
|
86
|
+
}
|
|
87
|
+
if (!await probeQdrant(conn)) {
|
|
88
|
+
log.warn(' Default unauthenticated Qdrant REST health check failed.');
|
|
89
|
+
if (!interactive) throw new Error('Qdrant needs credentials. Run `draftgo local setup` in a terminal to continue.');
|
|
90
|
+
conn = await promptQdrantConnection();
|
|
91
|
+
}
|
|
92
|
+
log.ok(` Qdrant ready: ${conn.host}:${conn.port}`);
|
|
93
|
+
return conn;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function pickAppPort(interactive = true) {
|
|
97
|
+
if (!interactive) {
|
|
98
|
+
for (let port = DEFAULT_APP_PORT; port < DEFAULT_APP_PORT + 100; port += 1) {
|
|
99
|
+
if (!await probePort('127.0.0.1', port)) return port;
|
|
100
|
+
}
|
|
101
|
+
throw new Error('No free local port found. Run `draftgo local setup` in a terminal to select a port.');
|
|
102
|
+
}
|
|
103
|
+
const taken = await probePort('127.0.0.1', DEFAULT_APP_PORT);
|
|
104
|
+
const suggested = String(taken ? DEFAULT_APP_PORT + 1 : DEFAULT_APP_PORT);
|
|
105
|
+
while (true) {
|
|
106
|
+
const port = Number(await askRequired(' DraftGo host port', { default: suggested }));
|
|
107
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) continue;
|
|
108
|
+
if (!await probePort('127.0.0.1', port)) return port;
|
|
109
|
+
if (await confirm(` Port ${port} is in use. Use it anyway?`, { default: false })) return port;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function pickProjectName() {
|
|
114
|
+
while (true) {
|
|
115
|
+
const raw = await askRequired(' Project container prefix', { default: 'draftgo' });
|
|
116
|
+
const clean = compose.sanitizeProjectName(raw, '');
|
|
117
|
+
if (clean) return clean;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function composeUp(docker, dir) {
|
|
122
|
+
const result = spawnSync(docker.composeCmd, [...docker.composeArgs, '-f', 'docker-compose.yaml', 'up', '-d'], {
|
|
123
|
+
cwd: dir, stdio: 'inherit', shell: false,
|
|
124
|
+
});
|
|
125
|
+
return result.status === 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function waitForApp(appPort) {
|
|
129
|
+
const ok = await probeHttp(`http://127.0.0.1:${appPort}/api/system/health`, { totalMs: 120000, intervalMs: 2000 });
|
|
130
|
+
if (!ok) log.err(' Timed out waiting for DraftGo. Run `draftgo local logs` to inspect the app.');
|
|
131
|
+
return ok;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function finishMcpSetup(projectDir, mcpCommands) {
|
|
135
|
+
const commands = mcpCommands || require('../commands/mcp');
|
|
136
|
+
const setupMcp = commands.setupMcp || commands.setup;
|
|
137
|
+
const testMcp = commands.testMcp || commands.test;
|
|
138
|
+
if (typeof setupMcp !== 'function' || typeof testMcp !== 'function') {
|
|
139
|
+
log.warn(' MCP setup helpers are unavailable. Run `draftgo mcp setup` after updating the CLI.');
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let setupOk = false;
|
|
144
|
+
try {
|
|
145
|
+
setupOk = await setupMcp(projectDir, [], { yes: true }) === 0;
|
|
146
|
+
} catch {
|
|
147
|
+
log.warn(' MCP host setup failed. Retry with `draftgo mcp setup`.');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let testOk = false;
|
|
151
|
+
try {
|
|
152
|
+
testOk = await testMcp(projectDir, {}) === 0;
|
|
153
|
+
} catch {
|
|
154
|
+
log.warn(' MCP validation failed. Run `draftgo mcp test` for diagnostics.');
|
|
155
|
+
}
|
|
156
|
+
if (!testOk) {
|
|
157
|
+
log.dim(' The local stack and project config are ready; MCP may still be starting.');
|
|
158
|
+
}
|
|
159
|
+
return setupOk && testOk;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function runWizard(projectDir, { yes = false, mcpCommands, interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !yes, params = {} } = {}) {
|
|
163
|
+
log.title('draftgo local setup');
|
|
164
|
+
if (!yes && !interactive) throw new Error('Run `draftgo local setup --yes` for unattended setup, or use a terminal for guided setup.');
|
|
165
|
+
if (!yes && !await confirm('Continue with local DraftGo setup?', { default: true })) return 0;
|
|
166
|
+
|
|
167
|
+
const docker = detectDocker();
|
|
168
|
+
if (!docker.ok) {
|
|
169
|
+
throw new Error(docker.reason === 'compose-missing'
|
|
170
|
+
? 'Install Docker Compose: https://docs.docker.com/compose/install/ then retry `draftgo local setup`.'
|
|
171
|
+
: docker.reason === 'docker-not-running'
|
|
172
|
+
? 'Start Docker Desktop or the Docker daemon, then retry `draftgo local setup`. Installed Skills are preserved.'
|
|
173
|
+
: 'Install Docker: https://docs.docker.com/get-started/get-docker/ then start it and retry `draftgo local setup`. Installed Skills are preserved.');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (exists(compose.composePath(projectDir))) {
|
|
177
|
+
if (readText(compose.composePath(projectDir)).includes('./data/storage:/app/data/storage')) {
|
|
178
|
+
throw new Error('Existing runtime uses legacy storage. Back up and migrate it to final DraftGo before setup; Compose and data were preserved.');
|
|
179
|
+
}
|
|
180
|
+
log.dim(' Existing project runtime preserved. Use `draftgo local start` to resume it, then `draftgo connect` to bind your API Key.');
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
const appPort = params.appPort || await pickAppPort(interactive);
|
|
184
|
+
const projectName = params.projectName || (interactive ? await pickProjectName() : compose.sanitizeProjectName(path.basename(projectDir)));
|
|
185
|
+
const mysql = params.mysql || await planMysql(docker, projectName, interactive);
|
|
186
|
+
const qdrant = params.qdrant === false ? null : (params.qdrant || await planQdrant(docker, interactive));
|
|
187
|
+
|
|
188
|
+
const out = compose.generate(projectDir, { projectName, appPort, mysql, qdrant, image: params.image });
|
|
189
|
+
appendGitignoreLine(projectDir, '.draftgo/docker/');
|
|
190
|
+
if (!composeUp(docker, out.dir) || !await waitForApp(appPort)) return 1;
|
|
191
|
+
|
|
192
|
+
const url = `http://localhost:${appPort}`;
|
|
193
|
+
log.ok(`DraftGo runtime is ready at ${url}`);
|
|
194
|
+
log.dim(' Open the site, register or sign in, and obtain your personal API Key.');
|
|
195
|
+
if (!interactive) {
|
|
196
|
+
log.dim(` Connection pending. Run \`draftgo connect --server ${url}\` in a terminal to enter your API Key.`);
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
let token = '';
|
|
200
|
+
while (token.length < 10) {
|
|
201
|
+
token = String(await askPassword('Paste a DraftGo user API Key')).trim();
|
|
202
|
+
if (token.length < 10) log.err(' API Key appears too short. Please try again.');
|
|
203
|
+
}
|
|
204
|
+
const cfgPath = writeProjectConfig(projectDir, url, token);
|
|
205
|
+
if (!await finishMcpSetup(projectDir, mcpCommands)) return 1;
|
|
206
|
+
log.ok(`DraftGo is ready at ${url}`);
|
|
207
|
+
log.dim(`Project config: ${cfgPath}`);
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = { finishMcpSetup, runWizard };
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Lightweight MySQL helper used by the local stack wizard to validate
|
|
4
|
+
// credentials and auto-create the project database when missing.
|
|
5
|
+
//
|
|
6
|
+
// We avoid taking a JS MySQL driver as a dependency. Instead we shell out
|
|
7
|
+
// to one of:
|
|
8
|
+
// - a native `mysql` client on PATH (preferred — fast, no pull)
|
|
9
|
+
// - a dockerised `mysql:8.0` client (always available because the wizard
|
|
10
|
+
// already requires Docker; first call may have to pull the image)
|
|
11
|
+
//
|
|
12
|
+
// Passwords are passed via the `MYSQL_PWD` env var so they don't show up
|
|
13
|
+
// in process listings.
|
|
14
|
+
|
|
15
|
+
const { spawnSync } = require('child_process');
|
|
16
|
+
const log = require('../logger');
|
|
17
|
+
|
|
18
|
+
function hasNativeMysql() {
|
|
19
|
+
try {
|
|
20
|
+
const r = spawnSync('mysql', ['--version'], { encoding: 'utf8', shell: false });
|
|
21
|
+
return r.status === 0;
|
|
22
|
+
} catch { return false; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hasDocker() {
|
|
26
|
+
try {
|
|
27
|
+
const r = spawnSync('docker', ['version', '--format', '{{.Client.Version}}'], { encoding: 'utf8', shell: false });
|
|
28
|
+
return r.status === 0;
|
|
29
|
+
} catch { return false; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pickClient() {
|
|
33
|
+
if (hasNativeMysql()) return { kind: 'native' };
|
|
34
|
+
if (hasDocker()) return { kind: 'docker' };
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Rewrite localhost/127.0.0.1 to host.docker.internal when running inside
|
|
39
|
+
// a container — the host MySQL is on the host network, not the bridge.
|
|
40
|
+
function dockerHost(host) {
|
|
41
|
+
return (host === 'localhost' || host === '127.0.0.1') ? 'host.docker.internal' : host;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runSQL(client, conn, sql) {
|
|
45
|
+
const env = { ...process.env, MYSQL_PWD: conn.password || '' };
|
|
46
|
+
if (client.kind === 'native') {
|
|
47
|
+
const args = [
|
|
48
|
+
'-h', conn.host,
|
|
49
|
+
'-P', String(conn.port),
|
|
50
|
+
'-u', conn.user,
|
|
51
|
+
'--protocol=TCP',
|
|
52
|
+
'--connect-timeout=5',
|
|
53
|
+
'-N', '-B',
|
|
54
|
+
'-e', sql,
|
|
55
|
+
];
|
|
56
|
+
const r = spawnSync('mysql', args, { encoding: 'utf8', env, shell: false });
|
|
57
|
+
return { ok: r.status === 0, stdout: r.stdout || '', stderr: r.stderr || '', code: r.status };
|
|
58
|
+
}
|
|
59
|
+
// docker variant
|
|
60
|
+
const args = [
|
|
61
|
+
'run', '--rm', '-i',
|
|
62
|
+
'--add-host', 'host.docker.internal:host-gateway',
|
|
63
|
+
'-e', 'MYSQL_PWD',
|
|
64
|
+
'mysql:8.0',
|
|
65
|
+
'mysql',
|
|
66
|
+
'-h', dockerHost(conn.host),
|
|
67
|
+
'-P', String(conn.port),
|
|
68
|
+
'-u', conn.user,
|
|
69
|
+
'--protocol=TCP',
|
|
70
|
+
'--connect-timeout=5',
|
|
71
|
+
'-N', '-B',
|
|
72
|
+
'-e', sql,
|
|
73
|
+
];
|
|
74
|
+
const r = spawnSync('docker', args, { encoding: 'utf8', env, shell: false });
|
|
75
|
+
return { ok: r.status === 0, stdout: r.stdout || '', stderr: r.stderr || '', code: r.status };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function testConnection(conn) {
|
|
79
|
+
const client = pickClient();
|
|
80
|
+
if (!client) return { ok: false, reason: 'no-client' };
|
|
81
|
+
const probe = runSQL(client, conn, 'SELECT 1');
|
|
82
|
+
if (probe.ok) return { ok: true };
|
|
83
|
+
const why = classifyError(probe.stderr);
|
|
84
|
+
return { ok: false, reason: why === 'auth' ? 'auth' : 'unreachable', detail: probe.stderr.trim() };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function classifyError(stderr) {
|
|
88
|
+
const s = (stderr || '').toLowerCase();
|
|
89
|
+
if (s.includes('access denied')) return 'auth';
|
|
90
|
+
if (s.includes("can't connect") || s.includes('connection refused') || s.includes('unknown server host') || s.includes('timed out')) return 'unreachable';
|
|
91
|
+
if (s.includes('access denied for user') && s.includes("to database")) return 'no-create-privilege';
|
|
92
|
+
return 'other';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Escape a db identifier so it can be embedded in a backticked identifier.
|
|
96
|
+
function escIdent(name) {
|
|
97
|
+
return String(name).replace(/`/g, '``');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Public API ----------------------------------------------------------------
|
|
101
|
+
//
|
|
102
|
+
// ensureDatabase(conn, { rootPromptFn }) returns one of:
|
|
103
|
+
// { ok: true, created: bool, mode: 'existed' | 'created' | 'created-as-root' }
|
|
104
|
+
// { ok: false, reason: 'no-client' | 'auth' | 'unreachable' | 'create-failed', detail }
|
|
105
|
+
//
|
|
106
|
+
// `rootPromptFn` (optional) is called when the user's account lacks CREATE
|
|
107
|
+
// privilege; it should resolve to `{ user, password }` (or null to abort).
|
|
108
|
+
async function ensureDatabase(conn, { rootPromptFn } = {}) {
|
|
109
|
+
const client = pickClient();
|
|
110
|
+
const connected = testConnection(conn);
|
|
111
|
+
if (!client || !connected.ok) return connected;
|
|
112
|
+
|
|
113
|
+
// 2) Does the database already exist?
|
|
114
|
+
const showSQL = `SHOW DATABASES LIKE '${String(conn.database).replace(/'/g, "''")}'`;
|
|
115
|
+
const show = runSQL(client, conn, showSQL);
|
|
116
|
+
if (show.ok && show.stdout.trim()) {
|
|
117
|
+
return { ok: true, created: false, mode: 'existed' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 3) Try to create as the supplied user.
|
|
121
|
+
const createSQL = `CREATE DATABASE IF NOT EXISTS \`${escIdent(conn.database)}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`;
|
|
122
|
+
const create = runSQL(client, conn, createSQL);
|
|
123
|
+
if (create.ok) return { ok: true, created: true, mode: 'created' };
|
|
124
|
+
|
|
125
|
+
// 4) Fall back to root if available (privilege issue).
|
|
126
|
+
if (typeof rootPromptFn === 'function' && /denied/i.test(create.stderr || '')) {
|
|
127
|
+
const root = await rootPromptFn();
|
|
128
|
+
if (root && root.user && root.password !== undefined) {
|
|
129
|
+
const rootConn = { ...conn, user: root.user, password: root.password };
|
|
130
|
+
const probeRoot = runSQL(client, rootConn, 'SELECT 1');
|
|
131
|
+
if (!probeRoot.ok) {
|
|
132
|
+
return { ok: false, reason: 'auth', detail: probeRoot.stderr.trim() };
|
|
133
|
+
}
|
|
134
|
+
const createRoot = runSQL(client, rootConn, createSQL);
|
|
135
|
+
if (!createRoot.ok) {
|
|
136
|
+
return { ok: false, reason: 'create-failed', detail: createRoot.stderr.trim() };
|
|
137
|
+
}
|
|
138
|
+
// Grant privileges on the new DB to the project user so the app can use it.
|
|
139
|
+
const grantSQL =
|
|
140
|
+
`GRANT ALL PRIVILEGES ON \`${escIdent(conn.database)}\`.* TO '${conn.user.replace(/'/g, "''")}'@'%'; FLUSH PRIVILEGES;`;
|
|
141
|
+
runSQL(client, rootConn, grantSQL); // best-effort
|
|
142
|
+
return { ok: true, created: true, mode: 'created-as-root' };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { ok: false, reason: 'create-failed', detail: create.stderr.trim() };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function describeClient() {
|
|
150
|
+
const c = pickClient();
|
|
151
|
+
if (!c) return 'none';
|
|
152
|
+
return c.kind === 'native' ? 'native mysql client' : 'docker mysql:8.0';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { ensureDatabase, testConnection, describeClient };
|