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 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` 管理用户组及其角色。交互式 CLI、MCP 和普通 HTTP API 均使用当前用户的 API Key,并按同一基础 RBAC 与业务守卫授权;API Key 不能创建或伪造服务身份。未知能力始终通过 `api search/describe/call` 使用实时 Registry,不依赖固化权限表。
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.11",
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",
@@ -2,7 +2,7 @@
2
2
  "schema_version": "1.0",
3
3
  "id": "draftgo",
4
4
  "name": "DraftGo 开发助手",
5
- "version": "1.0.11",
5
+ "version": "1.0.13",
6
6
  "entry": "SKILL.md",
7
7
  "description": "以 Skill/reference 任务路由、MCP 实时发现、长正文 checkout/commit、统一验证和完成日志为边界的 DraftGo 工作流。",
8
8
  "license": "MIT",
@@ -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 nil, err
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 | 先查 |
@@ -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 { callOperation } = require('./api');
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;
@@ -753,3 +753,4 @@ module.exports = async function components(projectDir, positional = [], flags =
753
753
  };
754
754
  module.exports.expandHtml = expandHtml;
755
755
  module.exports.verifyDefinition = verifyDefinition;
756
+ module.exports.findComponent = findComponent;
@@ -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.
@@ -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 pages = selected.filter(item => item.resourceType === 'pages');
69
- if (!pages.length) return [];
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
- for (const page of pages) {
74
- const entry = getEntry(manifest, 'pages', page.resourceId);
75
- if (!entry) continue;
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(`pages ${page.resourceId}: invalid data-dg-use "${name}"`);
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(`pages ${page.resourceId}: duplicate data-dg-instance "${instance}"`); instances.add(instance); }
87
- uses.push({ page: page.resourceId, node, attrs, name });
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
- 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);
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
- 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}`);
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(`pages ${use.page}: ${use.name} requires <${rootTag}> but found <${use.node.nodeName}>`);
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(`pages ${use.page}: unknown prop ${name} on ${use.name}`);
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(`pages ${use.page}: invalid boolean prop ${name} on ${use.name}`);
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(`pages ${use.page}: invalid number prop ${name} on ${use.name}`);
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(`pages ${use.page}: unknown slot ${slot} on ${use.name}`);
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
- try { if (flags.remote) await verifyPageComponents(projectDir, selected); }
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 (checkCode !== 0) return checkCode;
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;
@@ -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
  };
@@ -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: [] };