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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +249 -0
  3. package/bin/draftgo.js +9 -0
  4. package/package.json +70 -0
  5. package/resources/project-design/README.md +42 -0
  6. package/resources/skill/SKILL.md +62 -0
  7. package/resources/skill/init/SKILL.md +41 -0
  8. package/resources/skill/manifest.json +35 -0
  9. package/resources/skill/references/ai.md +41 -0
  10. package/resources/skill/references/app-api.md +97 -0
  11. package/resources/skill/references/architecture.md +13 -0
  12. package/resources/skill/references/chat-sdk.md +205 -0
  13. package/resources/skill/references/checkout.md +140 -0
  14. package/resources/skill/references/data.md +49 -0
  15. package/resources/skill/references/db-relations.md +29 -0
  16. package/resources/skill/references/delivery.md +33 -0
  17. package/resources/skill/references/development.md +41 -0
  18. package/resources/skill/references/diagnostics.md +50 -0
  19. package/resources/skill/references/frontend.md +158 -0
  20. package/resources/skill/references/mcp.md +110 -0
  21. package/resources/skill/references/methods.md +143 -0
  22. package/resources/skill/references/modules.md +75 -0
  23. package/resources/skill/references/runtime.md +109 -0
  24. package/resources/skill/references/services.md +32 -0
  25. package/src/apiContractCache.js +120 -0
  26. package/src/cli.js +100 -0
  27. package/src/commandRegistry.js +46 -0
  28. package/src/commands/api.js +244 -0
  29. package/src/commands/apiKey.js +30 -0
  30. package/src/commands/autoPush.js +36 -0
  31. package/src/commands/capabilities.js +100 -0
  32. package/src/commands/check.js +82 -0
  33. package/src/commands/checkout.js +18 -0
  34. package/src/commands/clean.js +72 -0
  35. package/src/commands/commit.js +47 -0
  36. package/src/commands/components.js +554 -0
  37. package/src/commands/conflict.js +30 -0
  38. package/src/commands/conflicts.js +16 -0
  39. package/src/commands/connect.js +91 -0
  40. package/src/commands/delete.js +95 -0
  41. package/src/commands/deploy.js +77 -0
  42. package/src/commands/diff.js +39 -0
  43. package/src/commands/group.js +37 -0
  44. package/src/commands/help.js +190 -0
  45. package/src/commands/init.js +126 -0
  46. package/src/commands/listTargets.js +13 -0
  47. package/src/commands/local.js +79 -0
  48. package/src/commands/map.js +395 -0
  49. package/src/commands/mcp.js +150 -0
  50. package/src/commands/reconcile.js +20 -0
  51. package/src/commands/role.js +31 -0
  52. package/src/commands/status.js +98 -0
  53. package/src/commands/uninstall.js +52 -0
  54. package/src/commands/update.js +79 -0
  55. package/src/commands/verify.js +188 -0
  56. package/src/commands/visualVerify.js +281 -0
  57. package/src/commands/worklog.js +117 -0
  58. package/src/consoleEncoding.js +34 -0
  59. package/src/contractCompatibility.js +65 -0
  60. package/src/detect.js +25 -0
  61. package/src/diffReport.js +106 -0
  62. package/src/fsx.js +67 -0
  63. package/src/index.js +46 -0
  64. package/src/localRuntime/compose.js +119 -0
  65. package/src/localRuntime/detect.js +77 -0
  66. package/src/localRuntime/index.js +211 -0
  67. package/src/localRuntime/mysqlClient.js +155 -0
  68. package/src/localRuntime/services.js +117 -0
  69. package/src/logger.js +37 -0
  70. package/src/mcp/client.js +558 -0
  71. package/src/mcp/hosts.js +520 -0
  72. package/src/mcp/parallel.js +54 -0
  73. package/src/mcp/protocol.js +223 -0
  74. package/src/mcp/stdio.js +300 -0
  75. package/src/mcp/tools.js +51 -0
  76. package/src/paths.js +32 -0
  77. package/src/platforms.js +110 -0
  78. package/src/projectConfig.js +139 -0
  79. package/src/projectDesign.js +19 -0
  80. package/src/projectHealth.js +33 -0
  81. package/src/projectMap.js +220 -0
  82. package/src/prompt.js +94 -0
  83. package/src/releaseInstall.js +105 -0
  84. package/src/runtimeFiles.js +45 -0
  85. package/src/skill.js +295 -0
  86. package/src/targets.js +43 -0
  87. package/src/timeout.js +18 -0
  88. package/src/updateCheck.js +100 -0
  89. package/src/worklog.js +276 -0
  90. package/src/worktree/backend.js +438 -0
  91. package/src/worktree/errors.js +28 -0
  92. package/src/worktree/index.js +751 -0
  93. package/src/worktree/inlineScripts.js +99 -0
  94. package/src/worktree/locks.js +52 -0
  95. package/src/worktree/manifest.js +89 -0
  96. package/src/worktree/status.js +124 -0
  97. package/src/worktree/streams.js +200 -0
  98. package/src/worktree/types.js +103 -0
  99. package/src/worktree/validate.js +37 -0
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ // Single source of truth describing every supported AI-tool target.
4
+ // Each platform receives the complete instruction body, subskills, domain
5
+ // references, and the small scripts directory used for bundled documentation.
6
+ //
7
+ // mainFile : the markdown file the AI tool reads first
8
+ // assetDir : where subskills, references, and scripts are placed
9
+ // skillDir : project-relative path used to substitute {{SKILL_DIR}} in
10
+ // template files (typically === assetDir written with forward
11
+ // slashes so it works in markdown)
12
+ // signals : project-level files/dirs that indicate the tool is in use
13
+ // frontmatter: object → prepended as YAML frontmatter to mainFile; null →
14
+ // no frontmatter
15
+ //
16
+ // For platforms whose convention is a flat .md file (cursor / windsurf /
17
+ // antigravity / copilot / kiro), the main file lives next to a sibling dir
18
+ // that holds the rest of the assets.
19
+
20
+ const SKILL_NAME = 'draftgo';
21
+ const DESCRIPTION =
22
+ 'DraftGo 开发助手。在用户谈及 DraftGo 开发、页面、导航、自定义脚本、bug 修复或同步等场景时激活。';
23
+
24
+ const platforms = [
25
+ {
26
+ name: 'claudecode',
27
+ displayName: 'Claude Code',
28
+ mainFile: '.claude/skills/draftgo/SKILL.md',
29
+ assetDir: '.claude/skills/draftgo',
30
+ skillDir: '.claude/skills/draftgo',
31
+ signals: ['.claude'],
32
+ frontmatter: { name: SKILL_NAME, description: DESCRIPTION },
33
+ },
34
+ {
35
+ name: 'cursor',
36
+ displayName: 'Cursor',
37
+ mainFile: '.cursor/commands/draftgo.md',
38
+ assetDir: '.cursor/commands/draftgo',
39
+ skillDir: '.cursor/commands/draftgo',
40
+ signals: ['.cursor'],
41
+ frontmatter: { name: SKILL_NAME, description: DESCRIPTION },
42
+ },
43
+ {
44
+ name: 'windsurf',
45
+ displayName: 'Windsurf',
46
+ mainFile: '.windsurf/workflows/draftgo.md',
47
+ assetDir: '.windsurf/workflows/draftgo',
48
+ skillDir: '.windsurf/workflows/draftgo',
49
+ signals: ['.windsurf'],
50
+ frontmatter: { name: SKILL_NAME, description: DESCRIPTION },
51
+ },
52
+ {
53
+ name: 'antigravity',
54
+ displayName: 'Antigravity',
55
+ mainFile: '.agent/workflows/draftgo.md',
56
+ assetDir: '.agent/workflows/draftgo',
57
+ skillDir: '.agent/workflows/draftgo',
58
+ signals: ['.agent'],
59
+ frontmatter: { name: SKILL_NAME, description: DESCRIPTION },
60
+ },
61
+ {
62
+ name: 'kiro',
63
+ displayName: 'Kiro',
64
+ mainFile: '.kiro/steering/draftgo.md',
65
+ assetDir: '.kiro/steering/draftgo',
66
+ skillDir: '.kiro/steering/draftgo',
67
+ signals: ['.kiro'],
68
+ frontmatter: { inclusion: 'manual', name: SKILL_NAME, description: DESCRIPTION },
69
+ },
70
+ {
71
+ name: 'copilot',
72
+ displayName: 'GitHub Copilot',
73
+ mainFile: '.github/prompts/draftgo.prompt.md',
74
+ assetDir: '.github/prompts/draftgo',
75
+ skillDir: '.github/prompts/draftgo',
76
+ signals: ['.github/prompts', '.github/copilot-instructions.md'],
77
+ frontmatter: { mode: 'agent', description: DESCRIPTION },
78
+ },
79
+ {
80
+ name: 'codex',
81
+ displayName: 'Codex CLI',
82
+ mainFile: '.codex/skills/draftgo/SKILL.md',
83
+ assetDir: '.codex/skills/draftgo',
84
+ skillDir: '.codex/skills/draftgo',
85
+ signals: ['.codex'],
86
+ frontmatter: { name: SKILL_NAME, description: DESCRIPTION },
87
+ },
88
+ {
89
+ name: 'gemini',
90
+ displayName: 'Gemini CLI',
91
+ mainFile: '.gemini/skills/draftgo/SKILL.md',
92
+ assetDir: '.gemini/skills/draftgo',
93
+ skillDir: '.gemini/skills/draftgo',
94
+ signals: ['.gemini'],
95
+ frontmatter: { name: SKILL_NAME, description: DESCRIPTION },
96
+ },
97
+ {
98
+ name: 'pi',
99
+ displayName: 'Pi',
100
+ mainFile: '.agents/skills/draftgo/SKILL.md',
101
+ assetDir: '.agents/skills/draftgo',
102
+ skillDir: '.agents/skills/draftgo',
103
+ signals: ['.agents/skills', '.pi'],
104
+ frontmatter: { name: SKILL_NAME, description: DESCRIPTION },
105
+ },
106
+ ];
107
+
108
+ const byName = Object.fromEntries(platforms.map((p) => [p.name, p]));
109
+
110
+ module.exports = { platforms, byName, SKILL_NAME, DESCRIPTION };
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { exists, ensureDir, appendGitignoreLine } = require('./fsx');
6
+
7
+ const IGNORE_ENTRIES = [
8
+ '.draftgo/config.json',
9
+ '.draftgo/token',
10
+ '.draftgo/worktree/',
11
+ '.draftgo/conflicts/',
12
+ '.draftgo/api-contract-cache.json',
13
+ '.draftgo/api-contract-cache.json.lock',
14
+ ];
15
+
16
+ function parseHttpUrl(raw) {
17
+ let value = String(raw || '').trim();
18
+ if (!value) return null;
19
+ if (!/^https?:\/\//i.test(value)) value = `http://${value}`;
20
+ const parsed = new URL(value);
21
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password
22
+ || parsed.search || parsed.hash) {
23
+ throw new Error('DraftGo server must be an absolute HTTP(S) URL without credentials, query, or fragment.');
24
+ }
25
+ return parsed;
26
+ }
27
+
28
+ function normalizeServer(raw) {
29
+ const parsed = parseHttpUrl(raw);
30
+ if (!parsed) return '';
31
+ return parsed.toString().replace(/\/+$/, '');
32
+ }
33
+
34
+ function normalizeMcpEndpoint(raw) {
35
+ const parsed = parseHttpUrl(raw);
36
+ return parsed ? parsed.toString() : '';
37
+ }
38
+
39
+ function normalizeConnection(raw) {
40
+ const value = normalizeMcpEndpoint(raw);
41
+ if (!value) return { server: '', mcp_url: '' };
42
+ const parsed = new URL(value);
43
+ const pathname = parsed.pathname.replace(/\/+$/, '');
44
+ if (!pathname.toLowerCase().endsWith('/mcp')) {
45
+ return { server: '', mcp_url: value };
46
+ }
47
+ parsed.pathname = pathname.slice(0, -4) || '/';
48
+ return {
49
+ server: parsed.toString().replace(/\/+$/, ''),
50
+ mcp_url: value,
51
+ };
52
+ }
53
+
54
+ function configPath(projectDir) {
55
+ return path.join(projectDir, '.draftgo', 'config.json');
56
+ }
57
+
58
+ function ensureProjectIgnores(projectDir) {
59
+ for (const entry of IGNORE_ENTRIES) appendGitignoreLine(projectDir, entry);
60
+ }
61
+
62
+ function readExisting(projectDir) {
63
+ const file = configPath(projectDir);
64
+ if (!exists(file)) return {};
65
+ try {
66
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
67
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
68
+ } catch (error) {
69
+ throw new Error(`Invalid .draftgo/config.json: ${error.message}`);
70
+ }
71
+ }
72
+
73
+ function writePrivateJson(file, value) {
74
+ ensureDir(path.dirname(file));
75
+ const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
76
+ const temporary = `${file}.tmp-${suffix}`;
77
+ const backup = `${file}.bak-${suffix}`;
78
+ let backedUp = false;
79
+ try {
80
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
81
+ try { fs.chmodSync(temporary, 0o600); } catch { /* Windows ACLs are inherited. */ }
82
+ const descriptor = fs.openSync(temporary, 'r+');
83
+ try { fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); }
84
+ if (exists(file)) { fs.renameSync(file, backup); backedUp = true; }
85
+ fs.renameSync(temporary, file);
86
+ } catch (error) {
87
+ if (backedUp && !exists(file) && exists(backup)) fs.renameSync(backup, file);
88
+ throw error;
89
+ } finally {
90
+ if (exists(temporary)) fs.rmSync(temporary, { force: true });
91
+ }
92
+ if (exists(backup)) fs.rmSync(backup, { force: true });
93
+ try { fs.chmodSync(file, 0o600); } catch { /* Windows ACLs are inherited. */ }
94
+ }
95
+
96
+ function writeProjectConfig(projectDir, server, token, options = {}) {
97
+ const normalizedServer = normalizeServer(server);
98
+ const apiKey = String(token || '').trim();
99
+ if (!normalizedServer) throw new Error('DraftGo server is required.');
100
+ if (!apiKey) throw new Error('DraftGo API Key is required.');
101
+ const existing = readExisting(projectDir);
102
+ const config = {
103
+ server: normalizedServer,
104
+ token: apiKey,
105
+ auto_push: existing.auto_push === true,
106
+ };
107
+ if (options.mcp_url) config.mcp_url = normalizeMcpEndpoint(options.mcp_url);
108
+ else delete config.mcp_url;
109
+ const file = configPath(projectDir);
110
+ writePrivateJson(file, config);
111
+ ensureProjectIgnores(projectDir);
112
+ return file;
113
+ }
114
+
115
+ function loadProjectConfig(projectDir, { requireToken = true } = {}) {
116
+ const file = configPath(projectDir);
117
+ if (!exists(file)) throw new Error('Missing .draftgo/config.json; run `draftgo connect` first.');
118
+ const config = readExisting(projectDir);
119
+ const server = normalizeServer(config.server);
120
+ const mcpUrl = config.mcp_url ? normalizeMcpEndpoint(config.mcp_url) : '';
121
+ const token = String(config.token || config.sat || '').trim();
122
+ if (!server) throw new Error('DraftGo project config is missing server.');
123
+ if (requireToken && !token) throw new Error('DraftGo project config is missing API Key.');
124
+ const result = { server, token, auto_push: config.auto_push === true, path: file };
125
+ if (mcpUrl) result.mcp_url = mcpUrl;
126
+ else delete result.mcp_url;
127
+ return result;
128
+ }
129
+
130
+ module.exports = {
131
+ IGNORE_ENTRIES,
132
+ configPath,
133
+ normalizeServer,
134
+ normalizeMcpEndpoint,
135
+ normalizeConnection,
136
+ ensureProjectIgnores,
137
+ writeProjectConfig,
138
+ loadProjectConfig,
139
+ };
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function ensureProjectDesign(projectDir) {
7
+ const directory = path.join(projectDir, 'Design');
8
+ fs.mkdirSync(directory, { recursive: true });
9
+ const destination = path.join(directory, 'README.md');
10
+ try {
11
+ fs.copyFileSync(path.join(__dirname, '..', 'resources', 'project-design', 'README.md'), destination, fs.constants.COPYFILE_EXCL);
12
+ return { created: true, path: destination };
13
+ } catch (error) {
14
+ if (error.code === 'EEXIST') return { created: false, path: destination };
15
+ throw error;
16
+ }
17
+ }
18
+
19
+ module.exports = { ensureProjectDesign };
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const runtime = require('./runtimeFiles');
6
+
7
+ const PROTECTED_PREFIXES = ['worktree/', 'conflicts/', 'lessons/', 'tmp/', 'config.json', 'api-contract-cache.json', 'api-contract-cache.json.lock', 'worklog.md', 'story.yaml', '.version', 'runtime-manifest.json'];
8
+ function projectHealth(projectDir) {
9
+ const root = runtime.draftgoRoot(projectDir); const registered = new Set(runtime.load(projectDir).entries.map((entry) => String(entry.path).replace(/\\/g, '/')));
10
+ const summary = { managed_files: 0, unknown_files: 0, temporary_files: 0, artifact_files: 0, total_bytes: 0, reclaimable_bytes: 0, warnings: [] };
11
+ if (!fs.existsSync(root)) return summary;
12
+ const visit = (directory) => {
13
+ for (const item of fs.readdirSync(directory, { withFileTypes: true })) {
14
+ const absolute = path.join(directory, item.name); const relative = path.relative(root, absolute).replace(/\\/g, '/');
15
+ if (item.isDirectory()) {
16
+ if ((/^(.+\/)?(build|dist|node_modules|tmp-build|build-)/i.test(relative)) && directorySize(absolute) > 50 * 1024 * 1024) summary.warnings.push({ code: 'DG-PROJECT-LARGE-BUILD', path: relative });
17
+ visit(absolute); continue;
18
+ }
19
+ if (!item.isFile()) continue;
20
+ const size = fs.statSync(absolute).size; summary.total_bytes += size;
21
+ const managed = registered.has(relative) || PROTECTED_PREFIXES.some((prefix) => relative === prefix || relative.startsWith(prefix));
22
+ if (managed) summary.managed_files += 1; else summary.unknown_files += 1;
23
+ if (relative.startsWith('tmp/')) summary.temporary_files += 1;
24
+ if (relative.startsWith('artifacts/')) summary.artifact_files += 1;
25
+ if (registered.has(relative) || relative.startsWith('tmp/')) summary.reclaimable_bytes += size;
26
+ if (!managed && (/\.exe$/i.test(relative) || (/^[^/]+\.(go|json)$/i.test(relative) && !['config.json', 'runtime-manifest.json'].includes(relative)))) summary.warnings.push({ code: 'DG-PROJECT-UNKNOWN-FILE', path: relative, size });
27
+ if (relative.startsWith('artifacts/') && !registered.has(relative)) summary.warnings.push({ code: 'DG-PROJECT-UNREGISTERED-ARTIFACT', path: relative, size });
28
+ }
29
+ };
30
+ visit(root); return summary;
31
+ }
32
+ function directorySize(directory) { let total = 0; const visit = (current) => { for (const item of fs.readdirSync(current, { withFileTypes: true })) { const absolute = path.join(current, item.name); if (item.isDirectory()) visit(absolute); else if (item.isFile()) total += fs.statSync(absolute).size; if (total > 50 * 1024 * 1024) return; } }; visit(directory); return total; }
33
+ module.exports = { projectHealth };
@@ -0,0 +1,220 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const parse5 = require('parse5');
7
+ const { loadManifest, absolutePath } = require('./worktree/manifest');
8
+ const { mediaType } = require('./worktree/types');
9
+ const { validateInlineScripts } = require('./worktree/inlineScripts');
10
+
11
+ function normalizeRoute(route) {
12
+ if (!route || typeof route !== 'string') return '';
13
+ const clean = route.trim();
14
+ if (!clean || /^https?:\/\//i.test(clean) || clean.startsWith('#') || clean.startsWith('mailto:')) return '';
15
+ return clean.startsWith('/') ? clean : `/${clean}`;
16
+ }
17
+
18
+ function extractRoutes(html) {
19
+ const routes = new Set();
20
+ if (!html) return routes;
21
+ let document;
22
+ try { document = parse5.parse(html); } catch { return routes; }
23
+ const visit = (node) => {
24
+ for (const attribute of node.attrs || []) {
25
+ if (attribute.name === 'href' || attribute.name === 'data-page-route') {
26
+ const route = normalizeRoute(attribute.value);
27
+ if (route) routes.add(route);
28
+ }
29
+ }
30
+ for (const child of node.childNodes || []) visit(child);
31
+ };
32
+ visit(document);
33
+ return routes;
34
+ }
35
+
36
+ function extractScriptBindings() {
37
+ return { routes: [], events: [], schedules: [] };
38
+ }
39
+
40
+ function htmlParseErrors(html) {
41
+ const errors = [];
42
+ try {
43
+ parse5.parse(html, { onParseError(error) {
44
+ if (error.code !== 'missing-doctype') errors.push({
45
+ code: error.code || 'html-parse-error', line: error.startLine || null, column: error.startCol || null,
46
+ });
47
+ } });
48
+ } catch (error) {
49
+ errors.push({ code: error.message || 'html-parse-error', line: null, column: null });
50
+ }
51
+ return errors;
52
+ }
53
+
54
+ function sha256File(file) {
55
+ const hash = crypto.createHash('sha256');
56
+ hash.update(fs.readFileSync(file));
57
+ return hash.digest('hex');
58
+ }
59
+
60
+ function decodeTextFile(file, contentType) {
61
+ const match = String(contentType || '').match(/;\s*charset\s*=\s*[']?([^;'\s]+)/i);
62
+ const charset = match ? match[1] : 'utf-8';
63
+ return new TextDecoder(charset, { fatal: true }).decode(fs.readFileSync(file));
64
+ }
65
+
66
+ function classifyPageArea(entry) {
67
+ const route = normalizeRoute(entry.route);
68
+ if (route === '/') return 'home';
69
+ if (/^\/(login|setup|install|register)(\/|$)/i.test(route)) return 'system';
70
+ if (/^\/admin(\/|$)/i.test(route)) return 'admin';
71
+ return route ? 'business' : 'unknown';
72
+ }
73
+
74
+ function collectGroups(pages) {
75
+ const groups = { home: [], admin: [], business: [], system: [], unknown: [] };
76
+ for (const page of pages) groups[page.area || 'unknown'].push(page);
77
+ return groups;
78
+ }
79
+
80
+ function buildProjectMap(projectDir) {
81
+ const manifest = loadManifest(projectDir);
82
+ const checkouts = Object.values(manifest.entries).map((entry) => ({ ...entry }));
83
+ const pages = checkouts.filter((entry) => entry.resource_type === 'pages').map((entry) => ({
84
+ ...entry, id: entry.resource_id, html_file: entry.local_path, area: classifyPageArea(entry),
85
+ }));
86
+ const navigations = checkouts.filter((entry) => entry.resource_type === 'navigations').map((entry) => ({
87
+ ...entry, id: entry.resource_id, html_file: entry.local_path,
88
+ }));
89
+ const docs = checkouts.filter((entry) => entry.resource_type === 'docs').map((entry) => ({
90
+ ...entry, id: entry.resource_id, content_file: entry.local_path,
91
+ }));
92
+
93
+ const routeRefs = new Map();
94
+ for (const entry of [...pages, ...navigations]) {
95
+ const file = absolutePath(projectDir, entry.local_path);
96
+ if (!fs.existsSync(file)) continue;
97
+ let source;
98
+ try { source = decodeTextFile(file, entry.content_type); } catch { continue; }
99
+ for (const route of extractRoutes(source)) {
100
+ if (!routeRefs.has(route)) routeRefs.set(route, new Set());
101
+ routeRefs.get(route).add(`${entry.resource_type}:${entry.resource_id}`);
102
+ }
103
+ }
104
+ return {
105
+ projectDir,
106
+ manifest_path: '.draftgo/worktree/manifest.json',
107
+ checkouts,
108
+ pages,
109
+ navigations,
110
+ docs,
111
+ pageGroups: collectGroups(pages),
112
+ routeRefs: Object.fromEntries([...routeRefs].map(([route, sources]) => [route, [...sources]])),
113
+ remote_only: ['db_meta', 'models', 'prompts', 'plugins', 'knowledge', 'memory', 'agents', 'custom_services', 'system_config', 'roles', 'users', 'doc_categories'],
114
+ };
115
+ }
116
+
117
+ function worktreeFiles(projectDir, directory) {
118
+ const root = path.join(projectDir, '.draftgo', 'worktree', directory);
119
+ if (!fs.existsSync(root)) return [];
120
+ return fs.readdirSync(root, { withFileTypes: true })
121
+ .filter((entry) => entry.isFile())
122
+ .map((entry) => path.join(root, entry.name));
123
+ }
124
+
125
+ function analyzeProject(projectDir, options = {}) {
126
+ const map = buildProjectMap(projectDir);
127
+ const errors = [];
128
+ const warnings = [];
129
+ const warningDetails = [];
130
+ const addWarning = (code, confidence, message) => {
131
+ warnings.push(message);
132
+ warningDetails.push({ code, confidence, message });
133
+ };
134
+ const registered = new Set();
135
+
136
+ const resourceKeys = options.resourceKeys ? new Set(options.resourceKeys) : null;
137
+ const selectedCheckouts = resourceKeys
138
+ ? map.checkouts.filter((entry) => resourceKeys.has(`${entry.resource_type}:${entry.resource_id}`))
139
+ : map.checkouts;
140
+
141
+ for (const entry of selectedCheckouts) {
142
+ let local;
143
+ let base;
144
+ try {
145
+ local = absolutePath(projectDir, entry.local_path);
146
+ base = absolutePath(projectDir, entry.base_path);
147
+ } catch (error) {
148
+ errors.push(`${entry.resource_type} ${entry.resource_id}: ${error.message}`);
149
+ continue;
150
+ }
151
+ registered.add(path.resolve(local).toLowerCase());
152
+ if (!fs.existsSync(local)) {
153
+ errors.push(`${entry.resource_type} ${entry.resource_id}: local content is missing (${entry.local_path}).`);
154
+ }
155
+ if (!fs.existsSync(base)) {
156
+ errors.push(`${entry.resource_type} ${entry.resource_id}: checkout base is missing (${entry.base_path}).`);
157
+ } else {
158
+ const actualBaseHash = sha256File(base);
159
+ if (actualBaseHash !== entry.base_hash) {
160
+ errors.push(`${entry.resource_type} ${entry.resource_id}: checkout base hash does not match the manifest `
161
+ + `(manifest=${entry.base_hash}, base=${actualBaseHash}). `
162
+ + `Run \`draftgo check --remote\` to classify the mismatch before replacing files.`);
163
+ }
164
+ }
165
+
166
+ const type = mediaType(entry.content_type);
167
+ let expectedExtension = entry.file_extension;
168
+ if (type === 'text/html' || type === 'application/xhtml+xml') expectedExtension = '.html';
169
+ else if (type === 'text/markdown' || type === 'text/x-markdown') expectedExtension = '.md';
170
+ else if (type === 'text/plain') expectedExtension = '.txt';
171
+ if (path.extname(entry.local_path).toLowerCase() !== String(expectedExtension).toLowerCase()) {
172
+ errors.push(`${entry.resource_type} ${entry.resource_id}: local extension does not preserve ${entry.content_type}.`);
173
+ }
174
+ if (fs.existsSync(local) && (type === 'text/html' || type === 'application/xhtml+xml')) {
175
+ let source;
176
+ try { source = decodeTextFile(local, entry.content_type); }
177
+ catch (error) {
178
+ errors.push(`${entry.resource_type} ${entry.resource_id}: ${error.message}`);
179
+ continue;
180
+ }
181
+ for (const issue of htmlParseErrors(source).slice(0, 20)) {
182
+ const position = issue.line ? ` at ${issue.line}:${issue.column || 1}` : '';
183
+ addWarning('DG-HTML-001', 'high', `${entry.resource_type} ${entry.resource_id}: ${issue.code}${position}.`);
184
+ }
185
+ for (const issue of validateInlineScripts(source).slice(0, 20)) {
186
+ errors.push(`${entry.resource_type} ${entry.resource_id}: inline script #${issue.script} `
187
+ + `(${issue.script_type}) ${issue.message} at ${entry.local_path}:${issue.line}:${issue.column}.`);
188
+ }
189
+ }
190
+ }
191
+
192
+ if (!resourceKeys) {
193
+ for (const directory of ['pages', 'navigations', 'docs']) {
194
+ for (const file of worktreeFiles(projectDir, directory)) {
195
+ if (!registered.has(path.resolve(file).toLowerCase())) {
196
+ const relative = path.relative(projectDir, file).replace(/\\/g, '/');
197
+ addWarning('DG-WORKTREE-ORPHAN', 'high', `Unregistered worktree file: ${relative}.`);
198
+ }
199
+ }
200
+ }
201
+ }
202
+ for (const page of map.pages.filter((entry) => !resourceKeys || resourceKeys.has(`pages:${entry.resource_id}`))) {
203
+ if (!page.route || page.route === '/' || page.area === 'system') continue;
204
+ const sources = map.routeRefs[normalizeRoute(page.route)] || [];
205
+ if (!sources.some((source) => source.startsWith('navigations:'))) {
206
+ addWarning('DG-ROUTE-001', 'medium',
207
+ `pages ${page.resource_id}: route ${page.route} is not referenced by a checked-out navigation.`);
208
+ }
209
+ }
210
+ return { map, errors, warnings, warningDetails };
211
+ }
212
+
213
+ module.exports = {
214
+ buildProjectMap,
215
+ analyzeProject,
216
+ normalizeRoute,
217
+ extractRoutes,
218
+ extractScriptBindings,
219
+ htmlParseErrors,
220
+ };
package/src/prompt.js ADDED
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+
3
+ // Tiny interactive prompt helpers built on Node's readline.
4
+ // No external deps. All functions return Promises.
5
+
6
+ const readline = require('readline');
7
+
8
+ function makeRL() {
9
+ return readline.createInterface({
10
+ input: process.stdin,
11
+ output: process.stdout,
12
+ terminal: true,
13
+ });
14
+ }
15
+
16
+ function ask(question, { default: def } = {}) {
17
+ return new Promise((resolve) => {
18
+ const rl = makeRL();
19
+ const hint = def !== undefined && def !== '' ? ` [${def}]` : '';
20
+ rl.question(`${question}${hint} `, (answer) => {
21
+ rl.close();
22
+ const v = (answer || '').trim();
23
+ resolve(v === '' && def !== undefined ? String(def) : v);
24
+ });
25
+ });
26
+ }
27
+
28
+ async function askRequired(question, { default: def, validate } = {}) {
29
+ // Loop until user supplies a non-empty value (or default is used).
30
+ // If `validate(v)` returns a string, treat as error message and re-prompt.
31
+ while (true) {
32
+ const v = await ask(question, { default: def });
33
+ if (!v) {
34
+ console.log(' (不能为空,请重新输入)');
35
+ continue;
36
+ }
37
+ if (typeof validate === 'function') {
38
+ const err = validate(v);
39
+ if (typeof err === 'string' && err) {
40
+ console.log(` ${err}`);
41
+ continue;
42
+ }
43
+ }
44
+ return v;
45
+ }
46
+ }
47
+
48
+ async function confirm(question, { default: def = false } = {}) {
49
+ const yn = def ? 'Y/n' : 'y/N';
50
+ const v = (await ask(`${question} (${yn})`)).toLowerCase();
51
+ if (!v) return def;
52
+ return v === 'y' || v === 'yes';
53
+ }
54
+
55
+ async function askPassword(question, { default: def } = {}) {
56
+ // Best-effort masked input. Falls back to plain input if stdin is not a TTY.
57
+ if (!process.stdin.isTTY) return ask(question, { default: def });
58
+ return new Promise((resolve) => {
59
+ const stdin = process.stdin;
60
+ const out = process.stdout;
61
+ const hint = def ? ' [使用上次保存的值]' : '';
62
+ out.write(`${question}${hint} `);
63
+ let buf = '';
64
+ const onData = (ch) => {
65
+ const s = ch.toString('utf8');
66
+ for (const c of s) {
67
+ if (c === '\n' || c === '\r' || c === '\u0004') {
68
+ stdin.setRawMode(false);
69
+ stdin.pause();
70
+ stdin.removeListener('data', onData);
71
+ out.write('\n');
72
+ const v = buf;
73
+ resolve(v === '' && def !== undefined ? String(def) : v);
74
+ return;
75
+ } else if (c === '\u0003') { // Ctrl+C
76
+ process.exit(130);
77
+ } else if (c === '\u007f' || c === '\b') {
78
+ if (buf.length > 0) {
79
+ buf = buf.slice(0, -1);
80
+ out.write('\b \b');
81
+ }
82
+ } else {
83
+ buf += c;
84
+ out.write('*');
85
+ }
86
+ }
87
+ };
88
+ stdin.setRawMode(true);
89
+ stdin.resume();
90
+ stdin.on('data', onData);
91
+ });
92
+ }
93
+
94
+ module.exports = { ask, askRequired, confirm, askPassword };