draftgo-cli 3.0.56 → 4.0.22
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/README.md +169 -297
- package/package.json +12 -7
- package/resources/custom-service-sdk/ai.go +520 -0
- package/resources/custom-service-sdk/ai_test.go +156 -0
- package/resources/custom-service-sdk/billing.go +596 -0
- package/resources/custom-service-sdk/billing_test.go +150 -0
- package/resources/custom-service-sdk/go.mod +3 -0
- package/resources/custom-service-sdk/manifest.json +72 -0
- package/resources/custom-service-sdk/platform.go +360 -0
- package/resources/custom-service-sdk/platform_logger_test.go +24 -0
- package/resources/custom-service-sdk/registration_test.go +39 -0
- package/resources/custom-service-sdk/resources.go +246 -0
- package/resources/custom-service-sdk/resources_billing_test.go +115 -0
- package/resources/custom-service-sdk/resources_files_test.go +57 -0
- package/resources/custom-service-sdk/resources_scope_test.go +87 -0
- package/resources/custom-service-sdk/sdk.go +208 -0
- package/resources/skill/SKILL.md +36 -87
- package/resources/skill/init/SKILL.md +9 -14
- package/resources/skill/manifest.json +6 -2
- package/resources/skill/references/aihub.md +28 -5
- package/resources/skill/references/app-api.md +56 -6
- package/resources/skill/references/architecture.md +2 -2
- package/resources/skill/references/chat-sdk.md +4 -2
- package/resources/skill/references/checkout.md +21 -7
- package/resources/skill/references/custom-services.md +124 -222
- package/resources/skill/references/data.md +22 -6
- package/resources/skill/references/delivery.md +33 -0
- package/resources/skill/references/diagnostics.md +51 -0
- package/resources/skill/references/frontend.md +93 -499
- package/resources/skill/references/mcp.md +65 -101
- package/resources/skill/references/methods.md +189 -0
- package/resources/skill/references/modules.md +36 -8
- package/resources/skill/references/runtime.md +26 -3
- package/resources/skill/story/SKILL.md +1 -2
- package/src/apiContractCache.js +112 -0
- package/src/cli.js +24 -20
- package/src/commandRegistry.js +15 -12
- package/src/commands/api.js +41 -10
- package/src/commands/apiKey.js +34 -0
- package/src/commands/capabilities.js +93 -0
- package/src/commands/check.js +1 -10
- package/src/commands/checkout.js +1 -1
- package/src/commands/commit.js +1 -1
- package/src/commands/components.js +550 -0
- package/src/commands/conflict.js +1 -1
- package/src/commands/connect.js +18 -8
- package/src/commands/customService.js +22 -8
- package/src/commands/dataRange.js +33 -0
- package/src/commands/delete.js +34 -46
- package/src/commands/deploy.js +1 -1
- package/src/commands/diff.js +18 -2
- package/src/commands/grant.js +29 -0
- package/src/commands/group.js +38 -0
- package/src/commands/help.js +80 -51
- package/src/commands/init.js +6 -12
- package/src/commands/listTargets.js +1 -1
- package/src/commands/local.js +2 -6
- package/src/commands/map.js +145 -28
- package/src/commands/mcp.js +2 -2
- package/src/commands/reconcile.js +1 -1
- package/src/commands/role.js +32 -0
- package/src/commands/space.js +41 -0
- package/src/commands/status.js +111 -8
- package/src/commands/uninstall.js +3 -3
- package/src/commands/update.js +24 -12
- package/src/commands/verify.js +118 -21
- package/src/commands/{verifyUi.js → visualVerify.js} +28 -116
- package/src/commands/worklog.js +90 -0
- package/src/consoleEncoding.js +34 -0
- package/src/contractCompatibility.js +57 -0
- package/src/customServices.js +278 -41
- package/src/diffReport.js +106 -0
- package/src/index.js +2 -0
- package/src/{localdev → localRuntime}/compose.js +14 -17
- package/src/{localdev → localRuntime}/detect.js +1 -1
- package/src/{localdev → localRuntime}/index.js +22 -23
- package/src/{localdev → localRuntime}/mysqlClient.js +1 -1
- package/src/{localdev → localRuntime}/services.js +28 -37
- package/src/mcp/client.js +11 -2
- package/src/mcp/protocol.js +2 -2
- package/src/mcp/tools.js +14 -1
- package/src/platforms.js +9 -0
- package/src/projectConfig.js +8 -4
- package/src/releaseInstall.js +105 -0
- package/src/{installers/index.js → targets.js} +3 -5
- package/src/updateCheck.js +48 -28
- package/src/worklog.js +275 -0
- package/src/workspaceHealth.js +1 -1
- package/src/worktree/backend.js +1 -1
- package/src/worktree/index.js +86 -51
- package/src/changelog.js +0 -276
- package/src/commands/changelog.js +0 -24
- package/src/commands/localDev.js +0 -9
- package/src/commands/sync.js +0 -46
- package/src/commands/task.js +0 -408
- package/src/commands/verifyUiCompat.js +0 -16
package/src/commands/verify.js
CHANGED
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
const log = require('../logger');
|
|
4
4
|
const check = require('./check');
|
|
5
|
-
const
|
|
5
|
+
const visualVerify = require('./visualVerify');
|
|
6
6
|
const { canonicalResourceType } = require('../worktree/types');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const parse5 = require('parse5');
|
|
10
|
+
const { loadProjectConfig } = require('../projectConfig');
|
|
11
|
+
const { loadManifest, getEntry, absolutePath } = require('../worktree/manifest');
|
|
7
12
|
|
|
8
13
|
function mode(value, fallback, flag) {
|
|
9
14
|
const normalized = String(value == null ? fallback : value).toLowerCase();
|
|
10
|
-
if (!['
|
|
15
|
+
if (!['always', 'never'].includes(normalized)) throw new Error(`${flag} must be always or never.`);
|
|
11
16
|
return normalized;
|
|
12
17
|
}
|
|
13
18
|
|
|
@@ -21,7 +26,11 @@ function resources(positional) {
|
|
|
21
26
|
|
|
22
27
|
function viewports(flags) {
|
|
23
28
|
const value = String(flags.viewport || '').toLowerCase();
|
|
24
|
-
if (!value) return [{
|
|
29
|
+
if (!value) return [{
|
|
30
|
+
name: flags.width || flags.height ? 'custom' : 'desktop',
|
|
31
|
+
width: flags.width || 1440,
|
|
32
|
+
height: flags.height || 900,
|
|
33
|
+
}];
|
|
25
34
|
if (value === 'mobile') return [{ name: 'mobile', width: 390, height: 844 }];
|
|
26
35
|
if (value === 'desktop') return [{ name: 'desktop', width: 1440, height: 900 }];
|
|
27
36
|
if (value === 'both') return [
|
|
@@ -31,13 +40,96 @@ function viewports(flags) {
|
|
|
31
40
|
throw new Error('--viewport must be mobile, desktop, or both.');
|
|
32
41
|
}
|
|
33
42
|
|
|
43
|
+
function visualRequest(flags, url) {
|
|
44
|
+
const uiMode = mode(flags.ui, 'never', '--ui');
|
|
45
|
+
const screenshotMode = String(flags.screenshot || 'never').toLowerCase();
|
|
46
|
+
if (!['always', 'never'].includes(screenshotMode)) {
|
|
47
|
+
throw new Error('--screenshot must be always or never.');
|
|
48
|
+
}
|
|
49
|
+
const screenshotRequested = screenshotMode !== 'never';
|
|
50
|
+
if ((uiMode === 'always' || screenshotRequested) && !url) {
|
|
51
|
+
throw new Error('Visual verification requires --url <http://localhost:port/path>.');
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
uiMode,
|
|
55
|
+
screenshotMode,
|
|
56
|
+
captureOnly: uiMode === 'never' && screenshotMode === 'always',
|
|
57
|
+
run: Boolean(url) && (uiMode === 'always' || screenshotRequested),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function walkNodes(node, visit) {
|
|
62
|
+
if (!node) return;
|
|
63
|
+
visit(node);
|
|
64
|
+
for (const child of node.childNodes || []) walkNodes(child, visit);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function verifyPageComponents(projectDir, selected) {
|
|
68
|
+
const pages = selected.filter(item => item.resourceType === 'pages');
|
|
69
|
+
if (!pages.length) return [];
|
|
70
|
+
const config = loadProjectConfig(projectDir);
|
|
71
|
+
const manifest = loadManifest(projectDir);
|
|
72
|
+
const uses = [];
|
|
73
|
+
for (const page of pages) {
|
|
74
|
+
const entry = getEntry(manifest, 'pages', page.resourceId);
|
|
75
|
+
if (!entry) continue;
|
|
76
|
+
const file = absolutePath(projectDir, entry.local_path);
|
|
77
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
78
|
+
const document = parse5.parse(source);
|
|
79
|
+
const instances = new Set();
|
|
80
|
+
walkNodes(document, node => {
|
|
81
|
+
const attrs = new Map((node.attrs || []).map(attr => [attr.name, attr.value]));
|
|
82
|
+
const name = attrs.get('data-dg-use');
|
|
83
|
+
if (!name) return;
|
|
84
|
+
if (!/^([a-z][a-z0-9-]*)\/([a-z][a-z0-9-]*)$/.test(name)) throw new Error(`pages ${page.resourceId}: invalid data-dg-use "${name}"`);
|
|
85
|
+
const instance = attrs.get('data-dg-instance');
|
|
86
|
+
if (instance) { if (instances.has(instance)) throw new Error(`pages ${page.resourceId}: duplicate data-dg-instance "${instance}"`); instances.add(instance); }
|
|
87
|
+
uses.push({ page: page.resourceId, node, attrs, name });
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const cache = new Map();
|
|
91
|
+
for (const use of uses) {
|
|
92
|
+
if (!cache.has(use.name)) {
|
|
93
|
+
const slug = use.name.split('/')[1];
|
|
94
|
+
const response = await fetch(`${config.server}/api/components?library=${encodeURIComponent(use.name.split('/')[0])}&q=${encodeURIComponent(slug)}&page_size=100`, { headers: { Authorization: `Bearer ${config.token}` } });
|
|
95
|
+
const payload = await response.json().catch(() => ({}));
|
|
96
|
+
if (!response.ok) throw new Error(`component lookup failed for ${use.name} (${response.status})`);
|
|
97
|
+
const items = payload.data?.items || payload.items || [];
|
|
98
|
+
cache.set(use.name, items.find(item => item.slug === slug && (item.library_slug === use.name.split('/')[0] || item.full_name === use.name)) || null);
|
|
99
|
+
}
|
|
100
|
+
const component = cache.get(use.name);
|
|
101
|
+
if (!component) throw new Error(`pages ${use.page}: component not found ${use.name}`);
|
|
102
|
+
if (!component.published || Number(component.published_revision || 0) < 1) throw new Error(`pages ${use.page}: component is not published ${use.name}`);
|
|
103
|
+
const definition = component.published;
|
|
104
|
+
const rootTag = String(definition.root_tag || '').toLowerCase();
|
|
105
|
+
if (rootTag && String(use.node.nodeName || '').toLowerCase() !== rootTag) throw new Error(`pages ${use.page}: ${use.name} requires <${rootTag}> but found <${use.node.nodeName}>`);
|
|
106
|
+
const props = new Map((definition.props || []).map(prop => [prop.name, prop]));
|
|
107
|
+
for (const [name, value] of use.attrs) {
|
|
108
|
+
if (!name.startsWith('data-dg-prop-')) continue;
|
|
109
|
+
const propName = name.slice('data-dg-prop-'.length);
|
|
110
|
+
const prop = props.get(propName);
|
|
111
|
+
if (!prop) throw new Error(`pages ${use.page}: unknown prop ${name} on ${use.name}`);
|
|
112
|
+
if (prop.type === 'boolean' && !['', 'true', 'false', '1', '0'].includes(String(value).toLowerCase())) {
|
|
113
|
+
throw new Error(`pages ${use.page}: invalid boolean prop ${name} on ${use.name}`);
|
|
114
|
+
}
|
|
115
|
+
if (prop.type === 'number' && !Number.isFinite(Number(value))) {
|
|
116
|
+
throw new Error(`pages ${use.page}: invalid number prop ${name} on ${use.name}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const slots = new Set((definition.slots || []).map(slot => slot.name));
|
|
120
|
+
for (const child of use.node.childNodes || []) {
|
|
121
|
+
const slot = (child.attrs || []).find(attr => attr.name === 'data-dg-slot')?.value;
|
|
122
|
+
if (slot && slots.size && !slots.has(slot)) throw new Error(`pages ${use.page}: unknown slot ${slot} on ${use.name}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return uses;
|
|
126
|
+
}
|
|
127
|
+
|
|
34
128
|
async function verify(projectDir, positional = [], flags = {}) {
|
|
35
129
|
let selected;
|
|
36
|
-
let uiMode;
|
|
37
130
|
let remoteMode;
|
|
38
131
|
try {
|
|
39
132
|
selected = resources(positional);
|
|
40
|
-
uiMode = mode(flags.ui, 'auto', '--ui');
|
|
41
133
|
remoteMode = flags.remote ? 'always' : 'never';
|
|
42
134
|
} catch (error) {
|
|
43
135
|
log.err(error.message);
|
|
@@ -45,6 +137,8 @@ async function verify(projectDir, positional = [], flags = {}) {
|
|
|
45
137
|
}
|
|
46
138
|
|
|
47
139
|
log.title('draftgo verify');
|
|
140
|
+
try { await verifyPageComponents(projectDir, selected); }
|
|
141
|
+
catch (error) { log.err(error.message); return 1; }
|
|
48
142
|
const checkCode = await check(projectDir, [], {
|
|
49
143
|
strict: flags.strict,
|
|
50
144
|
remote: remoteMode === 'always',
|
|
@@ -55,31 +149,32 @@ async function verify(projectDir, positional = [], flags = {}) {
|
|
|
55
149
|
if (checkCode !== 0) return checkCode;
|
|
56
150
|
|
|
57
151
|
const url = String(flags.url || '').trim();
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
152
|
+
let visual;
|
|
153
|
+
try { visual = visualRequest(flags, url); }
|
|
154
|
+
catch (error) { log.err(error.message); return 1; }
|
|
155
|
+
if (!visual.run) {
|
|
156
|
+
if (url) log.warn('--url was provided without --screenshot always or --ui always; visual verification was skipped.');
|
|
64
157
|
log.ok('Unified verification passed (UI skipped).');
|
|
65
158
|
return 0;
|
|
66
159
|
}
|
|
67
|
-
if (selected.length > 1) {
|
|
68
|
-
log.err('One --url can assert at most one resource. Run verify once per route for UI validation.');
|
|
69
|
-
return 1;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
160
|
let targets;
|
|
73
161
|
try { targets = viewports(flags); }
|
|
74
162
|
catch (error) { log.err(error.message); return 1; }
|
|
75
163
|
for (const viewport of targets) {
|
|
76
|
-
const uiFlags = {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
164
|
+
const uiFlags = {
|
|
165
|
+
...flags,
|
|
166
|
+
url,
|
|
167
|
+
width: viewport.width,
|
|
168
|
+
height: viewport.height,
|
|
169
|
+
'capture-only': visual.captureOnly,
|
|
170
|
+
};
|
|
171
|
+
const code = await visualVerify(projectDir, [url], uiFlags);
|
|
81
172
|
if (code !== 0) return code;
|
|
82
173
|
}
|
|
174
|
+
if (visual.captureOnly) {
|
|
175
|
+
log.ok(`Visual capture completed (${targets.map((item) => item.name).join(', ')}).`);
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
83
178
|
log.ok(`Unified verification passed (${targets.map((item) => item.name).join(', ')}).`);
|
|
84
179
|
return 0;
|
|
85
180
|
}
|
|
@@ -88,3 +183,5 @@ module.exports = verify;
|
|
|
88
183
|
module.exports.mode = mode;
|
|
89
184
|
module.exports.resources = resources;
|
|
90
185
|
module.exports.viewports = viewports;
|
|
186
|
+
module.exports.visualRequest = visualRequest;
|
|
187
|
+
module.exports.verifyPageComponents = verifyPageComponents;
|
|
@@ -3,53 +3,8 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const runtimeFiles = require('../runtimeFiles');
|
|
6
|
-
const { spawnSync } = require('child_process');
|
|
7
6
|
const log = require('../logger');
|
|
8
7
|
const { configPath, loadProjectConfig } = require('../projectConfig');
|
|
9
|
-
const { loadManifest, getEntry } = require('../worktree/manifest');
|
|
10
|
-
const { canonicalResourceType } = require('../worktree/types');
|
|
11
|
-
const { resolveMetadata } = require('../worktree/backend');
|
|
12
|
-
const { inspectEntry, openMetadataSession } = require('../worktree/status');
|
|
13
|
-
|
|
14
|
-
const UI_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less', '.html', '.htm', '.jsx', '.tsx', '.vue', '.svelte']);
|
|
15
|
-
|
|
16
|
-
function isUiFile(file) {
|
|
17
|
-
const normalized = String(file || '').replace(/\\/g, '/').toLowerCase();
|
|
18
|
-
const ext = path.extname(normalized);
|
|
19
|
-
if (UI_EXTENSIONS.has(ext)) return true;
|
|
20
|
-
if (!['.js', '.ts'].includes(ext)) return false;
|
|
21
|
-
return /(^|\/)(frontend|web|ui|components|pages|views|client)(\/|$)/.test(normalized)
|
|
22
|
-
|| /(^|\/)(app|main|client)\.(js|ts)$/.test(normalized)
|
|
23
|
-
|| normalized.startsWith('.draftgo/worktree/pages/')
|
|
24
|
-
|| normalized.startsWith('.draftgo/worktree/navigations/')
|
|
25
|
-
|| normalized.startsWith('.draftgo/worktree/docs/');
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function gitChangedFiles(projectDir) {
|
|
29
|
-
const r = spawnSync('git', ['status', '--porcelain=v1', '--untracked-files=all'], {
|
|
30
|
-
cwd: projectDir,
|
|
31
|
-
encoding: 'utf8',
|
|
32
|
-
shell: false,
|
|
33
|
-
});
|
|
34
|
-
if (r.error || r.status !== 0) return null;
|
|
35
|
-
return String(r.stdout || '').split(/\r?\n/).filter(Boolean).map((line) => {
|
|
36
|
-
const raw = line.slice(3).trim();
|
|
37
|
-
const renamed = raw.includes(' -> ') ? raw.split(' -> ').pop() : raw;
|
|
38
|
-
return renamed.replace(/^"|"$/g, '');
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function decideMobileCheck(projectDir, mode) {
|
|
43
|
-
if (mode === 'always') return { run: true, reason: 'mobile-check=always' };
|
|
44
|
-
if (mode === 'never') return { run: false, reason: 'mobile-check=never' };
|
|
45
|
-
const changed = gitChangedFiles(projectDir);
|
|
46
|
-
if (changed === null || changed.length === 0) {
|
|
47
|
-
return { run: true, reason: '无法从 Git diff 确认影响范围,执行单视口 smoke check' };
|
|
48
|
-
}
|
|
49
|
-
const uiFiles = changed.filter(isUiFile);
|
|
50
|
-
if (!uiFiles.length) return { run: false, reason: '本次变更未涉及页面、样式或前端组件' };
|
|
51
|
-
return { run: true, reason: `检测到 ${uiFiles.length} 个 UI 文件变更` };
|
|
52
|
-
}
|
|
53
8
|
|
|
54
9
|
function numberFlag(value, fallback, min, max) {
|
|
55
10
|
const n = Number(value);
|
|
@@ -154,15 +109,6 @@ async function launchBrowser(chromium, requested, requestedPath) {
|
|
|
154
109
|
+ (lastError ? `;最后错误:${lastError.message.split('\n')[0]}` : ''));
|
|
155
110
|
}
|
|
156
111
|
|
|
157
|
-
function parseResourceSpec(value) {
|
|
158
|
-
const raw = String(value || '');
|
|
159
|
-
const separator = raw.indexOf(':');
|
|
160
|
-
if (separator <= 0 || separator === raw.length - 1) {
|
|
161
|
-
throw new Error('--resource 格式必须是 <pages|nav|docs>:<id>。');
|
|
162
|
-
}
|
|
163
|
-
return { resourceType: canonicalResourceType(raw.slice(0, separator)), resourceId: raw.slice(separator + 1) };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
112
|
async function selectFrames(page, mode = 'auto') {
|
|
167
113
|
const top = page.mainFrame ? page.mainFrame() : page;
|
|
168
114
|
const selected = [{ frame: top, label: 'top' }];
|
|
@@ -208,50 +154,33 @@ async function inspectFrame(frame) {
|
|
|
208
154
|
});
|
|
209
155
|
}
|
|
210
156
|
|
|
211
|
-
async function
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
...session,
|
|
219
|
-
clientInitialized: true,
|
|
220
|
-
});
|
|
221
|
-
return inspectEntry(projectDir, entry, remote);
|
|
157
|
+
async function captureScreenshot(projectDir, page) {
|
|
158
|
+
const dir = path.join(projectDir, '.draftgo', 'artifacts', 'ui');
|
|
159
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
160
|
+
const screenshotPath = path.join(dir, `ui-check-${process.pid}-${Date.now()}.png`);
|
|
161
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
162
|
+
runtimeFiles.register(projectDir, screenshotPath, 'ui-artifact', 'verify');
|
|
163
|
+
return screenshotPath;
|
|
222
164
|
}
|
|
223
165
|
|
|
224
|
-
async function
|
|
166
|
+
async function visualVerify(projectDir, positional, flags = {}) {
|
|
225
167
|
const rawUrl = String(flags.url || positional[0] || '').trim();
|
|
226
168
|
if (!/^https?:\/\//i.test(rawUrl)) {
|
|
227
|
-
log.err('
|
|
169
|
+
log.err('Usage: draftgo verify --url <http://localhost:port/path> --screenshot always|--ui always');
|
|
228
170
|
return 1;
|
|
229
171
|
}
|
|
230
172
|
|
|
231
173
|
const tokenMode = String(flags.token || 'auto').toLowerCase();
|
|
232
174
|
if (!['auto', 'never'].includes(tokenMode)) {
|
|
233
|
-
log.err('--token
|
|
175
|
+
log.err('--token in visual verification accepts auto or never.');
|
|
234
176
|
return 1;
|
|
235
177
|
}
|
|
236
178
|
|
|
237
|
-
const mode = String(flags['mobile-check'] || 'auto').toLowerCase();
|
|
238
|
-
if (!['auto', 'always', 'never'].includes(mode)) {
|
|
239
|
-
log.err('--mobile-check 只支持 auto、always、never。');
|
|
240
|
-
return 1;
|
|
241
|
-
}
|
|
242
|
-
const decision = flags.resource
|
|
243
|
-
? { run: true, reason: `resource=${flags.resource}` }
|
|
244
|
-
: decideMobileCheck(projectDir, mode);
|
|
245
|
-
if (!decision.run) {
|
|
246
|
-
log.ok(`跳过 UI smoke check:${decision.reason}`);
|
|
247
|
-
return 0;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
179
|
let url;
|
|
251
180
|
try {
|
|
252
181
|
url = configuredUiUrl(projectDir, rawUrl, tokenMode);
|
|
253
182
|
} catch (err) {
|
|
254
|
-
log.err(
|
|
183
|
+
log.err(`Unable to read visual verification config: ${err.message}`);
|
|
255
184
|
return 1;
|
|
256
185
|
}
|
|
257
186
|
|
|
@@ -263,30 +192,18 @@ async function verifyUi(projectDir, positional, flags = {}) {
|
|
|
263
192
|
return 1;
|
|
264
193
|
}
|
|
265
194
|
|
|
266
|
-
const width = numberFlag(flags.width,
|
|
267
|
-
const height = numberFlag(flags.height,
|
|
195
|
+
const width = numberFlag(flags.width, 1440, 240, 3840);
|
|
196
|
+
const height = numberFlag(flags.height, 900, 320, 2160);
|
|
268
197
|
const waitMs = numberFlag(flags['wait-ms'], 500, 0, 10000);
|
|
269
198
|
const screenshotMode = String(flags.screenshot || 'never').toLowerCase();
|
|
270
|
-
if (!['
|
|
271
|
-
log.err('--screenshot
|
|
199
|
+
if (!['always', 'never'].includes(screenshotMode)) {
|
|
200
|
+
log.err('--screenshot accepts always or never.');
|
|
272
201
|
return 1;
|
|
273
202
|
}
|
|
274
203
|
|
|
275
204
|
let browser;
|
|
276
205
|
try {
|
|
277
|
-
|
|
278
|
-
const resource = await verifyRemoteResource(projectDir, flags.resource);
|
|
279
|
-
log.info(`UI source: remote committed ${resource.resource_type} ${resource.resource_id}`);
|
|
280
|
-
log.info(`Remote version: ${resource.remote_version || '-'}; remote hash: ${resource.remote_hash || '-'}`);
|
|
281
|
-
if (['local_modified', 'diverged'].includes(resource.state)) {
|
|
282
|
-
log.warn(`Local worktree is not committed (${resource.state}); verify-ui will test the remote version.`);
|
|
283
|
-
}
|
|
284
|
-
if (!resource.local_matches_remote && resource.state !== 'local_modified') {
|
|
285
|
-
throw new Error(`Local and remote content differ (${resource.state}); UI verification stopped.`);
|
|
286
|
-
}
|
|
287
|
-
} else {
|
|
288
|
-
log.info('UI source: remote URL response; no local worktree version was asserted.');
|
|
289
|
-
}
|
|
206
|
+
log.info('UI source: requested URL response.');
|
|
290
207
|
const launch = await launchBrowser(
|
|
291
208
|
chromium,
|
|
292
209
|
flags.browser && String(flags.browser),
|
|
@@ -302,6 +219,15 @@ async function verifyUi(projectDir, positional, flags = {}) {
|
|
|
302
219
|
|
|
303
220
|
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
304
221
|
if (waitMs) await page.waitForTimeout(waitMs);
|
|
222
|
+
let screenshotPath = null;
|
|
223
|
+
if (screenshotMode === 'always' || flags['capture-only']) {
|
|
224
|
+
screenshotPath = await captureScreenshot(projectDir, page);
|
|
225
|
+
}
|
|
226
|
+
if (flags['capture-only']) {
|
|
227
|
+
log.ok(`截图已生成:${width}x${height}`);
|
|
228
|
+
log.info(`截图:${screenshotPath}`);
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
305
231
|
const frames = await selectFrames(page, String(flags.frame || 'auto'));
|
|
306
232
|
const states = [];
|
|
307
233
|
for (const item of frames) states.push({ ...item, state: await inspectFrame(item.frame) });
|
|
@@ -326,16 +252,6 @@ async function verifyUi(projectDir, positional, flags = {}) {
|
|
|
326
252
|
else log.info(`Selector ${flags.selector}: visible in ${match}`);
|
|
327
253
|
}
|
|
328
254
|
|
|
329
|
-
const shouldScreenshot = screenshotMode === 'always' || (screenshotMode === 'on-failure' && issues.length > 0);
|
|
330
|
-
let screenshotPath = null;
|
|
331
|
-
if (shouldScreenshot) {
|
|
332
|
-
const dir = path.join(projectDir, '.draftgo', 'artifacts', 'ui');
|
|
333
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
334
|
-
screenshotPath = path.join(dir, `ui-check-${process.pid}-${Date.now()}.png`);
|
|
335
|
-
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
336
|
-
runtimeFiles.register(projectDir, screenshotPath, 'ui-artifact', 'verify-ui');
|
|
337
|
-
}
|
|
338
|
-
|
|
339
255
|
if (issues.length) {
|
|
340
256
|
issues.forEach((issue) => log.err(issue));
|
|
341
257
|
if (consoleErrors.length) consoleErrors.slice(0, 5).forEach((msg) => log.dim(` console: ${msg}`));
|
|
@@ -344,7 +260,7 @@ async function verifyUi(projectDir, positional, flags = {}) {
|
|
|
344
260
|
return 1;
|
|
345
261
|
}
|
|
346
262
|
|
|
347
|
-
log.ok(`UI smoke check
|
|
263
|
+
log.ok(`UI smoke check passed: ${width}x${height}, ${frames.length} frame(s).`);
|
|
348
264
|
if (screenshotPath) log.info(`截图:${screenshotPath}`);
|
|
349
265
|
return 0;
|
|
350
266
|
} catch (err) {
|
|
@@ -355,15 +271,11 @@ async function verifyUi(projectDir, positional, flags = {}) {
|
|
|
355
271
|
}
|
|
356
272
|
}
|
|
357
273
|
|
|
358
|
-
module.exports =
|
|
359
|
-
module.exports.decideMobileCheck = decideMobileCheck;
|
|
360
|
-
module.exports.gitChangedFiles = gitChangedFiles;
|
|
361
|
-
module.exports.isUiFile = isUiFile;
|
|
274
|
+
module.exports = visualVerify;
|
|
362
275
|
module.exports.configuredUiUrl = configuredUiUrl;
|
|
363
276
|
module.exports.redactUrlTokens = redactUrlTokens;
|
|
364
277
|
module.exports.executableCandidates = executableCandidates;
|
|
365
278
|
module.exports.playwrightCacheCandidates = playwrightCacheCandidates;
|
|
366
|
-
module.exports.parseResourceSpec = parseResourceSpec;
|
|
367
|
-
module.exports.verifyRemoteResource = verifyRemoteResource;
|
|
368
279
|
module.exports.selectFrames = selectFrames;
|
|
369
280
|
module.exports.inspectFrame = inspectFrame;
|
|
281
|
+
module.exports.captureScreenshot = captureScreenshot;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const log = require('../logger');
|
|
4
|
+
const {
|
|
5
|
+
appendItem,
|
|
6
|
+
mutateWorklog,
|
|
7
|
+
normalizeDate,
|
|
8
|
+
readWorklog,
|
|
9
|
+
renderWorklog,
|
|
10
|
+
resolveReference,
|
|
11
|
+
updateItem,
|
|
12
|
+
worklogPath,
|
|
13
|
+
} = require('../worklog');
|
|
14
|
+
|
|
15
|
+
function printUsage() {
|
|
16
|
+
log.err('Usage: draftgo work start|add|start-item|complete|show|list <value>');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function output(flags, value) {
|
|
20
|
+
if (flags.output === 'json') console.log(JSON.stringify(value, null, 2));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function latestReference(blocks) {
|
|
24
|
+
const block = blocks[blocks.length - 1];
|
|
25
|
+
return `${block.date}#${block.entries[block.entries.length - 1].number}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resultFor(blocks, reference) {
|
|
29
|
+
const resolved = resolveReference(blocks, reference);
|
|
30
|
+
return { date: resolved.block.date, ...resolved.entry };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function add(projectDir, positional, flags, status) {
|
|
34
|
+
const title = positional.join(' ').trim();
|
|
35
|
+
if (!title) { printUsage(); return 1; }
|
|
36
|
+
const result = mutateWorklog(projectDir, (blocks) => {
|
|
37
|
+
const date = normalizeDate(flags.date);
|
|
38
|
+
const next = appendItem(blocks, title, status, flags.note ? [String(flags.note).trim()] : [], date);
|
|
39
|
+
return { blocks: next, reference: latestReference(next) };
|
|
40
|
+
});
|
|
41
|
+
const item = resultFor(result.blocks, result.reference);
|
|
42
|
+
output(flags, { path: result.path, item });
|
|
43
|
+
if (flags.output !== 'json') {
|
|
44
|
+
const verb = status === 'active' ? 'Started' : 'Added';
|
|
45
|
+
log.ok(`${verb} work item #${item.number} (${item.date}#${item.number}): ${item.title}`);
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function change(projectDir, positional, flags, status) {
|
|
51
|
+
const reference = positional[0];
|
|
52
|
+
if (!reference) { printUsage(); return 1; }
|
|
53
|
+
const result = mutateWorklog(projectDir, (blocks) => {
|
|
54
|
+
const note = flags.note == null ? '' : String(flags.note).trim();
|
|
55
|
+
const next = updateItem(blocks, reference, status, note);
|
|
56
|
+
return { blocks: next, reference };
|
|
57
|
+
});
|
|
58
|
+
output(flags, { path: result.path, item: resultFor(result.blocks, result.reference) });
|
|
59
|
+
if (flags.output !== 'json') log.ok(`Worklog item ${result.reference} marked ${status}.`);
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function show(projectDir, flags) {
|
|
64
|
+
const blocks = readWorklog(projectDir);
|
|
65
|
+
output(flags, { path: worklogPath(projectDir), blocks });
|
|
66
|
+
if (flags.output !== 'json') console.log(renderWorklog(blocks) || 'Worklog is empty.');
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function list(projectDir, flags) {
|
|
71
|
+
const blocks = readWorklog(projectDir);
|
|
72
|
+
const items = blocks.flatMap((block) => block.entries.map((entry) => ({ date: block.date, ...entry })));
|
|
73
|
+
output(flags, { path: worklogPath(projectDir), items });
|
|
74
|
+
if (flags.output !== 'json') items.forEach((item) => log.info(`${item.date}#${item.number} [${item.status}] ${item.title}`));
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function worklog(projectDir, positional = [], flags = {}) {
|
|
79
|
+
const operation = positional[0] || 'show';
|
|
80
|
+
if (operation === 'start') return add(projectDir, positional.slice(1), flags, 'active');
|
|
81
|
+
if (operation === 'add') return add(projectDir, positional.slice(1), flags, 'pending');
|
|
82
|
+
if (operation === 'start-item' || operation === 'start_item') return change(projectDir, positional.slice(1), flags, 'active');
|
|
83
|
+
if (operation === 'complete' || operation === 'done') return change(projectDir, positional.slice(1), flags, 'completed');
|
|
84
|
+
if (operation === 'show') return show(projectDir, flags);
|
|
85
|
+
if (operation === 'list') return list(projectDir, flags);
|
|
86
|
+
throw new Error(`Unknown worklog operation ${operation}.`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
worklog.printUsage = printUsage;
|
|
90
|
+
module.exports = worklog;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Windows console hosts still use the active console code page to decode the
|
|
7
|
+
* UTF-8 bytes Node writes. Switch only an interactive console to UTF-8.
|
|
8
|
+
*
|
|
9
|
+
* This deliberately does not run for redirected stdout: JSON consumers and
|
|
10
|
+
* MCP stdio use pipes, where Node's UTF-8 byte stream is already correct and
|
|
11
|
+
* changing a shared console would be both unnecessary and surprising.
|
|
12
|
+
*/
|
|
13
|
+
function configureConsoleUtf8(options = {}) {
|
|
14
|
+
const platform = options.platform || process.platform;
|
|
15
|
+
const stdout = options.stdout || process.stdout;
|
|
16
|
+
const run = options.spawnSync || spawnSync;
|
|
17
|
+
|
|
18
|
+
if (platform !== 'win32' || !stdout || !stdout.isTTY) return false;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
// chcp changes the code page for the inherited Windows console. Redirect
|
|
22
|
+
// its own localized status line so stdout remains exclusively CLI output.
|
|
23
|
+
run('cmd.exe', ['/d', '/s', '/c', 'chcp 65001 >nul'], {
|
|
24
|
+
stdio: 'ignore',
|
|
25
|
+
windowsHide: true,
|
|
26
|
+
});
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
// Encoding setup is a presentation enhancement; never block the command.
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { configureConsoleUtf8 };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const HARD_INCOMPATIBLE_CODES = new Set([
|
|
4
|
+
'CONTRACT_INCOMPATIBLE',
|
|
5
|
+
'CLIENT_VERSION_UNSUPPORTED',
|
|
6
|
+
'CLI_VERSION_UNSUPPORTED',
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
function errorCode(error) {
|
|
10
|
+
return String(error && error.code || '').trim().toUpperCase();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isContractCompatibilityError(error) {
|
|
14
|
+
const code = errorCode(error);
|
|
15
|
+
return code === 'CONTRACT_CHANGED' || HARD_INCOMPATIBLE_CODES.has(code);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isHardContractIncompatibility(error) {
|
|
19
|
+
return HARD_INCOMPATIBLE_CODES.has(errorCode(error));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isDangerousOperation(contract) {
|
|
23
|
+
const operation = contract && contract.operation || contract || {};
|
|
24
|
+
return operation.destructive === true || String(operation.risk || '').toLowerCase() === 'high';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function canonicalValue(value) {
|
|
28
|
+
if (Array.isArray(value)) return value.map(canonicalValue);
|
|
29
|
+
if (!value || typeof value !== 'object') return value;
|
|
30
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sameOperationContract(left, right) {
|
|
34
|
+
const leftOperation = left && left.operation || left || {};
|
|
35
|
+
const rightOperation = right && right.operation || right || {};
|
|
36
|
+
return JSON.stringify(canonicalValue(leftOperation)) === JSON.stringify(canonicalValue(rightOperation));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function incompatibleOperationError(operationId, error) {
|
|
40
|
+
const wrapped = new Error(
|
|
41
|
+
`DraftGo CLI and server contracts are incompatible for dangerous operation ${operationId}; `
|
|
42
|
+
+ 'the call was blocked before retry. Upgrade DraftGo CLI, then describe and confirm the operation again.',
|
|
43
|
+
);
|
|
44
|
+
wrapped.code = 'CLI_CONTRACT_INCOMPATIBLE';
|
|
45
|
+
wrapped.details = error && (error.details || error.data);
|
|
46
|
+
wrapped.cause = error;
|
|
47
|
+
return wrapped;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = {
|
|
51
|
+
errorCode,
|
|
52
|
+
isContractCompatibilityError,
|
|
53
|
+
isHardContractIncompatibility,
|
|
54
|
+
isDangerousOperation,
|
|
55
|
+
sameOperationContract,
|
|
56
|
+
incompatibleOperationError,
|
|
57
|
+
};
|