@qnroa/qtype 0.0.1

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 (110) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/CHANGELOG.zh.md +82 -0
  3. package/LICENSE +201 -0
  4. package/README.md +113 -0
  5. package/README.zh.md +114 -0
  6. package/dist/cli/bootstrap.js +190 -0
  7. package/dist/cli/commands/check/action.js +56 -0
  8. package/dist/cli/commands/check/i18n.js +26 -0
  9. package/dist/cli/commands/check/index.js +20 -0
  10. package/dist/cli/commands/config/action.js +236 -0
  11. package/dist/cli/commands/config/i18n.js +64 -0
  12. package/dist/cli/commands/config/index.js +72 -0
  13. package/dist/cli/commands/content/index.js +22 -0
  14. package/dist/cli/commands/content/lint/action.js +102 -0
  15. package/dist/cli/commands/content/lint/i18n.js +32 -0
  16. package/dist/cli/commands/content/lint/index.js +19 -0
  17. package/dist/cli/commands/content/search/action.js +64 -0
  18. package/dist/cli/commands/content/search/i18n.js +20 -0
  19. package/dist/cli/commands/content/search/index.js +24 -0
  20. package/dist/cli/commands/content/validate/action.js +145 -0
  21. package/dist/cli/commands/content/validate/i18n.js +32 -0
  22. package/dist/cli/commands/content/validate/index.js +19 -0
  23. package/dist/cli/commands/index.js +19 -0
  24. package/dist/cli/commands/init/index.js +53 -0
  25. package/dist/cli/commands/pack/i18n.js +20 -0
  26. package/dist/cli/commands/pack/index.js +48 -0
  27. package/dist/cli/commands/plugin/i18n.js +28 -0
  28. package/dist/cli/commands/plugin/index.js +41 -0
  29. package/dist/cli/commands/publish/build/action.js +208 -0
  30. package/dist/cli/commands/publish/build/i18n.js +50 -0
  31. package/dist/cli/commands/publish/build/index.js +24 -0
  32. package/dist/cli/commands/publish/build/mermaidPass.js +93 -0
  33. package/dist/cli/commands/publish/build/mermaidPrerender.js +199 -0
  34. package/dist/cli/commands/publish/index.js +21 -0
  35. package/dist/cli/commands/publish/init/action.js +74 -0
  36. package/dist/cli/commands/publish/init/i18n.js +22 -0
  37. package/dist/cli/commands/publish/init/index.js +26 -0
  38. package/dist/cli/commands/publish/password/action.js +158 -0
  39. package/dist/cli/commands/publish/password/i18n.js +44 -0
  40. package/dist/cli/commands/publish/password/index.js +35 -0
  41. package/dist/cli/commands/publish/serve/action.js +117 -0
  42. package/dist/cli/commands/publish/serve/i18n.js +32 -0
  43. package/dist/cli/commands/publish/serve/index.js +37 -0
  44. package/dist/cli/commands/repo/index.js +19 -0
  45. package/dist/cli/commands/repo/new/action.js +98 -0
  46. package/dist/cli/commands/repo/new/i18n.js +46 -0
  47. package/dist/cli/commands/repo/new/index.js +57 -0
  48. package/dist/cli/commands/repo/sync/action.js +202 -0
  49. package/dist/cli/commands/repo/sync/i18n.js +44 -0
  50. package/dist/cli/commands/repo/sync/index.js +47 -0
  51. package/dist/cli/commands/repo/templates.js +87 -0
  52. package/dist/cli/i18n/index.js +141 -0
  53. package/dist/cli/i18n/shared.js +142 -0
  54. package/dist/cli/index.js +79 -0
  55. package/dist/cli/registry/command.js +1 -0
  56. package/dist/cli/registry/commander-i18n.js +76 -0
  57. package/dist/cli/registry/help.js +142 -0
  58. package/dist/cli/registry/mount.js +71 -0
  59. package/dist/cli/utils/output.js +92 -0
  60. package/dist/cli/utils/paths.js +13 -0
  61. package/dist/cli/utils/prompt.js +72 -0
  62. package/dist/cli/utils/repoGuard.js +41 -0
  63. package/dist/core/encrypt.js +113 -0
  64. package/dist/core/index.js +8 -0
  65. package/dist/core/keystroke/asciiMap.js +39 -0
  66. package/dist/core/keystroke/chineseMap.js +32 -0
  67. package/dist/core/keystroke/fullwidthMap.js +41 -0
  68. package/dist/core/keystroke/index.js +22 -0
  69. package/dist/core/keystroke/textNormalize.js +68 -0
  70. package/dist/core/keystroke/types.js +1 -0
  71. package/dist/core/parser/frontmatter.js +58 -0
  72. package/dist/core/parser/index.js +2 -0
  73. package/dist/core/parser/parseMaterial.js +42 -0
  74. package/dist/core/types.js +1 -0
  75. package/dist/core/typing/engine.js +112 -0
  76. package/dist/core/typing/index.js +2 -0
  77. package/dist/core/typing/metrics.js +20 -0
  78. package/dist/store/index.js +44 -0
  79. package/dist/store/kv.js +276 -0
  80. package/dist/store/storage/browser.js +61 -0
  81. package/dist/store/storage/json.js +113 -0
  82. package/dist/store/storage/memory.js +24 -0
  83. package/dist/store/types.js +47 -0
  84. package/dist/store/validators.js +71 -0
  85. package/dist/view/assets/index-DBABH3dG.js +111 -0
  86. package/dist/view/assets/index-RTLZ-zOC.css +1 -0
  87. package/dist/view/index.html +14 -0
  88. package/dist/view/qtype-logo.svg +49 -0
  89. package/dist/view/qtype-mark.svg +21 -0
  90. package/docs/README.md +23 -0
  91. package/docs/README.zh.md +20 -0
  92. package/docs/en/authoring.md +138 -0
  93. package/docs/en/cli-reference.md +171 -0
  94. package/docs/zh/authoring.md +131 -0
  95. package/docs/zh/cli-reference.md +162 -0
  96. package/package.json +85 -0
  97. package/templates/en/QTYPE.md +618 -0
  98. package/templates/en/README.md +64 -0
  99. package/templates/en/TODO.md +7 -0
  100. package/templates/en/index.md +7 -0
  101. package/templates/en/log.md +3 -0
  102. package/templates/en/material/.gitkeep +0 -0
  103. package/templates/en/material/example.md +47 -0
  104. package/templates/zh/QTYPE.md +618 -0
  105. package/templates/zh/README.md +64 -0
  106. package/templates/zh/TODO.md +7 -0
  107. package/templates/zh/index.md +7 -0
  108. package/templates/zh/log.md +3 -0
  109. package/templates/zh/material/.gitkeep +0 -0
  110. package/templates/zh/material/example.md +47 -0
