@chenguangyao/devflow-kit 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/CHANGELOG.md +232 -0
  2. package/LICENSE +21 -0
  3. package/README.md +539 -0
  4. package/bin/devflow.js +9 -0
  5. package/docs/RFC-001-devflow-kit.md +617 -0
  6. package/docs/RFC-002-workflow-kernel.md +134 -0
  7. package/docs/enterprise-integration-supplement.md +274 -0
  8. package/docs/internal-gitlab-setup.md +426 -0
  9. package/docs/marketplace-skills.md +231 -0
  10. package/docs/migration-from-arb.md +232 -0
  11. package/docs/tooling-overview.md +774 -0
  12. package/docs/workflow-orchestration.md +695 -0
  13. package/docs/workflow-ui-prototype.html +271 -0
  14. package/package.json +52 -0
  15. package/schemas/config.schema.json +51 -0
  16. package/schemas/delta.schema.json +22 -0
  17. package/schemas/state.schema.json +130 -0
  18. package/schemas/status-surface.schema.json +197 -0
  19. package/schemas/workflow-confirmation-surface.schema.json +70 -0
  20. package/schemas/workflow-picker.schema.json +94 -0
  21. package/scripts/postinstall.js +101 -0
  22. package/scripts/render-workflow-ui-prototype.js +271 -0
  23. package/skills/apply/SKILL.md +313 -0
  24. package/skills/apply/references/discipline-checklist.md +145 -0
  25. package/skills/apply/references/subagent-implementer-prompt.md +113 -0
  26. package/skills/apply/references/subagent-orchestration.md +150 -0
  27. package/skills/apply/references/subagent-reviewer-prompt.md +180 -0
  28. package/skills/apply/references/tdd-loop.md +287 -0
  29. package/skills/apply/references/when-plan-is-wrong.md +279 -0
  30. package/skills/apply/references/worktree-swarm.md +292 -0
  31. package/skills/archive/SKILL.md +229 -0
  32. package/skills/archive/references/conflict-resolution.md +336 -0
  33. package/skills/archive/references/knowledge-deposit.md +381 -0
  34. package/skills/archive/references/spec-merge.md +365 -0
  35. package/skills/brainstorm/SKILL.md +123 -0
  36. package/skills/brainstorm/references/proposal-template.md +244 -0
  37. package/skills/brainstorm/references/question-catalog.md +168 -0
  38. package/skills/brainstorm/references/session-template.md +184 -0
  39. package/skills/ci-fix/SKILL.md +63 -0
  40. package/skills/ci-fix/references/loop.md +25 -0
  41. package/skills/code-review/SKILL.md +279 -0
  42. package/skills/code-review/references/escalation-playbook.md +192 -0
  43. package/skills/code-review/references/language-cheatsheets/go.md +175 -0
  44. package/skills/code-review/references/language-cheatsheets/java-spring-mybatis.md +246 -0
  45. package/skills/code-review/references/language-cheatsheets/python.md +170 -0
  46. package/skills/code-review/references/language-cheatsheets/vue.md +199 -0
  47. package/skills/code-review/references/output-template.md +275 -0
  48. package/skills/code-review/references/review-checklist.md +251 -0
  49. package/skills/complexity-grading/SKILL.md +259 -0
  50. package/skills/deliver/SKILL.md +271 -0
  51. package/skills/deliver/references/delivery-modes.md +299 -0
  52. package/skills/deliver/references/notify.md +359 -0
  53. package/skills/deliver/references/pr-description.md +319 -0
  54. package/skills/dependency-upgrade/SKILL.md +57 -0
  55. package/skills/dependency-upgrade/references/risk-matrix.md +38 -0
  56. package/skills/df-orchestrator/SKILL.md +407 -0
  57. package/skills/df-orchestrator/references/complexity-grading.md +177 -0
  58. package/skills/df-orchestrator/references/escalation-matrix.md +191 -0
  59. package/skills/df-orchestrator/references/routing-rules.md +290 -0
  60. package/skills/df-orchestrator/references/workflow-state-machine.md +208 -0
  61. package/skills/frontend-quality/SKILL.md +61 -0
  62. package/skills/frontend-quality/references/checklist.md +35 -0
  63. package/skills/handoff-resume/SKILL.md +59 -0
  64. package/skills/handoff-resume/references/handoff-template.md +54 -0
  65. package/skills/plan/SKILL.md +166 -0
  66. package/skills/plan/references/task-breakdown.md +207 -0
  67. package/skills/plan/references/task-sequencing.md +143 -0
  68. package/skills/plan/references/task-template.md +248 -0
  69. package/skills/requirement-analysis/SKILL.md +499 -0
  70. package/skills/requirement-analysis/references/acceptance-criteria.md +183 -0
  71. package/skills/requirement-analysis/references/code-recon.md +151 -0
  72. package/skills/requirement-analysis/references/edge-case-catalog.md +164 -0
  73. package/skills/requirement-analysis/references/requirement-template.md +339 -0
  74. package/skills/requirement-analysis/references/scope-negotiation.md +162 -0
  75. package/skills/security-hardening/SKILL.md +60 -0
  76. package/skills/security-hardening/references/checklist.md +42 -0
  77. package/skills/tech-spec/SKILL.md +388 -0
  78. package/skills/tech-spec/references/api-contract-design.md +172 -0
  79. package/skills/tech-spec/references/decision-records.md +110 -0
  80. package/skills/tech-spec/references/design-template.md +301 -0
  81. package/skills/tech-spec/references/rollout-and-rollback.md +203 -0
  82. package/skills/tech-spec/references/spec-delta-conventions.md +250 -0
  83. package/skills/tech-spec/references/transaction-patterns.md +212 -0
  84. package/skills/test-spec/SKILL.md +219 -0
  85. package/skills/test-spec/references/coverage-strategy.md +218 -0
  86. package/skills/test-spec/references/edge-case-to-test.md +143 -0
  87. package/skills/test-spec/references/test-case-template.md +276 -0
  88. package/skills/verify/SKILL.md +232 -0
  89. package/skills/verify/references/nfr-verification.md +292 -0
  90. package/skills/verify/references/report-templates.md +510 -0
  91. package/skills/verify/references/self-test-guide.md +240 -0
  92. package/skills/verify/references/verify-rollback-map.md +247 -0
  93. package/src/cli/commands/_helpers.js +108 -0
  94. package/src/cli/commands/_submit.js +718 -0
  95. package/src/cli/commands/apply.js +198 -0
  96. package/src/cli/commands/archive.js +180 -0
  97. package/src/cli/commands/checkpoint.js +113 -0
  98. package/src/cli/commands/deliver.js +377 -0
  99. package/src/cli/commands/deploy.js +504 -0
  100. package/src/cli/commands/design.js +158 -0
  101. package/src/cli/commands/disable.js +21 -0
  102. package/src/cli/commands/doctor.js +178 -0
  103. package/src/cli/commands/enable.js +21 -0
  104. package/src/cli/commands/flow.js +645 -0
  105. package/src/cli/commands/help.js +93 -0
  106. package/src/cli/commands/ingest.js +602 -0
  107. package/src/cli/commands/init.js +341 -0
  108. package/src/cli/commands/knowledge.js +523 -0
  109. package/src/cli/commands/logs.js +43 -0
  110. package/src/cli/commands/new.js +202 -0
  111. package/src/cli/commands/plan.js +49 -0
  112. package/src/cli/commands/propose.js +27 -0
  113. package/src/cli/commands/provider.js +698 -0
  114. package/src/cli/commands/report.js +143 -0
  115. package/src/cli/commands/requirement.js +227 -0
  116. package/src/cli/commands/review.js +301 -0
  117. package/src/cli/commands/skills.js +457 -0
  118. package/src/cli/commands/status.js +925 -0
  119. package/src/cli/commands/switch.js +27 -0
  120. package/src/cli/commands/sync.js +47 -0
  121. package/src/cli/commands/test.js +366 -0
  122. package/src/cli/commands/uninstall.js +32 -0
  123. package/src/cli/commands/update.js +74 -0
  124. package/src/cli/commands/verify.js +354 -0
  125. package/src/cli/commands/worktree.js +78 -0
  126. package/src/cli/index.js +72 -0
  127. package/src/cli/parse-args.js +102 -0
  128. package/src/core/autodetect.js +271 -0
  129. package/src/core/change.js +208 -0
  130. package/src/core/checkpoint.js +217 -0
  131. package/src/core/config.js +60 -0
  132. package/src/core/delta.js +290 -0
  133. package/src/core/markers.js +59 -0
  134. package/src/core/paths.js +173 -0
  135. package/src/core/plan-tasks.js +36 -0
  136. package/src/core/project-routing.js +285 -0
  137. package/src/core/projects.js +200 -0
  138. package/src/core/state.js +200 -0
  139. package/src/core/workflow-check.js +177 -0
  140. package/src/core/workflow-init.js +34 -0
  141. package/src/core/workflow-picker.js +154 -0
  142. package/src/core/workflow-policy.js +119 -0
  143. package/src/core/workflow-suggest.js +181 -0
  144. package/src/core/workflow-verify.js +88 -0
  145. package/src/core/workflow.js +433 -0
  146. package/src/core/worktree.js +241 -0
  147. package/src/knowledge/categories.js +107 -0
  148. package/src/knowledge/classify.js +125 -0
  149. package/src/knowledge/deposit.js +414 -0
  150. package/src/knowledge/migrate.js +149 -0
  151. package/src/knowledge/mr.js +219 -0
  152. package/src/knowledge/query.js +131 -0
  153. package/src/knowledge/registry.js +151 -0
  154. package/src/knowledge/sync.js +179 -0
  155. package/src/providers/base.js +74 -0
  156. package/src/providers/drivers/api-yapi.js +78 -0
  157. package/src/providers/drivers/ci-jenkins.js +109 -0
  158. package/src/providers/drivers/intake-confluence.js +544 -0
  159. package/src/providers/drivers/kb-git.js +549 -0
  160. package/src/providers/drivers/kb-weknora.js +472 -0
  161. package/src/providers/drivers/notify-smtp.js +515 -0
  162. package/src/providers/drivers/observability-oss.js +43 -0
  163. package/src/providers/drivers/observability-sls.js +50 -0
  164. package/src/providers/lifecycle.js +135 -0
  165. package/src/providers/loader.js +132 -0
  166. package/src/providers/local.js +190 -0
  167. package/src/providers/userconfig.js +283 -0
  168. package/src/reports/aggregate.js +185 -0
  169. package/src/reports/coverage.js +163 -0
  170. package/src/reports/detect.js +143 -0
  171. package/src/reports/parse.js +236 -0
  172. package/src/templates/files/ci/github.yml +38 -0
  173. package/src/templates/files/ci/gitlab.yml +27 -0
  174. package/src/templates/files/design.md +63 -0
  175. package/src/templates/files/ide/devflow-workflow.md +58 -0
  176. package/src/templates/files/ide/project-overview-reference.md +1 -0
  177. package/src/templates/files/ide/project-overview.md +27 -0
  178. package/src/templates/files/knowledge-index.json +17 -0
  179. package/src/templates/files/knowledge.md +28 -0
  180. package/src/templates/files/meta.json +8 -0
  181. package/src/templates/files/plan.md +38 -0
  182. package/src/templates/files/proposal.md +33 -0
  183. package/src/templates/files/reports/contract-test.md +40 -0
  184. package/src/templates/files/reports/e2e-test.md +30 -0
  185. package/src/templates/files/reports/integration-test.md +36 -0
  186. package/src/templates/files/reports/joint-test.md +58 -0
  187. package/src/templates/files/reports/perf.md +24 -0
  188. package/src/templates/files/reports/regression.md +20 -0
  189. package/src/templates/files/reports/remote-test.md +55 -0
  190. package/src/templates/files/reports/self-test.md +43 -0
  191. package/src/templates/files/reports/smoke-test.md +22 -0
  192. package/src/templates/files/reports/unit-test.md +36 -0
  193. package/src/templates/files/requirement.md +51 -0
  194. package/src/templates/files/review.md +38 -0
  195. package/src/templates/files/tests.md +36 -0
  196. package/src/templates/files/verify.md +32 -0
  197. package/src/templates/index.js +21 -0
  198. package/src/utils/log.js +37 -0
