draftgo-cli 1.0.11 → 1.0.13
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 +2 -2
- package/package.json +3 -2
- package/resources/skill/manifest.json +1 -1
- package/resources/skill/references/services.md +22 -1
- package/src/commands/apiKey.js +241 -1
- package/src/commands/components.js +1 -0
- package/src/commands/help.js +1 -1
- package/src/commands/verify.js +70 -27
- package/src/projectConfig.js +2 -0
- package/src/projectHealth.js +1 -1
package/README.md
CHANGED
|
@@ -164,10 +164,10 @@ RBAC 与 API Key 快捷命令:
|
|
|
164
164
|
draftgo role list
|
|
165
165
|
draftgo group members add --input request.json
|
|
166
166
|
draftgo api-key status
|
|
167
|
-
draftgo api-key rotate
|
|
167
|
+
draftgo api-key rotate --input api-key.json --yes
|
|
168
168
|
```
|
|
169
169
|
|
|
170
|
-
`role` 管理 Role 权限模板,`group`
|
|
170
|
+
`role` 管理 Role 权限模板,`group` 管理用户组及其角色。`api-key create|rotate` 按 Registry 的私有凭据交付 workflow 获取新密钥,验证后原子替换项目连接;命令不会把明文密钥打印到 stdout。若交付后验证或写配置中断,重跑同一命令会从 `.draftgo/credential-recovery.json` 恢复,不会再次轮换。交互式 CLI、MCP 和普通 HTTP API 均使用当前用户的 API Key,并按同一基础 RBAC 与业务守卫授权;API Key 不能创建或伪造服务身份。未知能力始终通过 `api search/describe/call` 使用实时 Registry,不依赖固化权限表。
|
|
171
171
|
|
|
172
172
|
### 交付与记录
|
|
173
173
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "draftgo-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
4
4
|
"description": "Install and manage the DraftGo skill across AI coding agents (Claude Code, Codex, Cursor, Windsurf, Antigravity, Copilot, Gemini, Kiro, Pi, ZCode).",
|
|
5
5
|
"bin": {
|
|
6
6
|
"draftgo": "bin/draftgo.js"
|
|
@@ -48,8 +48,9 @@
|
|
|
48
48
|
"validate:skill": "node scripts/validate-skill.js",
|
|
49
49
|
"build:release": "node scripts/build-release.js",
|
|
50
50
|
"verify:package": "node scripts/verify-package.js",
|
|
51
|
-
"test": "npm run lint && npm run validate:skill && npm run verify:package && npm run test:unit && npm run test:components && npm run test:capabilities && npm run test:worklog && npm run test:mcp && npm run test:worktree && npm run test:integration && npm run test:local && npm run test:e2e",
|
|
51
|
+
"test": "npm run lint && npm run validate:skill && npm run verify:package && npm run test:unit && npm run test:api-key && npm run test:components && npm run test:capabilities && npm run test:worklog && npm run test:mcp && npm run test:worktree && npm run test:integration && npm run test:local && npm run test:e2e",
|
|
52
52
|
"test:unit": "node tests/unit.js",
|
|
53
|
+
"test:api-key": "node --test tests/api-key.test.js",
|
|
53
54
|
"test:components": "node --test tests/components.test.js",
|
|
54
55
|
"test:capabilities": "node --test tests/capabilities.test.js",
|
|
55
56
|
"test:worklog": "node tests/worklog.test.js",
|
|
@@ -23,7 +23,9 @@ func Register(app *sdk.App) {
|
|
|
23
23
|
|
|
24
24
|
func createOrder(ctx *sdk.Context) (any, error) {
|
|
25
25
|
if err := ctx.Auth.RequireLogin(); err != nil {
|
|
26
|
-
return
|
|
26
|
+
return ctx.Respond(map[string]any{
|
|
27
|
+
"code": "unauthenticated", "message": "请先登录",
|
|
28
|
+
}, 401, nil), nil
|
|
27
29
|
}
|
|
28
30
|
return ctx.DB.Create("order", ctx.Input)
|
|
29
31
|
}
|
|
@@ -31,6 +33,23 @@ func createOrder(ctx *sdk.Context) (any, error) {
|
|
|
31
33
|
|
|
32
34
|
普通结果 `return body, nil`。要控 HTTP 状态码或响应头:`return ctx.Respond(body, status, headers), nil`。handler 返回前收束 goroutine。依赖走平台锁定,不替换托管 SDK。不要调用自定义 Go 服务自己的管理/执行 API。
|
|
33
35
|
|
|
36
|
+
### 业务错误与执行失败
|
|
37
|
+
|
|
38
|
+
Route 的预期业务失败(参数不合法、余额不足、订单不存在、业务权限不足)用 `ctx.Respond` 明确返回状态码和可展示正文,第二个返回值必须为 `nil`。例如在余额校验失败的分支中:
|
|
39
|
+
|
|
40
|
+
```go
|
|
41
|
+
return ctx.Respond(map[string]any{
|
|
42
|
+
"code": "insufficient_balance",
|
|
43
|
+
"message": "余额不足,请先充值",
|
|
44
|
+
}, 400, nil), nil
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`code` / `message` 是这个业务接口约定的正文示例,不是平台强制包装;页面按双方约定读取。根据业务语义选择 400、403、404、409 等状态码,签名以 checkout 的当前实例 SDK 为准。
|
|
48
|
+
|
|
49
|
+
`return nil, err`(包括 `errors.New` / `fmt.Errorf`)表示执行失败。当前普通 handler error 会转成 HTTP 500,外部只看到 `Internal Server Error`,脱敏后的具体原因保存在执行记录。即使同时返回 `ctx.Respond(...)`,只要第二个返回值非空,响应也不会被采用。SDK 调用返回的错误不会自动恢复成该模块原来的 HTTP 状态码;已识别的业务失败需要服务代码显式转换,未知错误保留为执行失败,不把任意 `err.Error()` 直接公开。
|
|
50
|
+
|
|
51
|
+
Event / Schedule 没有外部 HTTP 响应接收者,失败时返回 error 供执行记录追踪,不用 `ctx.Respond` 掩盖失败。Route 返回业务错误响应且 error 为 nil 时,执行记录仍算执行成功;必须检查响应状态码和正文判断业务结果。
|
|
52
|
+
|
|
34
53
|
已有服务源码:
|
|
35
54
|
|
|
36
55
|
```bash
|
|
@@ -89,6 +108,8 @@ checkout 服务时,SDK 落到 `.draftgo/worktree/sdk/`。读那里的 `sdk.go`
|
|
|
89
108
|
|
|
90
109
|
完成证据:草稿 revision、验证结果、试运行执行 ID;发布时加版本和真实调用结果。
|
|
91
110
|
|
|
111
|
+
涉及 Route 错误处理时,在任务授权范围内验证成功分支和预期业务失败分支,确认响应状态码、业务 code/message 与页面展示一致;不能只凭执行记录为 succeeded 判定业务成功。草稿试运行按实时契约检查结果,发布后的 HTTP 行为以真实 Route 调用为准。
|
|
112
|
+
|
|
92
113
|
## 失败
|
|
93
114
|
|
|
94
115
|
| HTTP | 先查 |
|
package/src/commands/apiKey.js
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
3
5
|
const log = require('../logger');
|
|
4
|
-
const {
|
|
6
|
+
const { confirm } = require('../prompt');
|
|
7
|
+
const {
|
|
8
|
+
ensureProjectIgnores,
|
|
9
|
+
loadProjectConfig,
|
|
10
|
+
writePrivateJson,
|
|
11
|
+
writeProjectConfig,
|
|
12
|
+
} = require('../projectConfig');
|
|
13
|
+
const { testConnection } = require('../mcp/client');
|
|
14
|
+
const { redactText } = require('../mcp/protocol');
|
|
15
|
+
const { TOOL_NAMES, openToolSession, callStructured } = require('../mcp/tools');
|
|
16
|
+
const { parseTimeout } = require('../timeout');
|
|
17
|
+
const {
|
|
18
|
+
readCallInput,
|
|
19
|
+
callOperation,
|
|
20
|
+
describeOperation,
|
|
21
|
+
assertInvocationResult,
|
|
22
|
+
printCallError,
|
|
23
|
+
} = require('./api');
|
|
5
24
|
|
|
6
25
|
const OPERATIONS = Object.freeze({
|
|
7
26
|
status: 'getCurrentUserAPIKey',
|
|
@@ -10,21 +29,242 @@ const OPERATIONS = Object.freeze({
|
|
|
10
29
|
delete: 'deleteCurrentUserAPIKey',
|
|
11
30
|
rotate: 'rotateCurrentUserAPIKey',
|
|
12
31
|
});
|
|
32
|
+
const CREDENTIAL_ACTIONS = new Set(['create', 'rotate']);
|
|
33
|
+
const RECOVERY_FILE = 'credential-recovery.json';
|
|
34
|
+
const DEFAULT_RESPONSE_LIMIT = 1024 * 1024;
|
|
13
35
|
|
|
14
36
|
function operationKey(value) {
|
|
15
37
|
const action = String(value || 'status').trim().toLowerCase();
|
|
16
38
|
return OPERATIONS[action] ? action : '';
|
|
17
39
|
}
|
|
18
40
|
|
|
41
|
+
function recoveryPath(projectDir) {
|
|
42
|
+
return path.join(projectDir, '.draftgo', RECOVERY_FILE);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readRecovery(projectDir) {
|
|
46
|
+
const file = recoveryPath(projectDir);
|
|
47
|
+
if (!fs.existsSync(file)) return null;
|
|
48
|
+
let value;
|
|
49
|
+
try { value = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
50
|
+
catch { throw new Error('The private API Key recovery file is invalid; remove it only after restoring the credential manually.'); }
|
|
51
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
52
|
+
|| !value.server || !value.token || !value.operation_id || !value.action) {
|
|
53
|
+
throw new Error('The private API Key recovery file is incomplete; remove it only after restoring the credential manually.');
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function jsonPointer(value, pointer) {
|
|
59
|
+
const text = String(pointer || '');
|
|
60
|
+
if (!text.startsWith('/')) throw new Error('The credential delivery workflow has no valid credential JSON Pointer.');
|
|
61
|
+
let current = value;
|
|
62
|
+
for (const raw of text.slice(1).split('/')) {
|
|
63
|
+
const key = raw.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
64
|
+
if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, key)) {
|
|
65
|
+
throw new Error('DraftGo returned no credential at the workflow JSON Pointer.');
|
|
66
|
+
}
|
|
67
|
+
current = current[key];
|
|
68
|
+
}
|
|
69
|
+
const credential = typeof current === 'string' ? current.trim() : '';
|
|
70
|
+
if (!credential) throw new Error('DraftGo returned an empty credential.');
|
|
71
|
+
return credential;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function workflowURL(config, workflow) {
|
|
75
|
+
const rawPath = String(workflow.path || '');
|
|
76
|
+
if (!rawPath.startsWith('/api/') || rawPath.includes('{')) {
|
|
77
|
+
throw new Error('The credential delivery workflow returned an unsupported API path.');
|
|
78
|
+
}
|
|
79
|
+
const target = new URL(rawPath, `${config.server.replace(/\/+$/, '')}/`);
|
|
80
|
+
const server = new URL(config.server);
|
|
81
|
+
if (target.origin !== server.origin || target.username || target.password || target.search || target.hash) {
|
|
82
|
+
throw new Error('The credential delivery workflow returned an invalid same-origin path.');
|
|
83
|
+
}
|
|
84
|
+
return target.toString();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validateWorkflow(contract, workflow) {
|
|
88
|
+
const operation = contract && contract.operation || {};
|
|
89
|
+
const policy = operation.response_policy || {};
|
|
90
|
+
const method = String(workflow && workflow.method || '').toUpperCase();
|
|
91
|
+
if (!workflow || workflow.required !== true || workflow.workflow !== 'credential_delivery'
|
|
92
|
+
|| workflow.mode !== 'sensitive' || !['POST', 'PUT', 'PATCH'].includes(method)
|
|
93
|
+
|| method !== String(operation.method || '').toUpperCase()
|
|
94
|
+
|| workflow.path !== operation.path
|
|
95
|
+
|| workflow.credential_pointer !== policy.credential_pointer
|
|
96
|
+
|| !String(workflow.credential_pointer || '').startsWith('/')) {
|
|
97
|
+
throw new Error('DraftGo returned an invalid credential delivery workflow descriptor.');
|
|
98
|
+
}
|
|
99
|
+
const types = Array.isArray(workflow.request_content_types) ? workflow.request_content_types : [];
|
|
100
|
+
if (types.length && !types.includes('application/json')) {
|
|
101
|
+
throw new Error('The credential delivery workflow does not accept JSON.');
|
|
102
|
+
}
|
|
103
|
+
return method;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function readBoundedJSON(response, maximum) {
|
|
107
|
+
const headerLength = Number(response.headers.get('content-length'));
|
|
108
|
+
if (Number.isFinite(headerLength) && headerLength > maximum) {
|
|
109
|
+
throw new Error('DraftGo credential response exceeded the workflow size limit.');
|
|
110
|
+
}
|
|
111
|
+
if (!response.body) return {};
|
|
112
|
+
const reader = response.body.getReader();
|
|
113
|
+
const chunks = [];
|
|
114
|
+
let size = 0;
|
|
115
|
+
try {
|
|
116
|
+
while (true) {
|
|
117
|
+
const { done, value } = await reader.read();
|
|
118
|
+
if (done) break;
|
|
119
|
+
const chunk = Buffer.from(value);
|
|
120
|
+
size += chunk.length;
|
|
121
|
+
if (size > maximum) throw new Error('DraftGo credential response exceeded the workflow size limit.');
|
|
122
|
+
chunks.push(chunk);
|
|
123
|
+
}
|
|
124
|
+
} finally { await reader.cancel().catch(() => {}); }
|
|
125
|
+
if (!size) return {};
|
|
126
|
+
try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))); }
|
|
127
|
+
catch { throw new Error('DraftGo credential response was not valid UTF-8 JSON.'); }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function requestCredential(config, input, contract, workflow, options = {}) {
|
|
131
|
+
const method = validateWorkflow(contract, workflow);
|
|
132
|
+
const maximum = Number(workflow.max_response_bytes || DEFAULT_RESPONSE_LIMIT);
|
|
133
|
+
const body = JSON.stringify(input.body ?? {});
|
|
134
|
+
const requestLimit = Number(workflow.max_request_bytes || 0);
|
|
135
|
+
if (requestLimit > 0 && Buffer.byteLength(body, 'utf8') > requestLimit) {
|
|
136
|
+
throw new Error('DraftGo credential request exceeded the workflow size limit.');
|
|
137
|
+
}
|
|
138
|
+
const response = await (options.fetch || fetch)(workflowURL(config, workflow), {
|
|
139
|
+
method,
|
|
140
|
+
headers: {
|
|
141
|
+
Authorization: `Bearer ${config.token}`,
|
|
142
|
+
Accept: 'application/json',
|
|
143
|
+
'Content-Type': 'application/json',
|
|
144
|
+
},
|
|
145
|
+
body,
|
|
146
|
+
redirect: 'error',
|
|
147
|
+
...(options.timeoutMs ? { signal: AbortSignal.timeout(options.timeoutMs) } : {}),
|
|
148
|
+
});
|
|
149
|
+
const payload = await readBoundedJSON(response, maximum > 0 ? maximum : DEFAULT_RESPONSE_LIMIT);
|
|
150
|
+
if (!response.ok) {
|
|
151
|
+
const detail = payload && (payload.error || payload);
|
|
152
|
+
const error = new Error(String(detail && detail.message || `DraftGo returned HTTP ${response.status}.`));
|
|
153
|
+
error.code = String(detail && detail.code || 'HTTP_ERROR');
|
|
154
|
+
error.status = response.status;
|
|
155
|
+
error.details = { request_id: response.headers.get('x-request-id') || undefined };
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
token: jsonPointer(payload, workflow.credential_pointer),
|
|
160
|
+
request_id: response.headers.get('x-request-id') || null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function validateAndCommit(projectDir, config, recovery, timeoutMs, options = {}) {
|
|
165
|
+
await (options.testConnection || testConnection)({ ...config, token: recovery.token }, { timeoutMs });
|
|
166
|
+
const file = (options.writeProjectConfig || writeProjectConfig)(
|
|
167
|
+
projectDir,
|
|
168
|
+
config.server,
|
|
169
|
+
recovery.token,
|
|
170
|
+
{ mcp_url: config.mcp_url || '' },
|
|
171
|
+
);
|
|
172
|
+
fs.rmSync(recoveryPath(projectDir), { force: true });
|
|
173
|
+
return file;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function credentialCommand(projectDir, action, flags = {}, options = {}) {
|
|
177
|
+
const operationId = OPERATIONS[action];
|
|
178
|
+
const timeoutMs = parseTimeout(flags.timeout);
|
|
179
|
+
const config = loadProjectConfig(projectDir);
|
|
180
|
+
const pending = readRecovery(projectDir);
|
|
181
|
+
if (pending) {
|
|
182
|
+
if (pending.server !== config.server || pending.operation_id !== operationId || pending.action !== action) {
|
|
183
|
+
throw new Error(`A pending ${pending.action} credential delivery must be recovered before another API Key operation.`);
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
await validateAndCommit(projectDir, config, pending, timeoutMs, options);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
error.message = redactText(error.message || error, [pending.token, config.token]);
|
|
189
|
+
return printCallError(operationId, error, flags);
|
|
190
|
+
}
|
|
191
|
+
if (flags.output === 'json') console.log(JSON.stringify({ operation_id: operationId, credential_updated: true, recovered: true }, null, 2));
|
|
192
|
+
else log.ok('Recovered and activated the previously delivered DraftGo API Key.');
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (action === 'rotate' && !flags.yes && !flags.y) {
|
|
197
|
+
if (flags.output === 'json' || !process.stdin.isTTY) {
|
|
198
|
+
console.log(JSON.stringify({ operation_id: operationId, error: { code: 'CONFIRMATION_REQUIRED', message: 'API Key rotation requires --yes in JSON or non-interactive mode.' } }, null, 2));
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
if (!await confirm('Rotate the current DraftGo API Key and replace the project credential?', { default: false })) return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const openSession = options.openToolSession || openToolSession;
|
|
206
|
+
const describe = options.describeOperation || describeOperation;
|
|
207
|
+
const invoke = options.callStructured || callStructured;
|
|
208
|
+
const session = await openSession(
|
|
209
|
+
config,
|
|
210
|
+
[TOOL_NAMES.apiSearch, TOOL_NAMES.apiDescribe, TOOL_NAMES.apiCall],
|
|
211
|
+
{ timeoutMs },
|
|
212
|
+
);
|
|
213
|
+
const contract = await describe(projectDir, config, session, operationId);
|
|
214
|
+
if (!contract) throw new Error('DraftGo API description is missing contract revision metadata.');
|
|
215
|
+
const input = readCallInput(projectDir, flags);
|
|
216
|
+
const descriptor = await invoke(session, TOOL_NAMES.apiCall, {
|
|
217
|
+
...input,
|
|
218
|
+
operation_id: operationId,
|
|
219
|
+
registry_revision: contract.registry_revision,
|
|
220
|
+
...(action === 'rotate' ? { confirm: true } : {}),
|
|
221
|
+
}, { timeoutMs });
|
|
222
|
+
if (!descriptor || !descriptor.workflow || descriptor.workflow.required !== true) {
|
|
223
|
+
assertInvocationResult(operationId, descriptor);
|
|
224
|
+
throw new Error('DraftGo did not return a credential delivery workflow.');
|
|
225
|
+
}
|
|
226
|
+
const delivered = await requestCredential(config, input, contract, descriptor.workflow, { timeoutMs, fetch: options.fetch });
|
|
227
|
+
const recovery = {
|
|
228
|
+
version: 1,
|
|
229
|
+
action,
|
|
230
|
+
operation_id: operationId,
|
|
231
|
+
server: config.server,
|
|
232
|
+
...(config.mcp_url ? { mcp_url: config.mcp_url } : {}),
|
|
233
|
+
token: delivered.token,
|
|
234
|
+
delivered_at: new Date().toISOString(),
|
|
235
|
+
};
|
|
236
|
+
ensureProjectIgnores(projectDir);
|
|
237
|
+
writePrivateJson(recoveryPath(projectDir), recovery);
|
|
238
|
+
await validateAndCommit(projectDir, config, recovery, timeoutMs, options);
|
|
239
|
+
if (flags.output === 'json') console.log(JSON.stringify({ operation_id: operationId, request_id: delivered.request_id, credential_updated: true, recovered: false }, null, 2));
|
|
240
|
+
else log.ok(`DraftGo API Key ${action === 'rotate' ? 'rotated' : 'created'} and project credential updated.`);
|
|
241
|
+
return 0;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
const secrets = [config.token];
|
|
244
|
+
const recovery = readRecovery(projectDir);
|
|
245
|
+
if (recovery) secrets.push(recovery.token);
|
|
246
|
+
if (error && error.message) error.message = redactText(error.message, secrets);
|
|
247
|
+
return printCallError(operationId, error, flags);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
19
251
|
async function apiKeyCommand(projectDir, positional, flags = {}) {
|
|
20
252
|
const action = operationKey(positional[0]);
|
|
21
253
|
if (!action) {
|
|
22
254
|
log.err('Usage: draftgo api-key status|create|update|delete|rotate [--input <json-file>]');
|
|
23
255
|
return 1;
|
|
24
256
|
}
|
|
257
|
+
if (CREDENTIAL_ACTIONS.has(action)) return credentialCommand(projectDir, action, flags);
|
|
25
258
|
return callOperation(projectDir, OPERATIONS[action], flags);
|
|
26
259
|
}
|
|
27
260
|
|
|
28
261
|
module.exports = apiKeyCommand;
|
|
29
262
|
module.exports.OPERATIONS = OPERATIONS;
|
|
30
263
|
module.exports.operationKey = operationKey;
|
|
264
|
+
module.exports.recoveryPath = recoveryPath;
|
|
265
|
+
module.exports.readRecovery = readRecovery;
|
|
266
|
+
module.exports.jsonPointer = jsonPointer;
|
|
267
|
+
module.exports.validateWorkflow = validateWorkflow;
|
|
268
|
+
module.exports.requestCredential = requestCredential;
|
|
269
|
+
module.exports.validateAndCommit = validateAndCommit;
|
|
270
|
+
module.exports.credentialCommand = credentialCommand;
|
package/src/commands/help.js
CHANGED
|
@@ -146,7 +146,7 @@ Important flags:
|
|
|
146
146
|
--yes Skip supported confirmation prompts.
|
|
147
147
|
--operation-id <id> (delete) Select an operation explicitly.
|
|
148
148
|
--params <json> (api call/delete) Pass an API parameter object.
|
|
149
|
-
--input <file> (api call/delete) Read a UTF-8 JSON object.
|
|
149
|
+
--input <file> (api call/delete/api-key create|rotate) Read a UTF-8 JSON object.
|
|
150
150
|
--delivery <mode> (deploy) local | preview | deploy.
|
|
151
151
|
--dry-run Preview supported cleanup or delivery operations.
|
|
152
152
|
--ui <mode> (verify) always | never; default never.
|
package/src/commands/verify.js
CHANGED
|
@@ -9,6 +9,7 @@ const path = require('path');
|
|
|
9
9
|
const parse5 = require('parse5');
|
|
10
10
|
const { loadProjectConfig } = require('../projectConfig');
|
|
11
11
|
const { loadManifest, getEntry, absolutePath } = require('../worktree/manifest');
|
|
12
|
+
const { findComponent } = require('./components');
|
|
12
13
|
|
|
13
14
|
function mode(value, fallback, flag) {
|
|
14
15
|
const normalized = String(value == null ? fallback : value).toLowerCase();
|
|
@@ -64,65 +65,81 @@ function walkNodes(node, visit) {
|
|
|
64
65
|
for (const child of node.childNodes || []) walkNodes(child, visit);
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
function componentTargets(projectDir, selected) {
|
|
69
|
+
if (selected.length) {
|
|
70
|
+
return selected.filter(item => item.resourceType === 'pages' || item.resourceType === 'navigations');
|
|
71
|
+
}
|
|
72
|
+
const manifest = loadManifest(projectDir);
|
|
73
|
+
return Object.values(manifest.entries)
|
|
74
|
+
.filter(entry => entry.resource_type === 'pages' || entry.resource_type === 'navigations')
|
|
75
|
+
.map(entry => ({ resourceType: entry.resource_type, resourceId: String(entry.resource_id) }));
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
async function verifyPageComponents(projectDir, selected) {
|
|
68
|
-
const
|
|
69
|
-
if (!
|
|
79
|
+
const targets = componentTargets(projectDir, selected);
|
|
80
|
+
if (!targets.length) {
|
|
81
|
+
return {
|
|
82
|
+
status: 'skipped', resources: [], reference_count: 0,
|
|
83
|
+
reason: selected.length ? 'no_page_or_navigation_selected' : 'no_checked_out_page_or_navigation',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
70
86
|
const config = loadProjectConfig(projectDir);
|
|
71
87
|
const manifest = loadManifest(projectDir);
|
|
72
88
|
const uses = [];
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
89
|
+
const covered = [];
|
|
90
|
+
for (const target of targets) {
|
|
91
|
+
const entry = getEntry(manifest, target.resourceType, target.resourceId);
|
|
92
|
+
if (!entry) throw new Error(`${target.resourceType} ${target.resourceId}: resource is not checked out`);
|
|
76
93
|
const file = absolutePath(projectDir, entry.local_path);
|
|
77
94
|
const source = fs.readFileSync(file, 'utf8');
|
|
78
95
|
const document = parse5.parse(source);
|
|
79
96
|
const instances = new Set();
|
|
97
|
+
let references = 0;
|
|
80
98
|
walkNodes(document, node => {
|
|
81
99
|
const attrs = new Map((node.attrs || []).map(attr => [attr.name, attr.value]));
|
|
82
100
|
const name = attrs.get('data-dg-use');
|
|
83
101
|
if (!name) return;
|
|
84
|
-
if (!/^([a-z][a-z0-9-]*)\/([a-z][a-z0-9-]*)$/.test(name)) throw new Error(
|
|
102
|
+
if (!/^([a-z][a-z0-9-]*)\/([a-z][a-z0-9-]*)$/.test(name)) throw new Error(`${target.resourceType} ${target.resourceId}: invalid data-dg-use "${name}"`);
|
|
85
103
|
const instance = attrs.get('data-dg-instance');
|
|
86
|
-
if (instance) { if (instances.has(instance)) throw new Error(
|
|
87
|
-
|
|
104
|
+
if (instance) { if (instances.has(instance)) throw new Error(`${target.resourceType} ${target.resourceId}: duplicate data-dg-instance "${instance}"`); instances.add(instance); }
|
|
105
|
+
references += 1;
|
|
106
|
+
uses.push({ resourceType: target.resourceType, resourceId: target.resourceId, node, attrs, name });
|
|
88
107
|
});
|
|
108
|
+
covered.push({ resource_type: target.resourceType, resource_id: target.resourceId, references });
|
|
89
109
|
}
|
|
90
110
|
const cache = new Map();
|
|
91
111
|
for (const use of uses) {
|
|
92
112
|
if (!cache.has(use.name)) {
|
|
93
|
-
|
|
94
|
-
|
|
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);
|
|
113
|
+
try { cache.set(use.name, await findComponent(config, use.name)); }
|
|
114
|
+
catch (error) { throw new Error(`component lookup failed for ${use.name}: ${error.message}`); }
|
|
99
115
|
}
|
|
100
116
|
const component = cache.get(use.name);
|
|
101
|
-
|
|
102
|
-
if (!component
|
|
117
|
+
const label = `${use.resourceType} ${use.resourceId}`;
|
|
118
|
+
if (!component) throw new Error(`${label}: component not found ${use.name}`);
|
|
119
|
+
if (!component.published || Number(component.published_revision || 0) < 1) throw new Error(`${label}: component is not published ${use.name}`);
|
|
103
120
|
const definition = component.published;
|
|
104
121
|
const rootTag = String(definition.root_tag || '').toLowerCase();
|
|
105
|
-
if (rootTag && String(use.node.nodeName || '').toLowerCase() !== rootTag) throw new Error(
|
|
122
|
+
if (rootTag && String(use.node.nodeName || '').toLowerCase() !== rootTag) throw new Error(`${label}: ${use.name} requires <${rootTag}> but found <${use.node.nodeName}>`);
|
|
106
123
|
const props = new Map((definition.props || []).map(prop => [prop.name, prop]));
|
|
107
124
|
for (const [name, value] of use.attrs) {
|
|
108
125
|
if (!name.startsWith('data-dg-prop-')) continue;
|
|
109
126
|
const propName = name.slice('data-dg-prop-'.length);
|
|
110
127
|
const prop = props.get(propName);
|
|
111
|
-
if (!prop) throw new Error(
|
|
128
|
+
if (!prop) throw new Error(`${label}: unknown prop ${name} on ${use.name}`);
|
|
112
129
|
if (prop.type === 'boolean' && !['', 'true', 'false', '1', '0'].includes(String(value).toLowerCase())) {
|
|
113
|
-
throw new Error(
|
|
130
|
+
throw new Error(`${label}: invalid boolean prop ${name} on ${use.name}`);
|
|
114
131
|
}
|
|
115
132
|
if (prop.type === 'number' && !Number.isFinite(Number(value))) {
|
|
116
|
-
throw new Error(
|
|
133
|
+
throw new Error(`${label}: invalid number prop ${name} on ${use.name}`);
|
|
117
134
|
}
|
|
118
135
|
}
|
|
119
136
|
const slots = new Set((definition.slots || []).map(slot => slot.name));
|
|
120
137
|
for (const child of use.node.childNodes || []) {
|
|
121
138
|
const slot = (child.attrs || []).find(attr => attr.name === 'data-dg-slot')?.value;
|
|
122
|
-
if (slot && slots.size && !slots.has(slot)) throw new Error(
|
|
139
|
+
if (slot && slots.size && !slots.has(slot)) throw new Error(`${label}: unknown slot ${slot} on ${use.name}`);
|
|
123
140
|
}
|
|
124
141
|
}
|
|
125
|
-
return uses;
|
|
142
|
+
return { status: 'passed', resources: covered, reference_count: uses.length, reason: null };
|
|
126
143
|
}
|
|
127
144
|
|
|
128
145
|
async function verify(projectDir, positional = [], flags = {}) {
|
|
@@ -141,17 +158,42 @@ async function verify(projectDir, positional = [], flags = {}) {
|
|
|
141
158
|
}
|
|
142
159
|
|
|
143
160
|
if (flags.output !== 'json') log.title('draftgo verify');
|
|
144
|
-
|
|
145
|
-
catch (error) { log.err(error.message); return 1; }
|
|
146
|
-
const checkCode = await check(projectDir, [], {
|
|
161
|
+
const checkOptions = {
|
|
147
162
|
output: flags.output,
|
|
148
163
|
strict: flags.strict,
|
|
149
164
|
remote: remoteMode === 'always',
|
|
150
165
|
resourceKeys: selected.length
|
|
151
166
|
? selected.map((entry) => `${entry.resourceType}:${entry.resourceId}`)
|
|
152
167
|
: null,
|
|
153
|
-
}
|
|
154
|
-
if (
|
|
168
|
+
};
|
|
169
|
+
if (flags.output === 'json') {
|
|
170
|
+
const result = await check(projectDir, [], { ...checkOptions, quiet: true, returnResult: true });
|
|
171
|
+
let componentContracts = {
|
|
172
|
+
status: 'skipped', resources: [], reference_count: 0,
|
|
173
|
+
reason: 'remote_not_requested',
|
|
174
|
+
};
|
|
175
|
+
if (flags.remote) {
|
|
176
|
+
try { componentContracts = await verifyPageComponents(projectDir, selected); }
|
|
177
|
+
catch (error) {
|
|
178
|
+
componentContracts = {
|
|
179
|
+
status: 'failed', resources: [], reference_count: 0, reason: null,
|
|
180
|
+
error: { code: error.code || 'COMPONENT_VALIDATION_FAILED', message: error.message },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
result.remote_validation.component_contracts = componentContracts;
|
|
185
|
+
const code = (result.code || componentContracts.status === 'failed') ? 1 : 0;
|
|
186
|
+
delete result.code;
|
|
187
|
+
console.log(JSON.stringify(result, null, 2));
|
|
188
|
+
if (code !== 0) return code;
|
|
189
|
+
} else {
|
|
190
|
+
if (flags.remote) {
|
|
191
|
+
try { await verifyPageComponents(projectDir, selected); }
|
|
192
|
+
catch (error) { log.err(error.message); return 1; }
|
|
193
|
+
}
|
|
194
|
+
const checkCode = await check(projectDir, [], checkOptions);
|
|
195
|
+
if (checkCode !== 0) return checkCode;
|
|
196
|
+
}
|
|
155
197
|
|
|
156
198
|
if (!visual.run) {
|
|
157
199
|
if (url) log.warn('--url was provided without --screenshot always or --ui always; visual verification was skipped.');
|
|
@@ -186,3 +228,4 @@ module.exports.resources = resources;
|
|
|
186
228
|
module.exports.viewports = viewports;
|
|
187
229
|
module.exports.visualRequest = visualRequest;
|
|
188
230
|
module.exports.verifyPageComponents = verifyPageComponents;
|
|
231
|
+
module.exports.componentTargets = componentTargets;
|
package/src/projectConfig.js
CHANGED
|
@@ -6,6 +6,7 @@ const { exists, ensureDir, appendGitignoreLine } = require('./fsx');
|
|
|
6
6
|
|
|
7
7
|
const IGNORE_ENTRIES = [
|
|
8
8
|
'.draftgo/config.json',
|
|
9
|
+
'.draftgo/credential-recovery.json',
|
|
9
10
|
'.draftgo/token',
|
|
10
11
|
'.draftgo/worktree/',
|
|
11
12
|
'.draftgo/conflicts/',
|
|
@@ -134,6 +135,7 @@ module.exports = {
|
|
|
134
135
|
normalizeMcpEndpoint,
|
|
135
136
|
normalizeConnection,
|
|
136
137
|
ensureProjectIgnores,
|
|
138
|
+
writePrivateJson,
|
|
137
139
|
writeProjectConfig,
|
|
138
140
|
loadProjectConfig,
|
|
139
141
|
};
|
package/src/projectHealth.js
CHANGED
|
@@ -4,7 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const runtime = require('./runtimeFiles');
|
|
6
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'];
|
|
7
|
+
const PROTECTED_PREFIXES = ['worktree/', 'conflicts/', 'lessons/', 'tmp/', 'config.json', 'credential-recovery.json', 'api-contract-cache.json', 'api-contract-cache.json.lock', 'worklog.md', 'story.yaml', '.version', 'runtime-manifest.json'];
|
|
8
8
|
function projectHealth(projectDir) {
|
|
9
9
|
const root = runtime.draftgoRoot(projectDir); const registered = new Set(runtime.load(projectDir).entries.map((entry) => String(entry.path).replace(/\\/g, '/')));
|
|
10
10
|
const summary = { managed_files: 0, unknown_files: 0, temporary_files: 0, artifact_files: 0, total_bytes: 0, reclaimable_bytes: 0, warnings: [] };
|