@@ -0,0 +1,190 @@
1
+ /**
2
+ * One-shot side effect at CLI startup: build the two KvStore instances
3
+ * (user and repo) and export them.
4
+ *
5
+ * ## The two KV instances
6
+ *
7
+ * - `userKv` → `~/.config/qtype/config.json` (XDG Base Directory)
8
+ * - Holds CLI user preferences (the user layer of cli.* / typing.*)
9
+ * - Always available (uses defaults when the file doesn't exist)
10
+ *
11
+ * - `repoKv` → `<repo>/.qtype/config.json`
12
+ * - Holds repo-bound configuration (the repo layer of repo.* / typing.*)
13
+ * - Only available when cwd is inside a qtype repo (detected via `guard`
14
+ * checking for the `.qtype/` directory)
15
+ *
16
+ * ## Schema (git-style layering)
17
+ *
18
+ * Both KVs enable `strict: true` (`qtype config set` needs to reject typo'd keys).
19
+ * Each key is one of:
20
+ * - user-only → registered on userKv only (cli.*)
21
+ * - repo-only → registered on repoKv only (repo.name)
22
+ * - both → registered on BOTH sides (repo.lang, typing.*).
23
+ * Users can set a global default; individual repos override.
24
+ * Read precedence: repo > user > schema default.
25
+ */
26
+ import fs from 'node:fs';
27
+ import os from 'node:os';
28
+ import path from 'node:path';
29
+ import { KvStore, JsonStorage, enumValidator, nonEmptyStringValidator, boolValidator, positiveIntValidator, } from '../store/index.js';
30
+ // ═══════════════════════════════════════════════════════════════
31
+ // Schemas grouped by scope (git-style layering)
32
+ // ═══════════════════════════════════════════════════════════════
33
+ /** user-only: settings tied to the person, not the repo. */
34
+ const userOnlySchema = {
35
+ // CLI display language. Values are BCP 47 codes; the short forms
36
+ // `zh` / `en` are accepted for backwards compatibility with older
37
+ // config files and normalised at read time by the i18n layer.
38
+ 'cli.lang': {
39
+ validator: enumValidator(['zh-CN', 'en-US', 'zh', 'en']),
40
+ default: 'en-US',
41
+ },
42
+ 'cli.color': {
43
+ validator: enumValidator(['always', 'never', 'auto']),
44
+ default: 'auto',
45
+ },
46
+ };
47
+ /** repo-only: settings that only make sense per-repo. */
48
+ const repoOnlySchema = {
49
+ 'repo.name': {
50
+ validator: nonEmptyStringValidator(),
51
+ },
52
+ // ── Password protection for `publish build` ─────────────
53
+ // On/off toggle. When true, `publish build` reads the other three fields
54
+ // and encrypts every material/*.md before writing to dist.
55
+ 'publish.password.enabled': {
56
+ validator: boolValidator(),
57
+ default: false,
58
+ },
59
+ // Argon2id PHC-style hash of the plaintext password, used to verify the
60
+ // password entered at build time. NOT the AES key — that's derived
61
+ // separately via `publish.password.aesSalt`. Empty string when protection
62
+ // is off; shape enforced by `publish password set` writing a well-formed
63
+ // PHC string.
64
+ 'publish.password.hash': {
65
+ validator: (v) => typeof v === 'string'
66
+ ? null
67
+ : { code: 'err.schema.nonEmptyString', fallback: 'must be a string' },
68
+ default: '',
69
+ },
70
+ // Base64 salt used with argon2id (password → AES-256 key). Rotated only
71
+ // when the user calls `publish password set` — stable across rebuilds so
72
+ // the same password unlocks previously published sites.
73
+ 'publish.password.aesSalt': {
74
+ validator: (v) => typeof v === 'string'
75
+ ? null
76
+ : { code: 'err.schema.nonEmptyString', fallback: 'must be a string' },
77
+ default: '',
78
+ },
79
+ // What to encrypt. `all` covers material/ + any future encrypted assets;
80
+ // `material-only` limits encryption to material/*.md (same behavior in
81
+ // v0 since qtype has no other content streams).
82
+ 'publish.password.scope': {
83
+ validator: enumValidator(['all', 'material-only', 'selected']),
84
+ default: 'all',
85
+ },
86
+ // When scope='selected', the list of file names (relative to material/)
87
+ // to encrypt. Ignored otherwise.
88
+ 'publish.password.selected': {
89
+ validator: (v) => Array.isArray(v) && v.every((x) => typeof x === 'string')
90
+ ? null
91
+ : { code: 'err.schema.nonEmptyString', fallback: 'must be an array of strings' },
92
+ default: [],
93
+ },
94
+ };
95
+ /** both: user sets a global default, individual repos override. */
96
+ const bothSchema = {
97
+ // Repo language. Drives:
98
+ // • which template locale (examples/zh vs examples/en) new repos are
99
+ // scaffolded from
100
+ // • the web UI language — the reader is locked to repo.lang so the UI
101
+ // matches the material's own language
102
+ //
103
+ // Values are BCP 47 codes (`zh-CN` / `en-US`); the short forms `zh` /
104
+ // `en` are accepted for backwards compatibility with older `.qtype/
105
+ // config.json` files and normalised at read time.
106
+ 'repo.lang': {
107
+ validator: enumValidator(['zh-CN', 'en-US', 'zh', 'en']),
108
+ default: 'en-US',
109
+ },
110
+ 'typing.theme': {
111
+ validator: enumValidator(['mocha', 'latte']),
112
+ default: 'mocha',
113
+ },
114
+ 'typing.targetWpm': {
115
+ validator: positiveIntValidator(),
116
+ },
117
+ 'typing.tabBehavior': {
118
+ validator: enumValidator(['reset', 'input', 'disabled']),
119
+ default: 'reset',
120
+ },
121
+ 'typing.hint': {
122
+ validator: enumValidator(['none', 'ghost']),
123
+ default: 'none',
124
+ },
125
+ 'typing.showKeyboard': {
126
+ validator: boolValidator(),
127
+ default: true,
128
+ },
129
+ 'typing.showHands': {
130
+ validator: boolValidator(),
131
+ default: true,
132
+ },
133
+ // Mermaid pre-render mode. `prerender` (default) uses puppeteer at build
134
+ // time to bake SVGs into dist/material/mermaid.json; `client` ships raw
135
+ // ```mermaid fences and lets the browser render them; `off` treats mermaid
136
+ // as plain code blocks.
137
+ 'publish.mermaid': {
138
+ validator: enumValidator(['prerender', 'client', 'off']),
139
+ default: 'prerender',
140
+ },
141
+ };
142
+ // ═══════════════════════════════════════════════════════════════
143
+ // Path resolution
144
+ // ═══════════════════════════════════════════════════════════════
145
+ function userConfigPath() {
146
+ const xdg = process.env.XDG_CONFIG_HOME;
147
+ const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), '.config');
148
+ return path.join(base, 'qtype', 'config.json');
149
+ }
150
+ /**
151
+ * Walk upward from cwd to find the nearest `.qtype/` directory. Returns null
152
+ * if none is found.
153
+ */
154
+ function findRepoRoot() {
155
+ let dir = process.cwd();
156
+ while (true) {
157
+ if (fs.existsSync(path.join(dir, '.qtype')))
158
+ return dir;
159
+ const parent = path.dirname(dir);
160
+ if (parent === dir)
161
+ return null;
162
+ dir = parent;
163
+ }
164
+ }
165
+ const repoRoot = findRepoRoot();
166
+ const repoConfigPath = path.join(repoRoot ?? process.cwd(), '.qtype', 'config.json');
167
+ export function isInRepo() {
168
+ return repoRoot !== null;
169
+ }
170
+ export function getRepoRoot() {
171
+ return repoRoot;
172
+ }
173
+ // ═══════════════════════════════════════════════════════════════
174
+ // User KV: `~/.config/qtype/config.json` (XDG)
175
+ // ═══════════════════════════════════════════════════════════════
176
+ export const userKv = new KvStore(new JsonStorage('user', userConfigPath()), { ...userOnlySchema, ...bothSchema }, { strict: true });
177
+ // ═══════════════════════════════════════════════════════════════
178
+ // Repo KV: `<repo>/.qtype/config.json`
179
+ // ═══════════════════════════════════════════════════════════════
180
+ export const repoKv = new KvStore(new JsonStorage('repo', repoConfigPath, { guard: isInRepo }), { ...repoOnlySchema, ...bothSchema }, { strict: true });
181
+ /**
182
+ * Build a fresh repo-scoped KvStore pointed at an explicit repo root.
183
+ * Used by commands that take an explicit `[path]` argument.
184
+ */
185
+ export function createRepoKv(root) {
186
+ const absRoot = path.resolve(root);
187
+ return new KvStore(new JsonStorage('repo', path.join(absRoot, '.qtype', 'config.json'), {
188
+ guard: () => fs.existsSync(path.join(absRoot, '.qtype')),
189
+ }), { ...repoOnlySchema, ...bothSchema }, { strict: true });
190
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `qtype check` action — validate material files without writing anything.
3
+ *
4
+ * Reports:
5
+ * - Missing `material/` directory (fatal)
6
+ * - Cards with non-typable characters (fatal, non-zero exit)
7
+ *
8
+ * Exit code: 0 if clean, 1 if any issue was found. Designed to be dropped
9
+ * into CI before `qtype publish build` so a bad card fails fast rather than
10
+ * producing a broken site.
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { info, success, fatal } from '../../utils/output.js';
15
+ import { ensureQtypeRepo } from '../../utils/repoGuard.js';
16
+ import { t } from '../../i18n/index.js';
17
+ import { parseMaterial } from '../../../core/parser/parseMaterial.js';
18
+ export async function checkAction(opts = {}) {
19
+ const root = ensureQtypeRepo(opts.repo ?? process.cwd());
20
+ info(t('check.msg.scanning', { path: root }));
21
+ const materialDir = path.join(root, 'material');
22
+ if (!fs.existsSync(materialDir)) {
23
+ fatal(t('err.dir.notExists', { path: materialDir }));
24
+ }
25
+ const files = fs
26
+ .readdirSync(materialDir)
27
+ .filter((f) => f.endsWith('.md'))
28
+ .map((f) => path.join(materialDir, f));
29
+ let cardTotal = 0;
30
+ const bad = [];
31
+ for (const file of files) {
32
+ const source = fs.readFileSync(file, 'utf-8');
33
+ const material = parseMaterial(source, path.relative(root, file));
34
+ cardTotal += material.cards.length;
35
+ material.cards.forEach((c, i) => {
36
+ if (c.unknownChars.length > 0) {
37
+ bad.push({
38
+ file: path.relative(root, file),
39
+ card: i + 1,
40
+ chars: [...new Set(c.unknownChars)],
41
+ });
42
+ }
43
+ });
44
+ }
45
+ console.log(' ' + t('check.msg.summary', { materials: files.length, cards: cardTotal }));
46
+ if (bad.length > 0) {
47
+ console.log('\n ' + t('check.msg.unknownChars', { count: bad.length }));
48
+ for (const b of bad.slice(0, 30)) {
49
+ console.log(` • ${b.file} #${b.card} → ${b.chars.join(' ')}`);
50
+ }
51
+ if (bad.length > 30)
52
+ console.log(` …and ${bad.length - 30} more`);
53
+ fatal(t('check.msg.failed', { count: bad.length }));
54
+ }
55
+ success(t('check.msg.ok'));
56
+ }
@@ -0,0 +1,26 @@
1
+ export const messages = {
2
+ 'zh-CN': {
3
+ 'check.summary': '校验资料',
4
+ 'check.description': '扫描目录下的 material/*.md,检查卡片格式是否合法',
5
+ 'check.opt.repo': '仓库路径(默认当前目录)',
6
+ 'check.example.basic': '校验当前仓库',
7
+ 'check.example.repo': '校验指定路径',
8
+ 'check.msg.scanning': '校验 {path}',
9
+ 'check.msg.summary': '资料 {materials} · 卡片 {cards}',
10
+ 'check.msg.unknownChars': '含无法练习的字符({count} 张卡):',
11
+ 'check.msg.ok': '所有卡片通过校验。',
12
+ 'check.msg.failed': '发现 {count} 个问题,请修复。',
13
+ },
14
+ 'en-US': {
15
+ 'check.summary': 'Validate materials',
16
+ 'check.description': 'Scan material/*.md in the given directory and report card format issues',
17
+ 'check.opt.repo': 'Repo path (default: current directory)',
18
+ 'check.example.basic': 'Check the current repo',
19
+ 'check.example.repo': 'Check an explicit path',
20
+ 'check.msg.scanning': 'Checking {path}',
21
+ 'check.msg.summary': 'materials {materials} · cards {cards}',
22
+ 'check.msg.unknownChars': 'Non-typable chars ({count} card(s)):',
23
+ 'check.msg.ok': 'All cards passed validation.',
24
+ 'check.msg.failed': 'Found {count} issue(s); please fix.',
25
+ },
26
+ };
@@ -0,0 +1,20 @@
1
+ import { t } from '../../i18n/index.js';
2
+ import { messages } from './i18n.js';
3
+ import { checkAction } from './action.js';
4
+ export const checkCmd = {
5
+ name: 'check',
6
+ messages,
7
+ summary: () => t('check.summary'),
8
+ describe: () => t('check.description'),
9
+ options: [
10
+ { flags: '--repo <path>', describe: () => t('check.opt.repo') },
11
+ ],
12
+ examples: () => [
13
+ { command: 'qtype check', describe: () => t('check.example.basic') },
14
+ { command: 'qtype check --repo ./my-material', describe: () => t('check.example.repo') },
15
+ ],
16
+ seeAlso: ['publish build', 'content lint'],
17
+ action: async (opts) => {
18
+ await checkAction({ repo: opts.repo });
19
+ },
20
+ };
@@ -0,0 +1,236 @@
1
+ import { KvError } from '../../../store/index.js';
2
+ import { userKv, repoKv } from '../../bootstrap.js';
3
+ import { info, success, dim, error } from '../../utils/output.js';
4
+ import { t } from '../../i18n/index.js';
5
+ /** --global / --local are mutually exclusive; returns the explicitly-specified scope (neither given → undefined). */
6
+ function pickScope(opts) {
7
+ if (opts.global && opts.local) {
8
+ throw new KvError('err.config.scope.conflict', {}, '--global and --local are mutually exclusive');
9
+ }
10
+ if (opts.global)
11
+ return 'user';
12
+ if (opts.local)
13
+ return 'repo';
14
+ return undefined;
15
+ }
16
+ /** Pick the KvStore for a given scope. */
17
+ function kvFor(scope) {
18
+ return scope === 'user' ? userKv : repoKv;
19
+ }
20
+ /**
21
+ * Decide which KV to write to (used by set).
22
+ *
23
+ * - Explicit `--global` / `--local` → corresponding KV
24
+ * - Neither given → check which KV the schema is registered in:
25
+ * - Only in userKv → userKv
26
+ * - Only in repoKv → repoKv
27
+ * - In both (view.*) → default to repoKv (if available), else userKv
28
+ * - In neither → strict mode will throw unknown key inside set()
29
+ */
30
+ async function resolveWriteScope(key, explicit) {
31
+ if (explicit)
32
+ return explicit;
33
+ const inUser = userKv.getSchema(key) !== undefined;
34
+ const inRepo = repoKv.getSchema(key) !== undefined;
35
+ if (inUser && !inRepo)
36
+ return 'user';
37
+ if (inRepo && !inUser)
38
+ return 'repo';
39
+ if (inUser && inRepo) {
40
+ // both-scope key: prefer repo; fall back to user if repo unavailable
41
+ return (await repoKv.available()) ? 'repo' : 'user';
42
+ }
43
+ // Neither side has it: default to repo, let strict throw unknown key;
44
+ // if repo is unavailable, give user instead
45
+ return (await repoKv.available()) ? 'repo' : 'user';
46
+ }
47
+ // ═══════════════════════════════════════════════════════════════
48
+ // get
49
+ // ═══════════════════════════════════════════════════════════════
50
+ /** `qtype config get <key>` */
51
+ export async function configGetAction(key, opts) {
52
+ const scope = pickScope(opts);
53
+ if (opts.showOrigin) {
54
+ await showOrigin(key, scope);
55
+ return;
56
+ }
57
+ if (scope) {
58
+ // Single-layer lookup
59
+ const kv = kvFor(scope);
60
+ const has = await kv.has(key);
61
+ if (!has) {
62
+ const def = kv.getSchema(key)?.default;
63
+ if (def === undefined) {
64
+ error(t('config.get.msg.notSetScoped', { key, scope }));
65
+ process.exit(1);
66
+ }
67
+ console.log(formatValue(def));
68
+ return;
69
+ }
70
+ const v = await kv.get(key);
71
+ console.log(formatValue(v));
72
+ return;
73
+ }
74
+ // Merged lookup: repo first, fall back to user, then to schema default
75
+ const fromRepo = (await repoKv.available()) && (await repoKv.has(key));
76
+ if (fromRepo) {
77
+ console.log(formatValue(await repoKv.get(key)));
78
+ return;
79
+ }
80
+ const fromUser = await userKv.has(key);
81
+ if (fromUser) {
82
+ console.log(formatValue(await userKv.get(key)));
83
+ return;
84
+ }
85
+ // Fall back to default: repo schema first, then user
86
+ const def = repoKv.getSchema(key)?.default ?? userKv.getSchema(key)?.default;
87
+ if (def === undefined) {
88
+ error(t('config.get.msg.notSet', { key }));
89
+ process.exit(1);
90
+ }
91
+ console.log(formatValue(def));
92
+ }
93
+ async function showOrigin(key, scope) {
94
+ const inRepo = (await repoKv.available()) && (await repoKv.has(key));
95
+ const inUser = await userKv.has(key);
96
+ if (scope === 'user') {
97
+ if (!inUser) {
98
+ error(t('config.get.msg.notSetScoped', { key, scope: 'user' }));
99
+ process.exit(1);
100
+ }
101
+ console.log(formatValue(await userKv.get(key)));
102
+ console.error(` ${dim('from:')} ${userKv.origin} (user)`);
103
+ return;
104
+ }
105
+ if (scope === 'repo') {
106
+ if (!inRepo) {
107
+ error(t('config.get.msg.notSetScoped', { key, scope: 'repo' }));
108
+ process.exit(1);
109
+ }
110
+ console.log(formatValue(await repoKv.get(key)));
111
+ console.error(` ${dim('from:')} ${repoKv.origin} (repo)`);
112
+ return;
113
+ }
114
+ // Merged mode: repo first
115
+ if (inRepo) {
116
+ console.log(formatValue(await repoKv.get(key)));
117
+ console.error(` ${dim('from:')} ${repoKv.origin} (repo)`);
118
+ if (inUser) {
119
+ console.error(` ${dim('overridden by:')} ${userKv.origin} (user) = ${formatValue(await userKv.get(key))}`);
120
+ }
121
+ return;
122
+ }
123
+ if (inUser) {
124
+ console.log(formatValue(await userKv.get(key)));
125
+ console.error(` ${dim('from:')} ${userKv.origin} (user)`);
126
+ return;
127
+ }
128
+ error(t('config.get.msg.notSet', { key }));
129
+ process.exit(1);
130
+ }
131
+ // ═══════════════════════════════════════════════════════════════
132
+ // set
133
+ // ═══════════════════════════════════════════════════════════════
134
+ /** `qtype config set <key> <value>` */
135
+ export async function configSetAction(key, value, opts) {
136
+ const explicit = pickScope(opts);
137
+ const target = await resolveWriteScope(key, explicit);
138
+ const kv = kvFor(target);
139
+ // Explicit scope but schema is not in this KV: emit a precise error message
140
+ if (explicit && kv.getSchema(key) === undefined) {
141
+ const other = explicit === 'user' ? 'repo' : 'user';
142
+ const otherHas = (other === 'user' ? userKv : repoKv).getSchema(key) !== undefined;
143
+ if (otherHas) {
144
+ throw new KvError('err.config.scope.violation', { name: key, allowed: other, attempted: explicit }, `'${key}' is ${other}-only, cannot write to ${explicit} scope`);
145
+ }
146
+ // Otherwise fall through to strict's unknown key path (thrown inside kv.set)
147
+ }
148
+ await kv.set(key, value);
149
+ success(`${key} = ${value}`);
150
+ console.log(` ${dim('written to')} ${kv.origin} (${target})`);
151
+ }
152
+ // ═══════════════════════════════════════════════════════════════
153
+ // unset
154
+ // ═══════════════════════════════════════════════════════════════
155
+ /** `qtype config unset <key>` */
156
+ export async function configUnsetAction(key, opts) {
157
+ const explicit = pickScope(opts);
158
+ let target;
159
+ if (explicit) {
160
+ target = explicit;
161
+ }
162
+ else {
163
+ const inUser = await userKv.has(key);
164
+ const inRepo = (await repoKv.available()) && (await repoKv.has(key));
165
+ if (inUser && inRepo) {
166
+ throw new KvError('err.config.unset.ambiguous', { raw: key }, `'${key}' exists in both user and repo scopes; use --global or --local to disambiguate`);
167
+ }
168
+ target = inRepo ? 'repo' : 'user';
169
+ }
170
+ const kv = kvFor(target);
171
+ const removed = await kv.delete(key);
172
+ if (!removed) {
173
+ info(t('config.unset.msg.notSet', { key, scope: target }));
174
+ return;
175
+ }
176
+ success(t('config.unset.msg.removed', { key, scope: target }));
177
+ console.log(` ${dim('at')} ${kv.origin}`);
178
+ }
179
+ // ═══════════════════════════════════════════════════════════════
180
+ // list
181
+ // ═══════════════════════════════════════════════════════════════
182
+ /** `qtype config list [--global|--local] [--show-origin]` */
183
+ export async function configListAction(opts) {
184
+ const scope = pickScope(opts);
185
+ const userEntries = scope === 'repo' ? [] : await userKv.list();
186
+ const repoEntries = scope === 'user' ? [] : (await repoKv.available()) ? await repoKv.list() : [];
187
+ const rows = [];
188
+ if (scope === 'user') {
189
+ for (const [k, v] of userEntries) {
190
+ rows.push({ key: k, value: v, scope: 'user', origin: userKv.origin });
191
+ }
192
+ }
193
+ else if (scope === 'repo') {
194
+ for (const [k, v] of repoEntries) {
195
+ rows.push({ key: k, value: v, scope: 'repo', origin: repoKv.origin });
196
+ }
197
+ }
198
+ else {
199
+ // merged: repo overrides user
200
+ const repoKeys = new Set(repoEntries.map(([k]) => k));
201
+ for (const [k, v] of userEntries) {
202
+ if (!repoKeys.has(k)) {
203
+ rows.push({ key: k, value: v, scope: 'user', origin: userKv.origin });
204
+ }
205
+ }
206
+ for (const [k, v] of repoEntries) {
207
+ rows.push({ key: k, value: v, scope: 'repo', origin: repoKv.origin });
208
+ }
209
+ }
210
+ if (rows.length === 0) {
211
+ info(t(scope ? 'config.list.msg.emptyScoped' : 'config.list.msg.empty', { scope: scope ?? '' }));
212
+ return;
213
+ }
214
+ rows.sort((a, b) => a.key.localeCompare(b.key));
215
+ for (const r of rows) {
216
+ if (opts.showOrigin) {
217
+ console.log(`${r.key}=${formatValue(r.value)}\t${dim(`${r.scope}:${r.origin}`)}`);
218
+ }
219
+ else {
220
+ console.log(`${r.key}=${formatValue(r.value)}`);
221
+ }
222
+ }
223
+ }
224
+ // ═══════════════════════════════════════════════════════════════
225
+ // helpers
226
+ // ═══════════════════════════════════════════════════════════════
227
+ /** Format a value as a single-line string. Objects / arrays use JSON. */
228
+ function formatValue(v) {
229
+ if (v == null)
230
+ return '';
231
+ if (typeof v === 'string')
232
+ return v;
233
+ if (typeof v === 'number' || typeof v === 'boolean')
234
+ return String(v);
235
+ return JSON.stringify(v);
236
+ }
@@ -0,0 +1,64 @@
1
+ export const messages = {
2
+ 'zh-CN': {
3
+ 'config.summary': '配置管理',
4
+ 'config.description': '读写 qtype 配置:user 层(~/.config/qtype/)+ repo 层(<repo>/.qtype/),git 风格 --global / --local 切换',
5
+ 'config.arg.key': '配置 key,格式 <section>.<name>,如 cli.lang',
6
+ 'config.arg.value': '配置值',
7
+ 'config.opt.global': '操作 user 层(~/.config/qtype/config.json)',
8
+ 'config.opt.local': '操作 repo 层(<repo>/.qtype/config.json)',
9
+ 'config.opt.showOrigin': '显示配置项来源(文件路径 / 哪一层)',
10
+ 'config.example.setLang': '设置 CLI 语言',
11
+ 'config.example.list': '列出所有生效配置',
12
+ 'config.get.summary': '读配置',
13
+ 'config.get.description': '读取指定 key 的值(默认合并两层;key 不存在 exit 1)',
14
+ 'config.get.example.basic': '读 CLI 语言偏好',
15
+ 'config.get.example.origin': '看值从哪一层来',
16
+ 'config.get.msg.notSet': "'{key}' 未设置",
17
+ 'config.get.msg.notSetScoped': "'{key}' 在 {scope} 层未设置",
18
+ 'config.set.summary': '写配置',
19
+ 'config.set.description': '写入 key=value;不加 flag 时按 section scope 决定写哪层',
20
+ 'config.set.example.user': '写 user 层(cli 是 user-only)',
21
+ 'config.set.example.local': '写当前仓库的 repo 层',
22
+ 'config.unset.summary': '删配置',
23
+ 'config.unset.description': '移除某个配置项',
24
+ 'config.unset.msg.notSet': '{key} 未设置({scope}),无需操作',
25
+ 'config.unset.msg.removed': '已从 {scope} 移除 {key}',
26
+ 'config.list.summary': '列配置',
27
+ 'config.list.description': '列出所有已设置的配置项',
28
+ 'config.list.example.merged': '合并两层,展示生效值',
29
+ 'config.list.example.global': '只看 user 层',
30
+ 'config.list.msg.empty': '配置为空',
31
+ 'config.list.msg.emptyScoped': '配置为空 ({scope})',
32
+ },
33
+ 'en-US': {
34
+ 'config.summary': 'Manage config',
35
+ 'config.description': 'Read / write qtype config: user layer (~/.config/qtype/) + repo layer (<repo>/.qtype/), git-style --global / --local',
36
+ 'config.arg.key': 'Config key, format <section>.<name>, e.g. cli.lang',
37
+ 'config.arg.value': 'Config value',
38
+ 'config.opt.global': 'Operate on user scope (~/.config/qtype/config.json)',
39
+ 'config.opt.local': 'Operate on repo scope (<repo>/.qtype/config.json)',
40
+ 'config.opt.showOrigin': 'Show where each entry comes from (file path and scope)',
41
+ 'config.example.setLang': 'Set CLI language',
42
+ 'config.example.list': 'List all effective config',
43
+ 'config.get.summary': 'Get value',
44
+ 'config.get.description': 'Get value for a key (merged by default; exit 1 if key not set)',
45
+ 'config.get.example.basic': 'Read CLI language preference',
46
+ 'config.get.example.origin': 'See which layer the value comes from',
47
+ 'config.get.msg.notSet': "'{key}' is not set",
48
+ 'config.get.msg.notSetScoped': "'{key}' is not set in {scope} scope",
49
+ 'config.set.summary': 'Set value',
50
+ 'config.set.description': 'Write key=value; layer is decided by section scope when no flag is given',
51
+ 'config.set.example.user': 'Write user scope (cli is user-only)',
52
+ 'config.set.example.local': 'Write the current repo scope',
53
+ 'config.unset.summary': 'Remove entry',
54
+ 'config.unset.description': 'Remove a config entry',
55
+ 'config.unset.msg.notSet': '{key} not set ({scope}); nothing to do',
56
+ 'config.unset.msg.removed': 'Removed {key} from {scope}',
57
+ 'config.list.summary': 'List entries',
58
+ 'config.list.description': 'List all configured entries',
59
+ 'config.list.example.merged': 'Merged view, effective values',
60
+ 'config.list.example.global': 'Only user scope',
61
+ 'config.list.msg.empty': 'Config is empty',
62
+ 'config.list.msg.emptyScoped': 'Config is empty ({scope})',
63
+ },
64
+ };
@@ -0,0 +1,72 @@
1
+ import { t } from '../../i18n/index.js';
2
+ import { messages } from './i18n.js';
3
+ import { configGetAction, configSetAction, configUnsetAction, configListAction, } from './action.js';
4
+ /** Shared --global / --local / --show-origin options. */
5
+ const scopeOptions = [
6
+ { flags: '--global', describe: () => t('config.opt.global') },
7
+ { flags: '--local', describe: () => t('config.opt.local') },
8
+ ];
9
+ const getCmd = {
10
+ name: 'get',
11
+ summary: () => t('config.get.summary'),
12
+ describe: () => t('config.get.description'),
13
+ args: [{ flag: '<key>', describe: () => t('config.arg.key') }],
14
+ options: [
15
+ ...scopeOptions,
16
+ { flags: '--show-origin', describe: () => t('config.opt.showOrigin') },
17
+ ],
18
+ examples: () => [
19
+ { command: 'qtype config get cli.lang', describe: () => t('config.get.example.basic') },
20
+ { command: 'qtype config get repo.lang --show-origin', describe: () => t('config.get.example.origin') },
21
+ ],
22
+ action: async (key, opts) => configGetAction(key, opts),
23
+ };
24
+ const setCmd = {
25
+ name: 'set',
26
+ summary: () => t('config.set.summary'),
27
+ describe: () => t('config.set.description'),
28
+ args: [
29
+ { flag: '<key>', describe: () => t('config.arg.key') },
30
+ { flag: '<value>', describe: () => t('config.arg.value') },
31
+ ],
32
+ options: scopeOptions,
33
+ examples: () => [
34
+ { command: 'qtype config set cli.lang zh', describe: () => t('config.set.example.user') },
35
+ { command: 'qtype config set repo.lang en', describe: () => t('config.set.example.local') },
36
+ ],
37
+ action: async (key, value, opts) => configSetAction(key, value, opts),
38
+ };
39
+ const unsetCmd = {
40
+ name: 'unset',
41
+ summary: () => t('config.unset.summary'),
42
+ describe: () => t('config.unset.description'),
43
+ args: [{ flag: '<key>', describe: () => t('config.arg.key') }],
44
+ options: scopeOptions,
45
+ action: async (key, opts) => configUnsetAction(key, opts),
46
+ };
47
+ const listCmd = {
48
+ name: 'list',
49
+ aliases: ['ls'],
50
+ summary: () => t('config.list.summary'),
51
+ describe: () => t('config.list.description'),
52
+ options: [
53
+ ...scopeOptions,
54
+ { flags: '--show-origin', describe: () => t('config.opt.showOrigin') },
55
+ ],
56
+ examples: () => [
57
+ { command: 'qtype config list', describe: () => t('config.list.example.merged') },
58
+ { command: 'qtype config list --global', describe: () => t('config.list.example.global') },
59
+ ],
60
+ action: async (opts) => configListAction(opts),
61
+ };
62
+ export const configCmd = {
63
+ name: 'config',
64
+ messages,
65
+ summary: () => t('config.summary'),
66
+ describe: () => t('config.description'),
67
+ examples: () => [
68
+ { command: 'qtype config set cli.lang zh', describe: () => t('config.example.setLang') },
69
+ { command: 'qtype config list', describe: () => t('config.example.list') },
70
+ ],
71
+ subcommands: [getCmd, setCmd, unsetCmd, listCmd],
72
+ };