@@ -0,0 +1,698 @@
1
+ 'use strict';
2
+ const fs = require('fs/promises');
3
+ const fsSync = require('fs');
4
+ const readline = require('readline');
5
+ const log = require('../../utils/log.js');
6
+ const paths = require('../../core/paths.js');
7
+ const providers = require('../../providers/loader.js');
8
+ const lifecycle = require('../../providers/lifecycle.js');
9
+ const userconfig = require('../../providers/userconfig.js');
10
+ const projects = require('../../core/projects.js');
11
+
12
+ /**
13
+ * Resolve the providers.json file path for a given scope.
14
+ *
15
+ * --scope=user (default) → ~/.devflow/providers.json
16
+ * --scope=project → <repo>/devflow/providers.json
17
+ *
18
+ * "user" is default because that's the recommended single source of truth across
19
+ * all projects; project-level is reserved for repo-specific overrides (e.g. a
20
+ * project that needs its own kb endpoint different from the team default).
21
+ */
22
+ function scopeFile(root, flags) {
23
+ const scope = flags.scope || 'user';
24
+ if (scope === 'project') return { scope, file: paths.projectProvidersFile(root) };
25
+ if (scope === 'user' || scope === 'global') return { scope: 'user', file: paths.userProvidersFile() };
26
+ throw new Error(`unknown --scope: ${scope} (expected: user | project)`);
27
+ }
28
+
29
+ async function run({ sub, flags = {}, positional = [], cwd }) {
30
+ const root = cwd || process.cwd();
31
+ const action = sub || positional[0];
32
+ switch (action) {
33
+ case 'list': return list(root);
34
+ case 'status': return status(root, positional[0] || flags.name);
35
+ case 'add': return add(root, positional[0] || flags.name, flags);
36
+ case 'setup': return setup(root, flags);
37
+ case 'import': return importConfig(root, positional[0] || flags.format, flags);
38
+ case 'remove': return remove(root, positional[0] || flags.name, flags);
39
+ case 'relogin': return relogin(root, positional[0] || flags.name, flags);
40
+ case 'rotate': return rotate(root, positional[0] || flags.name, flags);
41
+ case 'logout': return logout(root, positional[0] || flags.name);
42
+ case 'audit': return audit(root);
43
+ case undefined:
44
+ case 'help': return help();
45
+ default:
46
+ log.error(`unknown subcommand: ${action}`);
47
+ help();
48
+ process.exitCode = 2;
49
+ }
50
+ }
51
+
52
+ async function importConfig(_root, format, flags) {
53
+ if (!format || !flags.from) {
54
+ log.error('usage: devflow provider import publish-code --from=<file.json>');
55
+ process.exitCode = 2;
56
+ return;
57
+ }
58
+ if (format !== 'publish-code') {
59
+ log.error(`unknown import format: ${format}`);
60
+ process.exitCode = 2;
61
+ return;
62
+ }
63
+ const result = await projects.importPublishCodeConfig(require('path').resolve(_root, flags.from));
64
+ log.ok(`imported ${result.imported} project(s) into ${result.file}`);
65
+ if (result.skipped) log.warn(`skipped ${result.skipped} malformed project(s)`);
66
+ log.dim('credentials were not imported; use env vars or devflow provider rotate for Jenkins/GitLab secrets.');
67
+ }
68
+
69
+ async function list(root) {
70
+ const arr = await providers.listConfigured(root);
71
+ if (!arr.length) {
72
+ log.info('no providers configured. defaults: local for every type.');
73
+ return;
74
+ }
75
+ for (const p of arr) log.raw(` ${p.name.padEnd(28)} ${p.type.padEnd(8)} ${p.driver.padEnd(14)} [${p.source}]`);
76
+ }
77
+
78
+ async function status(root, name) {
79
+ if (!name) {
80
+ const arr = await providers.listConfigured(root);
81
+ if (!arr.length) { log.info('no providers configured.'); return; }
82
+ let bad = 0;
83
+ for (const p of arr) {
84
+ const s = await statusOne(root, p.name);
85
+ if (!s.ok) bad++;
86
+ }
87
+ if (bad > 0) process.exitCode = 1;
88
+ return;
89
+ }
90
+ const s = await statusOne(root, name);
91
+ if (!s.ok) process.exitCode = 1;
92
+ }
93
+
94
+ async function statusOne(root, name) {
95
+ let p;
96
+ try { p = await providers.load(root, { name }); }
97
+ catch (e) {
98
+ log.warn(`${name.padEnd(28)} load-error: ${e.message.split('\n')[0]}`);
99
+ return { ok: false };
100
+ }
101
+ let v;
102
+ try { v = await p.validate(); }
103
+ catch (e) { log.warn(`${name.padEnd(28)} validate-error: ${e.message}`); return { ok: false }; }
104
+ if (v.ok) {
105
+ log.ok(`${name.padEnd(28)} ok`);
106
+ return { ok: true };
107
+ }
108
+ const flag = v.needsLogin ? '!login' : '!error';
109
+ log.warn(`${name.padEnd(28)} ${flag.padEnd(8)} ${v.reason || 'unknown'}`);
110
+ return { ok: false };
111
+ }
112
+
113
+ async function add(root, name, flags) {
114
+ if (!name) {
115
+ log.error('usage: devflow provider add <name> [--scope=user|project] [--type=<t>] [--driver=<d>] [--config-key=value ...] [--from=<file.json>]');
116
+ process.exitCode = 2;
117
+ return;
118
+ }
119
+ // Default scope is "user" (~/.devflow/providers.json) so all credentials
120
+ // live in one place. `--scope=project` writes to <repo>/devflow/providers.json
121
+ // for repo-specific overrides.
122
+ const { scope, file } = scopeFile(root, flags);
123
+ if (scope === 'user') await userconfig.ensureUserConfig();
124
+ let cur = {};
125
+ if (fsSync.existsSync(file)) cur = JSON.parse(await fs.readFile(file, 'utf8'));
126
+ const existing = cur[name] || { type: '', driver: '', config: {} };
127
+
128
+ // --- Step 1: Import seed fragment from --from <file> ---
129
+ // The file is a JSON object. If it contains a key matching <name>, that
130
+ // sub-object (type/driver/config) is used as the seed. Otherwise the file
131
+ // itself is treated as a config{} fragment.
132
+ let imported = null;
133
+ if (flags.from) {
134
+ const fromPath = require('path').resolve(root, flags.from);
135
+ if (!fsSync.existsSync(fromPath)) {
136
+ log.error(`--from: file not found: ${fromPath}`);
137
+ process.exitCode = 2;
138
+ return;
139
+ }
140
+ try {
141
+ const raw = JSON.parse(await fs.readFile(fromPath, 'utf8'));
142
+ if (raw && raw[name] && typeof raw[name] === 'object') {
143
+ imported = raw[name];
144
+ } else if (raw && (raw.type || raw.driver || raw.config)) {
145
+ imported = raw;
146
+ } else {
147
+ imported = { config: raw }; // treat entire file as config{}
148
+ }
149
+ log.dim(`imported seed from ${flags.from}`);
150
+ } catch (e) {
151
+ log.error(`--from: invalid JSON: ${e.message}`);
152
+ process.exitCode = 2;
153
+ return;
154
+ }
155
+ }
156
+
157
+ const interactive = isTty();
158
+ let type = flags.type || (imported && imported.type) || existing.type;
159
+ let driver = flags.driver || (imported && imported.driver) || existing.driver;
160
+
161
+ if (!type && interactive) type = await prompt(`type (intake|issue|vcs|ci|notify|kb|api|observability): `, existing.type);
162
+ if (!driver && interactive) driver = await prompt(`driver (local|confluence|weknora|smtp|github|...): `, existing.driver);
163
+ if (!type || !driver) { log.error('--type and --driver are required'); process.exitCode = 2; return; }
164
+
165
+ // --- Step 2: merge config from existing + imported + --config-*= flags ---
166
+ const config = {
167
+ ...(existing.config || {}),
168
+ ...((imported && imported.config) || {}),
169
+ };
170
+ for (const k of Object.keys(flags)) {
171
+ if (!k.startsWith('config-')) continue;
172
+ const ck = k.slice('config-'.length);
173
+ setConfigValue(config, ck, flags[k]);
174
+ }
175
+
176
+ // --- Step 3: interactive guided prompts (skipped if --no-prompt) ---
177
+ if (interactive && !flags.noPrompt) {
178
+ log.dim(`(press enter to keep existing values, prefix with 'env:' to use \${env:VAR})`);
179
+ const known = configHints(driver);
180
+ for (const hint of known) {
181
+ if (hint.help) log.dim(` # ${hint.help}`);
182
+ const curVal = getConfigValue(config, hint.key);
183
+ const defShown = formatConfigValue(curVal, hint.default);
184
+ const suffix = hint.choices ? ` [${hint.choices.join(' | ')}]` : '';
185
+ const val = await prompt(`${hint.key}${hint.required ? '*' : ''}${suffix} = `, defShown);
186
+ if (val === '') continue;
187
+ if (hint.choices && !hint.choices.includes(val)) {
188
+ log.warn(`value "${val}" not in [${hint.choices.join(', ')}] — stored anyway`);
189
+ }
190
+ if (val.startsWith('env:')) setConfigValue(config, hint.key, `\${env:${val.slice(4)}}`);
191
+ else if (hint.cast === 'number') setConfigValue(config, hint.key, Number(val));
192
+ else if (hint.cast === 'boolean') setConfigValue(config, hint.key, /^(1|true|yes|y)$/i.test(val));
193
+ else setConfigValue(config, hint.key, val);
194
+ }
195
+ }
196
+
197
+ cur[name] = { type, driver, config };
198
+ await fs.mkdir(require('path').dirname(file), { recursive: true });
199
+ await fs.writeFile(file, JSON.stringify(cur, null, 2) + '\n', 'utf8');
200
+ await lifecycle.applySecureMode(file);
201
+ log.ok(`saved [${scope}]: ${name} -> ${file} (mode 0600)`);
202
+
203
+ if (interactive && !flags.noVerify) {
204
+ log.info('verifying ...');
205
+ await statusOne(root, name);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Catalogue of "one-line" provider presets driven by the wizard.
211
+ *
212
+ * Each entry maps to (type, driver, default name) so the wizard can:
213
+ * 1. Show a numbered list and let the user pick (or "all"/"none").
214
+ * 2. Reuse the existing `configHints(driver)` prompts.
215
+ * 3. Save under a sensible default name like "kb.weknora", "notify.smtp", etc.
216
+ *
217
+ * Adding a new driver = add an entry here + extend `configHints()`. No other
218
+ * wiring needed — the wizard auto-picks it up.
219
+ */
220
+ const SETUP_PRESETS = [
221
+ { name: 'intake.confluence', type: 'intake', driver: 'confluence',
222
+ label: 'Intake — Confluence (内置,零外部依赖)',
223
+ hint: '支持 Server/Data Center 和 Cloud;需要 baseUrl + PAT' },
224
+ { name: 'notify.smtp', type: 'notify', driver: 'smtp',
225
+ label: 'Notify — Email via SMTP (任意 SMTP 服务器)',
226
+ hint: '比如 smtp.exmail.qq.com / 465 / 用户名 / 应用密钥' },
227
+ { name: 'kb.weknora', type: 'kb', driver: 'weknora',
228
+ label: 'Knowledge base — WeKnora-compatible API',
229
+ hint: '需要 endpoint + kbId + token,可走 ${env:WEKNORA_API_KEY}' },
230
+ { name: 'kb.git', type: 'kb', driver: 'git',
231
+ label: 'Knowledge base — 独立 Git 仓库(类 arb-knowledge 模式)',
232
+ hint: '知识库是独立 GitLab/GitHub 项目;deposit --mr 会在那边建分支 + 创 MR' },
233
+ { name: 'ci.jenkins', type: 'ci', driver: 'jenkins',
234
+ label: 'CI — Jenkins build/deploy jobs',
235
+ hint: '用于 devflow deploy 触发测试/预发 Jenkins job;凭证建议走 env:JENKINS_TOKEN' },
236
+ { name: 'api.yapi', type: 'api', driver: 'yapi',
237
+ label: 'API — YAPI contract sync',
238
+ hint: '把 OpenAPI/Swagger 或接口说明同步到 YAPI;凭证建议走 env:YAPI_TOKEN' },
239
+ { name: 'observability.sls', type: 'observability', driver: 'sls',
240
+ label: 'Observability — Aliyun SLS log query',
241
+ hint: '用于提测/verify 时按 traceId、requestId 或关键词检索测试环境日志' },
242
+ { name: 'observability.oss', type: 'observability', driver: 'oss',
243
+ label: 'Observability — OSS log/object lookup',
244
+ hint: '用于从 OSS 归档日志或对象中检索提测证据' },
245
+ ];
246
+
247
+ /**
248
+ * Interactive multi-provider wizard. `devflow provider setup`.
249
+ *
250
+ * Flow:
251
+ * 1. List `SETUP_PRESETS` numbered; mark "[configured]" if already set.
252
+ * 2. Ask which to (re)configure: comma-separated indices, "all", or "none".
253
+ * 3. For each pick, run guided prompts via the same `configHints()` table
254
+ * that `add()` uses — no new prompt code paths.
255
+ * 4. Save each entry to providers.json (default --scope=user) with chmod 0600.
256
+ * 5. Final pass: `statusOne()` per saved provider so the user sees green/red.
257
+ *
258
+ * Non-TTY:
259
+ * Refuse early. Setup is wizard-only; for CI / scripted setups, use
260
+ * `devflow provider add --no-prompt --type=… --driver=… --config-key=…`.
261
+ */
262
+ async function setup(root, flags = {}) {
263
+ if (!isTty()) {
264
+ log.error('setup is interactive only; in non-TTY contexts use "devflow provider add --no-prompt ..."');
265
+ process.exitCode = 2;
266
+ return;
267
+ }
268
+
269
+ const { scope, file } = scopeFile(root, flags);
270
+ if (scope === 'user') await userconfig.ensureUserConfig();
271
+ let cur = {};
272
+ if (fsSync.existsSync(file)) {
273
+ try { cur = JSON.parse(await fs.readFile(file, 'utf8')); } catch { cur = {}; }
274
+ }
275
+
276
+ log.raw('');
277
+ log.raw('devflow provider setup — interactive wizard');
278
+ log.raw(`writing to: ${file} (scope=${scope})`);
279
+ log.raw('');
280
+ log.raw('available providers:');
281
+ SETUP_PRESETS.forEach((p, i) => {
282
+ const marked = cur[p.name] ? ' [configured]' : '';
283
+ log.raw(` ${String(i + 1).padStart(2)}. ${p.label}${marked}`);
284
+ if (p.hint) log.dim(` ${p.hint}`);
285
+ });
286
+ log.raw('');
287
+
288
+ const sel = (await prompt('select (e.g. "1,3,5", "all", or "none"): ', 'all')).trim();
289
+ const picks = parseSelection(sel, SETUP_PRESETS.length);
290
+ if (!picks.length) { log.info('nothing selected, exiting'); return; }
291
+
292
+ const saved = [];
293
+ for (const idx of picks) {
294
+ const p = SETUP_PRESETS[idx];
295
+ log.raw('');
296
+ log.raw(`── ${p.label} ──`);
297
+ if (cur[p.name]) {
298
+ const reuse = await prompt(`already configured. (k)eep / (e)dit / (s)kip: `, 'k');
299
+ if (/^s/i.test(reuse)) { log.dim(' skipped'); continue; }
300
+ if (/^k/i.test(reuse)) { log.dim(' kept as-is'); saved.push(p.name); continue; }
301
+ }
302
+ const existing = cur[p.name] || { config: {} };
303
+ const config = { ...(existing.config || {}) };
304
+
305
+ log.dim(` (press enter to keep existing/default; prefix with "env:" to use \${env:VAR})`);
306
+ for (const hint of configHints(p.driver)) {
307
+ if (hint.help) log.dim(` # ${hint.help}`);
308
+ const curVal = getConfigValue(config, hint.key);
309
+ const defShown = formatConfigValue(curVal, hint.default);
310
+ const suffix = hint.choices ? ` [${hint.choices.join(' | ')}]` : '';
311
+ const val = await prompt(` ${hint.key}${hint.required ? '*' : ''}${suffix} = `, defShown);
312
+ if (val === '') {
313
+ if (hint.required && !defShown) {
314
+ log.warn(` ${hint.key} is required; you can fill later via "devflow provider rotate ${p.name}"`);
315
+ }
316
+ continue;
317
+ }
318
+ if (hint.choices && !hint.choices.includes(val)) {
319
+ log.warn(` value "${val}" not in [${hint.choices.join(', ')}] — stored anyway`);
320
+ }
321
+ if (val.startsWith('env:')) setConfigValue(config, hint.key, `\${env:${val.slice(4)}}`);
322
+ else if (hint.cast === 'number') setConfigValue(config, hint.key, Number(val));
323
+ else if (hint.cast === 'boolean') setConfigValue(config, hint.key, /^(1|true|yes|y)$/i.test(val));
324
+ else setConfigValue(config, hint.key, val);
325
+
326
+ // afterPrompt: field-level side-effect hook (e.g. open browser after baseUrl is entered)
327
+ if (typeof hint.afterPrompt === 'function') {
328
+ await hint.afterPrompt(val, config);
329
+ }
330
+ }
331
+ normalizeSetupConfig(p.driver, config);
332
+
333
+ cur[p.name] = { type: p.type, driver: p.driver, config };
334
+ saved.push(p.name);
335
+ log.ok(` → ${p.name} ready`);
336
+ }
337
+
338
+ if (!saved.length) { log.info('no changes saved'); return; }
339
+
340
+ await fs.mkdir(require('path').dirname(file), { recursive: true });
341
+ await fs.writeFile(file, JSON.stringify(cur, null, 2) + '\n', 'utf8');
342
+ await lifecycle.applySecureMode(file);
343
+ log.raw('');
344
+ log.ok(`saved ${saved.length} provider(s) to ${file} (mode 0600)`);
345
+
346
+ if (!flags.noVerify) {
347
+ log.raw('');
348
+ log.info('verifying ...');
349
+ let bad = 0;
350
+ for (const n of saved) {
351
+ const s = await statusOne(root, n);
352
+ if (!s.ok) bad++;
353
+ }
354
+ if (bad > 0) {
355
+ log.raw('');
356
+ log.warn(`${bad}/${saved.length} provider(s) need attention`);
357
+ log.dim(' fix incomplete fields: devflow provider rotate <name>');
358
+ log.dim(' re-run wizard: devflow provider setup');
359
+ process.exitCode = 1;
360
+ } else {
361
+ log.raw('');
362
+ log.ok('all configured providers passed validation');
363
+ }
364
+ }
365
+
366
+ log.raw('');
367
+ log.dim('next:');
368
+ log.dim(' devflow provider list # see what is configured');
369
+ log.dim(' devflow provider audit # security check (plaintext tokens, file modes)');
370
+ log.dim(' devflow provider rotate <n> # rotate token / fix specific field');
371
+ }
372
+
373
+ /**
374
+ * Parse a selection string from the wizard.
375
+ * "all" → all indices
376
+ * "none" / "" → []
377
+ * "1,3,5" or "1 3" → [0,2,4] (0-based, dedup'd, in-range)
378
+ *
379
+ * Out-of-range / non-numeric entries are silently skipped (the wizard prints
380
+ * the menu again if nothing valid is picked, by virtue of an empty result).
381
+ */
382
+ function parseSelection(input, count) {
383
+ const s = (input || '').trim().toLowerCase();
384
+ if (!s || s === 'none' || s === 'no' || s === 'n') return [];
385
+ if (s === 'all' || s === 'a' || s === '*') {
386
+ return Array.from({ length: count }, (_, i) => i);
387
+ }
388
+ const seen = new Set();
389
+ for (const tok of s.split(/[,\s]+/)) {
390
+ const n = parseInt(tok, 10);
391
+ if (Number.isFinite(n) && n >= 1 && n <= count) seen.add(n - 1);
392
+ }
393
+ return Array.from(seen).sort((a, b) => a - b);
394
+ }
395
+
396
+ function configHints(driver) {
397
+ switch (driver) {
398
+ case 'weknora':
399
+ return [
400
+ { key: 'endpoint', required: true, help: 'base URL of WeKnora (or WeKnora-compatible) server' },
401
+ { key: 'kb_id', required: true, help: 'target knowledge base UUID' },
402
+ { key: 'token', required: true, help: 'API key — prefer env:WEKNORA_API_KEY over literal' },
403
+ { key: 'idempotent', required: false, cast: 'boolean', choices: ['true', 'false'],
404
+ help: 'if true, upload does findByTitle→update-or-create (arb-style). Default: false.' },
405
+ ];
406
+ case 'smtp':
407
+ return [
408
+ { key: 'host', required: true, help: 'SMTP server hostname (e.g. smtp.exmail.qq.com)' },
409
+ { key: 'port', required: false, cast: 'number', default: 465, help: 'SMTP port (465=SMTPS, 587=STARTTLS, 25=plain)' },
410
+ { key: 'secure', required: false, cast: 'boolean', choices: ['true', 'false'],
411
+ help: 'true = implicit TLS (port 465). Auto-inferred from port if omitted.' },
412
+ { key: 'user', required: true, help: 'SMTP auth username; prefer env:SMTP_USER' },
413
+ { key: 'pass', required: true, help: 'SMTP auth password / app token; prefer env:SMTP_PASS' },
414
+ { key: 'authMethod', required: false, choices: ['auto', 'login', 'plain'],
415
+ help: 'AUTH method (default auto). Some servers reject one of LOGIN/PLAIN.' },
416
+ { key: 'from', required: false, help: 'default From address; falls back to user' },
417
+ { key: 'defaults.to', required: false, help: 'default recipients for devflow deliver --notify; comma-separated emails' },
418
+ { key: 'defaults.cc', required: false, help: 'default CC recipients for devflow deliver --notify; comma-separated emails' },
419
+ ];
420
+ case 'notion': return [{ key: 'token', required: true, help: 'Notion integration token' }, { key: 'database_id', required: false, help: 'target database ID' }];
421
+ case 'github': return [{ key: 'token', required: true, help: 'GitHub PAT; prefer env:GITHUB_TOKEN' }, { key: 'repo', required: false, help: '<owner>/<name>' }];
422
+ case 'gitlab': return [{ key: 'token', required: true, help: 'GitLab PAT; prefer env:GITLAB_TOKEN' }, { key: 'project_id', required: false, help: 'numeric project ID' }];
423
+ case 'jenkins':
424
+ return [
425
+ { key: 'baseUrl', required: false, help: 'Jenkins base URL; job URLs imported per project can omit this' },
426
+ { key: 'user', required: false, help: 'Jenkins username; prefer env:JENKINS_USER' },
427
+ { key: 'token', required: false, help: 'Jenkins API token; prefer env:JENKINS_TOKEN' },
428
+ { key: 'triggerMethod', required: false, choices: ['post', 'get'], help: 'default post; use get for legacy jobs that require query parameters' },
429
+ { key: 'jobDiscovery', required: false, cast: 'boolean', choices: ['true', 'false'], help: 'enable smart matching from project/git remote to likely Jenkins jobs' },
430
+ ];
431
+ case 'yapi':
432
+ return [
433
+ { key: 'baseUrl', required: true, help: 'YAPI base URL, e.g. https://yapi.corp.com' },
434
+ { key: 'token', required: true, help: 'YAPI project token; prefer env:YAPI_TOKEN' },
435
+ { key: 'projectId', required: false, help: 'YAPI project id, if token is not project-scoped' },
436
+ { key: 'categoryId', required: false, help: 'target category/menu id; optional' },
437
+ { key: 'syncPath', required: false, default: '/api/open/import_data', help: 'YAPI import endpoint path' },
438
+ ];
439
+ case 'sls':
440
+ return [
441
+ { key: 'endpoint', required: true, help: 'Aliyun SLS endpoint, e.g. cn-hangzhou.log.aliyuncs.com' },
442
+ { key: 'project', required: true, help: 'SLS project name' },
443
+ { key: 'logstore', required: true, help: 'SLS logstore name' },
444
+ { key: 'accessKeyId', required: true, help: 'prefer env:ALIYUN_ACCESS_KEY_ID' },
445
+ { key: 'accessKeySecret', required: true, help: 'prefer env:ALIYUN_ACCESS_KEY_SECRET' },
446
+ { key: 'queryCommand', required: false, help: 'optional local query command template; supports ${query}, ${traceId}, ${requestId}, ${since}, ${limit}' },
447
+ ];
448
+ case 'oss':
449
+ return [
450
+ { key: 'endpoint', required: true, help: 'Aliyun OSS endpoint' },
451
+ { key: 'bucket', required: true, help: 'OSS bucket name' },
452
+ { key: 'prefix', required: false, help: 'default log/object prefix' },
453
+ { key: 'accessKeyId', required: true, help: 'prefer env:ALIYUN_ACCESS_KEY_ID' },
454
+ { key: 'accessKeySecret', required: true, help: 'prefer env:ALIYUN_ACCESS_KEY_SECRET' },
455
+ { key: 'queryCommand', required: false, help: 'optional local query command template; supports ${query}, ${traceId}, ${requestId}, ${since}, ${limit}' },
456
+ ];
457
+ case 'feishu': return [{ key: 'app_id', required: true }, { key: 'app_secret', required: true }];
458
+ case 'yuque': return [{ key: 'token', required: true }, { key: 'namespace', required: true, help: '<user>/<repo>' }];
459
+ case 'confluence':
460
+ return [
461
+ {
462
+ key: 'baseUrl', required: true,
463
+ help: 'Confluence 实例根 URL(如 https://confluence.corp.com 或 https://myorg.atlassian.net)',
464
+ afterPrompt: (baseUrl) => {
465
+ const base = baseUrl.replace(/\/+$/, '');
466
+ log.raw('');
467
+ if (base.includes('.atlassian.net')) {
468
+ const patUrl = 'https://id.atlassian.com/manage/api-tokens';
469
+ log.info(`正在打开 Confluence Cloud API Token 页面…`);
470
+ log.dim(` ${patUrl}`);
471
+ openBrowser(patUrl);
472
+ } else {
473
+ log.info(`请在浏览器里找到 Personal Access Tokens(按顺序尝试):`);
474
+ log.raw(` 1) 右上角头像 → Profile → Personal Access Tokens`);
475
+ log.raw(` 2) ${base}/plugins/servlet/de.resolution.apitokenauth/token`);
476
+ log.raw(` 3) 若都没有,token 填密码 + user 填用户名(Basic Auth)`);
477
+ openBrowser(`${base}/plugins/servlet/de.resolution.apitokenauth/token`);
478
+ }
479
+ log.raw('');
480
+ },
481
+ },
482
+ { key: 'token', required: true, help: 'PAT 模式:粘贴 Personal Access Token\n # Basic Auth 模式(Confluence 较旧 / 无 PAT 插件):填登录密码,并在下一项填用户名' },
483
+ { key: 'user', required: false, help: 'Basic Auth 用户名(Confluence 登录名);留空 = Bearer/PAT 模式(推荐)' },
484
+ { key: 'timeoutMs', required: false, cast: 'number', help: '每次请求超时(ms),默认 15000' },
485
+ ];
486
+ case 'git':
487
+ return [
488
+ {
489
+ key: 'repoUrl', required: true,
490
+ help: '独立知识库仓库 Git clone URL\n # 例:git@code.corp.com:group/arb-knowledge.git\n # https://github.com/org/kb.git',
491
+ },
492
+ {
493
+ key: 'localPath', required: true,
494
+ help: '本地 clone 缓存路径\n # 推荐填绝对路径,如 ~/.devflow/kb/arb-knowledge',
495
+ },
496
+ {
497
+ key: 'targetBranch', required: false,
498
+ help: 'MR 目标分支,默认 main',
499
+ },
500
+ {
501
+ key: 'gitlabApiUrl', required: false,
502
+ help: 'GitLab API 根 URL(私有 GitLab)\n # 如 https://code.corp.com/api/v4\n # 留空 = 仅用 glab CLI 或 GitHub API',
503
+ afterPrompt: (v) => {
504
+ if (!v) return;
505
+ log.raw('');
506
+ log.info('接下来需要 GitLab Personal Access Token(至少 api 权限):');
507
+ const base = v.replace(/\/api\/v4\/?$/, '');
508
+ openBrowser(`${base}/-/user_settings/personal_access_tokens`);
509
+ log.raw('');
510
+ },
511
+ },
512
+ {
513
+ key: 'gitlabToken', required: false,
514
+ help: 'GitLab API token;prefer env:GITLAB_TOKEN\n # 权限:api(read_repository + write_repository + create_merge_requests)',
515
+ },
516
+ {
517
+ key: 'projectId', required: false,
518
+ help: 'GitLab project path(如 group/arb-knowledge)或数字 ID\n # 可在仓库首页 → Settings → General 看到',
519
+ },
520
+ {
521
+ key: 'githubToken', required: false,
522
+ help: 'GitHub PAT(仓库在 GitHub 时填此项);prefer env:GITHUB_TOKEN',
523
+ },
524
+ {
525
+ key: 'githubRepo', required: false,
526
+ help: 'GitHub 仓库 "owner/repo"(可从 repoUrl 自动解析,留空即可)',
527
+ },
528
+ {
529
+ key: 'mrReviewers', required: false,
530
+ help: '自动指定的 GitLab MR reviewer(逗号分隔 GitLab 用户名,可选)',
531
+ },
532
+ ];
533
+ default: return [];
534
+ }
535
+ }
536
+
537
+ function normalizeSetupConfig(driver, config) {
538
+ if (driver !== 'weknora') return config;
539
+ delete config.tag_id;
540
+ if (!config.tags) {
541
+ const { buildTagMap } = require('../../knowledge/categories.js');
542
+ config.tags = buildTagMap();
543
+ }
544
+ return config;
545
+ }
546
+
547
+ async function remove(root, name, flags = {}) {
548
+ if (!name) { log.error('usage: devflow provider remove <name> [--scope=user|project]'); process.exitCode = 2; return; }
549
+ const { scope, file } = scopeFile(root, flags);
550
+ if (!fsSync.existsSync(file)) { log.warn(`no providers.json at ${scope} scope (${file})`); return; }
551
+ const cur = JSON.parse(await fs.readFile(file, 'utf8'));
552
+ if (!cur[name]) { log.warn(`provider not found in ${scope} scope: ${name}`); return; }
553
+ delete cur[name];
554
+ await fs.writeFile(file, JSON.stringify(cur, null, 2) + '\n', 'utf8');
555
+ await lifecycle.applySecureMode(file);
556
+ log.ok(`removed [${scope}]: ${name}`);
557
+ }
558
+
559
+ async function relogin(root, name, flags) {
560
+ if (!name) { log.error('usage: df provider relogin <name> [--force]'); process.exitCode = 2; return; }
561
+ let p;
562
+ try { p = await providers.load(root, { name }); }
563
+ catch (e) { log.error(e.message); process.exitCode = 1; return; }
564
+ if (typeof p.login !== 'function') { log.warn(`${name} does not support login`); return; }
565
+ await p.login({ force: !!(flags.force), interactive: isTty() });
566
+ const v = await p.validate();
567
+ if (v.ok) log.ok(`${name}: re-login complete`);
568
+ else { log.error(`${name}: still ${v.reason}`); process.exitCode = 1; }
569
+ }
570
+
571
+ async function rotate(root, name, flags) {
572
+ if (!name) { log.error('usage: devflow provider rotate <name> [--scope=user|project] [--config-token=NEW]'); process.exitCode = 2; return; }
573
+ const { scope, file } = scopeFile(root, flags);
574
+ if (!fsSync.existsSync(file)) { log.error(`no providers.json at ${scope} scope (${file})`); process.exitCode = 1; return; }
575
+ const cur = JSON.parse(await fs.readFile(file, 'utf8'));
576
+ if (!cur[name]) { log.error(`not found in ${scope} scope: ${name}`); process.exitCode = 1; return; }
577
+ cur[name].config = cur[name].config || {};
578
+ // Apply --config-* overrides; for fields not supplied, prompt interactively.
579
+ let touched = 0;
580
+ for (const k of Object.keys(flags)) {
581
+ if (!k.startsWith('config-')) continue;
582
+ const ck = k.slice('config-'.length);
583
+ setConfigValue(cur[name].config, ck, flags[k]);
584
+ touched++;
585
+ }
586
+ if (touched === 0 && isTty()) {
587
+ log.info('no --config-* given; prompting for sensitive fields ...');
588
+ const driver = cur[name].driver;
589
+ for (const hint of configHints(driver)) {
590
+ if (!/token|secret|password|key|cookie|auth/i.test(hint.key)) continue;
591
+ const v = await prompt(`new ${hint.key} (or env:NAME, blank=keep): `);
592
+ if (v === '') continue;
593
+ setConfigValue(cur[name].config, hint.key, v.startsWith('env:') ? `\${env:${v.slice(4)}}` : v);
594
+ touched++;
595
+ }
596
+ }
597
+ if (touched === 0) { log.warn('nothing to rotate'); return; }
598
+ await fs.writeFile(file, JSON.stringify(cur, null, 2) + '\n', 'utf8');
599
+ await lifecycle.applySecureMode(file);
600
+ log.ok(`rotated ${touched} field(s) on ${name}`);
601
+ await statusOne(root, name);
602
+ }
603
+
604
+ function getConfigValue(config, key) {
605
+ return String(key).split('.').reduce((acc, part) => (
606
+ acc && typeof acc === 'object' ? acc[part] : undefined
607
+ ), config);
608
+ }
609
+
610
+ function setConfigValue(config, key, value) {
611
+ const parts = String(key).split('.');
612
+ let cur = config;
613
+ for (let i = 0; i < parts.length - 1; i++) {
614
+ const part = parts[i];
615
+ if (!cur[part] || typeof cur[part] !== 'object' || Array.isArray(cur[part])) cur[part] = {};
616
+ cur = cur[part];
617
+ }
618
+ cur[parts[parts.length - 1]] = value;
619
+ }
620
+
621
+ function formatConfigValue(value, fallback) {
622
+ if (Array.isArray(value)) return value.join(',');
623
+ if (value !== undefined && value !== null) return String(value);
624
+ return fallback !== undefined ? String(fallback) : '';
625
+ }
626
+
627
+ async function logout(root, name) {
628
+ if (!name) { log.error('usage: df provider logout <name>'); process.exitCode = 2; return; }
629
+ let p;
630
+ try { p = await providers.load(root, { name }); }
631
+ catch (e) { log.error(e.message); process.exitCode = 1; return; }
632
+ if (typeof p.logout !== 'function') { log.warn('provider has no logout'); return; }
633
+ await p.logout();
634
+ log.ok(`${name}: logged out`);
635
+ }
636
+
637
+ async function audit(root) {
638
+ const findings = await lifecycle.securityCheck(root);
639
+ if (!findings.length) { log.ok('no security issues'); return; }
640
+ for (const f of findings) {
641
+ const tag = f.severity === 'error' ? '[err]' : f.severity === 'warn' ? '[warn]' : '[info]';
642
+ log.raw(`${tag} ${f.file}\n ${f.message}${f.fix ? ` (fix: ${f.fix})` : ''}`);
643
+ }
644
+ process.exitCode = findings.some((f) => f.severity === 'error') ? 1 : 0;
645
+ }
646
+
647
+ function prompt(q, def = '') {
648
+ return new Promise((resolve) => {
649
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
650
+ const showDef = def ? ` [${def}]` : '';
651
+ rl.question(`${q}${showDef}`, (ans) => {
652
+ rl.close();
653
+ resolve((ans || '').trim() || def);
654
+ });
655
+ });
656
+ }
657
+
658
+ function isTty() { return !!(process.stdin && process.stdin.isTTY && process.stdout && process.stdout.isTTY); }
659
+
660
+ function openBrowser(url) {
661
+ const { spawnSync } = require('child_process');
662
+ try {
663
+ if (process.platform === 'darwin') spawnSync('open', [url], { stdio: 'ignore' });
664
+ else if (process.platform === 'win32') spawnSync('cmd', ['/c','start','',url],{ stdio: 'ignore', shell: true });
665
+ else spawnSync('xdg-open', [url], { stdio: 'ignore' });
666
+ } catch { /* ignore */ }
667
+ }
668
+
669
+ function help() {
670
+ log.raw(`devflow provider <list|status|setup|add|remove|relogin|rotate|logout|audit> [args]
671
+
672
+ setup interactive wizard — pick which providers to set up,
673
+ fill credentials step by step (recommended for first run)
674
+ list show configured providers (merged: user + project)
675
+ status [<name>] validate one (or all) — exit 1 if any not ok
676
+ add <name> [--scope=user|project] [--type=<t>] [--driver=<d>]
677
+ [--config-key=val] [--no-prompt] [--from=<file.json>]
678
+ remove <name> [--scope=user|project]
679
+ relogin <name> [--force] run provider.login() then validate
680
+ rotate <name> [--scope=user|project] [--config-key=val]
681
+ logout <name> drop session if provider supports it
682
+ audit scan providers.json for plaintext secrets / weak modes
683
+
684
+ Scope:
685
+ --scope=user (default) ~/.devflow/providers.json one source of truth across projects
686
+ --scope=project <repo>/devflow/providers.json repo-specific overrides
687
+
688
+ Examples:
689
+ devflow provider setup # ★ recommended first-time flow
690
+ devflow provider setup --scope=project # repo-only wizard
691
+ devflow provider add kb.weknora # user-scope, guided prompts
692
+ devflow provider add kb.weknora --from ./weknora-arb.local.json # arb-shape seed file
693
+ devflow provider add notify.smtp # user-scope SMTP setup
694
+ devflow provider add kb.weknora-staging --scope=project # repo-only override
695
+ `);
696
+ }
697
+
698
+ module.exports = { run, parseSelection, SETUP_PRESETS };