hamster-wheel-cli 0.1.0

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 (55) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.yml +107 -0
  2. package/.github/ISSUE_TEMPLATE/config.yml +15 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.yml +56 -0
  4. package/.github/workflows/ci-pr.yml +50 -0
  5. package/.github/workflows/publish.yml +121 -0
  6. package/.github/workflows/sync-master-to-dev.yml +100 -0
  7. package/AGENTS.md +20 -0
  8. package/CHANGELOG.md +12 -0
  9. package/LICENSE +21 -0
  10. package/README.md +90 -0
  11. package/dist/cli.d.ts +6 -0
  12. package/dist/cli.js +2678 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/index.d.ts +80 -0
  15. package/dist/index.js +2682 -0
  16. package/dist/index.js.map +1 -0
  17. package/docs/ai-workflow.md +58 -0
  18. package/package.json +44 -0
  19. package/src/ai.ts +173 -0
  20. package/src/cli.ts +189 -0
  21. package/src/config.ts +134 -0
  22. package/src/deps.ts +210 -0
  23. package/src/gh.ts +228 -0
  24. package/src/git.ts +285 -0
  25. package/src/global-config.ts +296 -0
  26. package/src/index.ts +3 -0
  27. package/src/logger.ts +122 -0
  28. package/src/logs-viewer.ts +420 -0
  29. package/src/logs.ts +132 -0
  30. package/src/loop.ts +422 -0
  31. package/src/monitor.ts +291 -0
  32. package/src/runtime-tracker.ts +65 -0
  33. package/src/summary.ts +255 -0
  34. package/src/types.ts +176 -0
  35. package/src/utils.ts +179 -0
  36. package/src/webhook.ts +107 -0
  37. package/tests/deps.test.ts +72 -0
  38. package/tests/e2e/cli.e2e.test.ts +77 -0
  39. package/tests/e2e/gh-pr-create.e2e.test.ts +55 -0
  40. package/tests/e2e/gh-run-list.e2e.test.ts +47 -0
  41. package/tests/gh-pr-create.test.ts +55 -0
  42. package/tests/gh-run-list.test.ts +35 -0
  43. package/tests/global-config.test.ts +52 -0
  44. package/tests/logger-file.test.ts +56 -0
  45. package/tests/logger.test.ts +72 -0
  46. package/tests/logs-viewer.test.ts +57 -0
  47. package/tests/logs.test.ts +33 -0
  48. package/tests/prompt.test.ts +20 -0
  49. package/tests/run-command-stream.test.ts +60 -0
  50. package/tests/summary.test.ts +58 -0
  51. package/tests/token-usage.test.ts +33 -0
  52. package/tests/utils.test.ts +8 -0
  53. package/tests/webhook.test.ts +89 -0
  54. package/tsconfig.json +18 -0
  55. package/tsup.config.ts +18 -0
package/src/gh.ts ADDED
@@ -0,0 +1,228 @@
1
+ import { Logger } from './logger';
2
+ import { PrConfig } from './types';
3
+ import { runCommand } from './utils';
4
+
5
+ /**
6
+ * PR 基础信息。
7
+ */
8
+ export interface GhPrInfo {
9
+ readonly number: number;
10
+ readonly url: string;
11
+ readonly title: string;
12
+ readonly state: string;
13
+ readonly headRefName: string;
14
+ }
15
+
16
+ /**
17
+ * Actions 运行信息。
18
+ */
19
+ export interface GhRunInfo {
20
+ readonly databaseId: number;
21
+ readonly name: string;
22
+ readonly status: string;
23
+ readonly conclusion?: string | null;
24
+ readonly url: string;
25
+ }
26
+
27
+ function isGhPrInfo(input: unknown): input is GhPrInfo {
28
+ if (typeof input !== 'object' || input === null) return false;
29
+ const candidate = input as Record<string, unknown>;
30
+ return typeof candidate.number === 'number'
31
+ && typeof candidate.url === 'string'
32
+ && typeof candidate.title === 'string'
33
+ && typeof candidate.state === 'string'
34
+ && typeof candidate.headRefName === 'string';
35
+ }
36
+
37
+ function resolveRunDatabaseId(candidate: Record<string, unknown>): number | null {
38
+ const databaseId = candidate.databaseId;
39
+ if (typeof databaseId === 'number' && Number.isFinite(databaseId)) return databaseId;
40
+ const id = candidate.id;
41
+ if (typeof id === 'number' && Number.isFinite(id)) return id;
42
+ if (typeof id === 'string') {
43
+ const parsed = Number(id);
44
+ if (Number.isFinite(parsed)) return parsed;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function parseGhRunInfo(input: unknown): GhRunInfo | null {
50
+ if (typeof input !== 'object' || input === null) return null;
51
+ const candidate = input as Record<string, unknown>;
52
+ const databaseId = resolveRunDatabaseId(candidate);
53
+ if (databaseId === null) return null;
54
+ if (typeof candidate.name !== 'string') return null;
55
+ if (typeof candidate.status !== 'string') return null;
56
+ if (typeof candidate.url !== 'string') return null;
57
+ const conclusion = candidate.conclusion;
58
+ const hasValidConclusion = conclusion === undefined || conclusion === null || typeof conclusion === 'string';
59
+ if (!hasValidConclusion) return null;
60
+ return {
61
+ databaseId,
62
+ name: candidate.name,
63
+ status: candidate.status,
64
+ conclusion: conclusion ?? null,
65
+ url: candidate.url
66
+ };
67
+ }
68
+
69
+ /**
70
+ * 解析 gh run list 的 JSON 输出。
71
+ */
72
+ export function parseGhRunList(output: string): GhRunInfo[] {
73
+ try {
74
+ const parsed = JSON.parse(output);
75
+ if (!Array.isArray(parsed)) return [];
76
+ return parsed
77
+ .map(parseGhRunInfo)
78
+ .filter((run): run is GhRunInfo => run !== null);
79
+ } catch {
80
+ return [];
81
+ }
82
+ }
83
+
84
+ /**
85
+ * 解析 PR 标题,必要时给出兜底标题。
86
+ */
87
+ export function resolvePrTitle(branch: string, title?: string): string {
88
+ const trimmed = title?.trim();
89
+ if (trimmed) return trimmed;
90
+ return `chore: 自动 PR (${branch})`;
91
+ }
92
+
93
+ /**
94
+ * 组装 gh pr create 所需参数。
95
+ */
96
+ export function buildPrCreateArgs(branch: string, config: PrConfig): string[] {
97
+ const args = ['pr', 'create', '--head', branch, '--title', resolvePrTitle(branch, config.title)];
98
+ if (config.bodyPath) {
99
+ args.push('--body-file', config.bodyPath);
100
+ } else {
101
+ args.push('--body', '自动生成 PR(未提供 body 文件)');
102
+ }
103
+ if (config.draft) {
104
+ args.push('--draft');
105
+ }
106
+ if (config.reviewers && config.reviewers.length > 0) {
107
+ args.push('--reviewer', config.reviewers.join(','));
108
+ }
109
+ return args;
110
+ }
111
+
112
+ /**
113
+ * 判断 gh pr create 是否提示 PR 已存在。
114
+ */
115
+ export function isPrAlreadyExistsMessage(output: string): boolean {
116
+ const trimmed = output.trim();
117
+ if (!trimmed) return false;
118
+ const ghPattern =
119
+ /a pull request for branch ["']?[^"']+["']? into branch ["']?[^"']+["']? already exists/i;
120
+ if (ghPattern.test(trimmed)) return true;
121
+
122
+ const hasAlreadyExists = /already exists/i.test(trimmed);
123
+ const hasPrKeyword = /\b(pull request|pr)\b/i.test(trimmed);
124
+ const hasBranch = /\bbranch\b/i.test(trimmed);
125
+ if (hasAlreadyExists && hasPrKeyword && hasBranch) return true;
126
+
127
+ const hasChineseExists = trimmed.includes('已存在');
128
+ const hasChinesePr = trimmed.includes('拉取请求') || trimmed.includes('合并请求') || /\bPR\b/i.test(trimmed);
129
+ const hasChineseBranch = trimmed.includes('分支');
130
+ if (hasChineseExists && hasChinesePr && hasChineseBranch) return true;
131
+ return false;
132
+ }
133
+
134
+ function extractPrUrl(output: string): string | null {
135
+ const match = output.match(/https?:\/\/\S+/);
136
+ if (!match) return null;
137
+ return match[0].replace(/[),.]+$/, '');
138
+ }
139
+
140
+ /**
141
+ * 读取当前分支 PR 信息。
142
+ */
143
+ export async function viewPr(branch: string, cwd: string, logger: Logger): Promise<GhPrInfo | null> {
144
+ const result = await runCommand('gh', ['pr', 'view', branch, '--json', 'number,title,url,state,headRefName'], {
145
+ cwd,
146
+ logger,
147
+ verboseLabel: 'gh',
148
+ verboseCommand: `gh pr view ${branch} --json number,title,url,state,headRefName`
149
+ });
150
+ if (result.exitCode !== 0) {
151
+ logger.warn(`gh pr view 失败: ${result.stderr}`);
152
+ return null;
153
+ }
154
+ try {
155
+ const parsed = JSON.parse(result.stdout);
156
+ if (isGhPrInfo(parsed)) return parsed;
157
+ logger.warn('gh pr view 返回格式异常');
158
+ return null;
159
+ } catch (error) {
160
+ logger.warn(`解析 gh pr view 输出失败: ${String(error)}`);
161
+ return null;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * 创建 PR 并返回 PR 信息。
167
+ */
168
+ export async function createPr(branch: string, config: PrConfig, cwd: string, logger: Logger): Promise<GhPrInfo | null> {
169
+ if (!config.enable) return null;
170
+ const args = buildPrCreateArgs(branch, config);
171
+
172
+ const result = await runCommand('gh', args, {
173
+ cwd,
174
+ logger,
175
+ verboseLabel: 'gh',
176
+ verboseCommand: `gh ${args.join(' ')}`
177
+ });
178
+ if (result.exitCode !== 0) {
179
+ const output = `${result.stderr}\n${result.stdout}`.trim();
180
+ if (isPrAlreadyExistsMessage(output)) {
181
+ const existingPr = await viewPr(branch, cwd, logger);
182
+ if (existingPr) {
183
+ logger.warn(`创建 PR 失败,但检测到已有 PR,视为创建完成: ${existingPr.url}`);
184
+ return existingPr;
185
+ }
186
+ const fallbackUrl = extractPrUrl(output);
187
+ logger.warn('创建 PR 失败,提示已存在 PR,但读取 PR 信息失败,视为创建完成');
188
+ return {
189
+ number: 0,
190
+ url: fallbackUrl ?? '未获取到链接',
191
+ title: '已存在 PR(未获取详情)',
192
+ state: 'unknown',
193
+ headRefName: branch
194
+ };
195
+ }
196
+ logger.warn(`创建 PR 失败: ${output || '未知错误'}`);
197
+ return null;
198
+ }
199
+
200
+ return viewPr(branch, cwd, logger);
201
+ }
202
+
203
+ /**
204
+ * 获取最近失败的 Actions 运行。
205
+ */
206
+ export async function listFailedRuns(branch: string, cwd: string, logger: Logger): Promise<GhRunInfo[]> {
207
+ const result = await runCommand('gh', ['run', 'list', '--branch', branch, '--json', 'databaseId,name,status,conclusion,url', '--limit', '5'], {
208
+ cwd,
209
+ logger,
210
+ verboseLabel: 'gh',
211
+ verboseCommand: `gh run list --branch ${branch} --json databaseId,name,status,conclusion,url --limit 5`
212
+ });
213
+ if (result.exitCode !== 0) {
214
+ logger.warn(`获取 Actions 运行失败: ${result.stderr}`);
215
+ return [];
216
+ }
217
+ try {
218
+ const runs = parseGhRunList(result.stdout);
219
+ const failed = runs.filter(run => run.conclusion && run.conclusion !== 'success');
220
+ if (failed.length === 0) {
221
+ logger.info('最近 5 次 Actions 运行无失败');
222
+ }
223
+ return failed;
224
+ } catch (error) {
225
+ logger.warn(`解析 Actions 输出失败: ${String(error)}`);
226
+ return [];
227
+ }
228
+ }
package/src/git.ts ADDED
@@ -0,0 +1,285 @@
1
+ import path from 'node:path';
2
+ import { Logger } from './logger';
3
+ import { CommitMessage, WorktreeConfig, WorktreeResult } from './types';
4
+ import { runCommand, resolvePath } from './utils';
5
+
6
+ async function branchExists(branch: string, cwd: string, logger?: Logger): Promise<boolean> {
7
+ const result = await runCommand('git', ['rev-parse', '--verify', branch], {
8
+ cwd,
9
+ logger,
10
+ verboseLabel: 'git',
11
+ verboseCommand: `git rev-parse --verify ${branch}`
12
+ });
13
+ return result.exitCode === 0;
14
+ }
15
+
16
+ async function resolveBaseBranch(baseBranch: string, repoRoot: string, logger: Logger): Promise<string> {
17
+ const baseExists = await branchExists(baseBranch, repoRoot, logger);
18
+ if (baseExists) return baseBranch;
19
+
20
+ const current = await getCurrentBranch(repoRoot, logger);
21
+ const currentExists = await branchExists(current, repoRoot, logger);
22
+ if (currentExists) {
23
+ logger.warn(`基线分支 ${baseBranch} 不存在,改用当前分支 ${current} 作为基线`);
24
+ return current;
25
+ }
26
+
27
+ throw new Error(`基线分支 ${baseBranch} 不存在,且无法确定可用的当前分支`);
28
+ }
29
+
30
+ /**
31
+ * 获取仓库根目录。
32
+ */
33
+ export async function getRepoRoot(cwd: string, logger?: Logger): Promise<string> {
34
+ const result = await runCommand('git', ['rev-parse', '--show-toplevel'], {
35
+ cwd,
36
+ logger,
37
+ verboseLabel: 'git',
38
+ verboseCommand: 'git rev-parse --show-toplevel'
39
+ });
40
+ if (result.exitCode !== 0) {
41
+ throw new Error('当前目录不是 git 仓库,无法继续');
42
+ }
43
+ return result.stdout.trim();
44
+ }
45
+
46
+ /**
47
+ * 获取当前分支名。
48
+ */
49
+ export async function getCurrentBranch(cwd: string, logger?: Logger): Promise<string> {
50
+ const result = await runCommand('git', ['branch', '--show-current'], {
51
+ cwd,
52
+ logger,
53
+ verboseLabel: 'git',
54
+ verboseCommand: 'git branch --show-current'
55
+ });
56
+ if (result.exitCode !== 0) {
57
+ throw new Error(`无法获取当前分支: ${result.stderr}`);
58
+ }
59
+ return result.stdout.trim();
60
+ }
61
+
62
+ async function getUpstreamBranch(branchName: string, cwd: string, logger?: Logger): Promise<string | null> {
63
+ const result = await runCommand('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branchName}@{u}`], {
64
+ cwd,
65
+ logger,
66
+ verboseLabel: 'git',
67
+ verboseCommand: `git rev-parse --abbrev-ref --symbolic-full-name ${branchName}@{u}`
68
+ });
69
+ if (result.exitCode !== 0) {
70
+ logger?.warn(`分支 ${branchName} 没有关联的 upstream: ${result.stderr || result.stdout}`);
71
+ return null;
72
+ }
73
+ return result.stdout.trim();
74
+ }
75
+
76
+ function defaultWorktreePath(repoRoot: string, branchName: string): string {
77
+ return path.join(repoRoot, '..', 'worktrees', branchName);
78
+ }
79
+
80
+ /**
81
+ * 确保目标分支存在。
82
+ */
83
+ export async function ensureBranchExists(branchName: string, baseBranch: string, repoRoot: string, logger: Logger): Promise<void> {
84
+ const exists = await branchExists(branchName, repoRoot, logger);
85
+ if (exists) return;
86
+ const create = await runCommand('git', ['branch', branchName, baseBranch], {
87
+ cwd: repoRoot,
88
+ logger,
89
+ verboseLabel: 'git',
90
+ verboseCommand: `git branch ${branchName} ${baseBranch}`
91
+ });
92
+ if (create.exitCode !== 0) {
93
+ throw new Error(`创建分支失败: ${create.stderr}`);
94
+ }
95
+ logger.info(`已基于 ${baseBranch} 创建分支 ${branchName}`);
96
+ }
97
+
98
+ /**
99
+ * 根据配置创建或复用 worktree。
100
+ */
101
+ export async function ensureWorktree(config: WorktreeConfig, repoRoot: string, logger: Logger): Promise<WorktreeResult> {
102
+ if (!config.useWorktree) {
103
+ return { path: repoRoot, created: false };
104
+ }
105
+
106
+ const branchName = config.branchName ?? generateBranchName();
107
+ const baseBranch = await resolveBaseBranch(config.baseBranch, repoRoot, logger);
108
+ const worktreePath = resolvePath(repoRoot, config.worktreePath ?? defaultWorktreePath(repoRoot, branchName));
109
+
110
+ await ensureBranchExists(branchName, baseBranch, repoRoot, logger);
111
+
112
+ const addResult = await runCommand('git', ['worktree', 'add', worktreePath, branchName], {
113
+ cwd: repoRoot,
114
+ logger,
115
+ verboseLabel: 'git',
116
+ verboseCommand: `git worktree add ${worktreePath} ${branchName}`
117
+ });
118
+ if (addResult.exitCode !== 0) {
119
+ const alreadyExists = addResult.stderr.includes('already exists') || addResult.stdout.includes('already exists');
120
+ if (alreadyExists) {
121
+ logger.warn(`worktree 路径已存在,跳过创建: ${worktreePath}`);
122
+ return { path: worktreePath, created: false };
123
+ }
124
+ throw new Error(`创建 worktree 失败: ${addResult.stderr || addResult.stdout}`);
125
+ }
126
+
127
+ logger.success(`已在 ${worktreePath} 创建并挂载 worktree (${branchName})`);
128
+ return { path: worktreePath, created: true };
129
+ }
130
+
131
+ /**
132
+ * 判断 worktree 是否干净。
133
+ */
134
+ export async function isWorktreeClean(cwd: string, logger?: Logger): Promise<boolean> {
135
+ const status = await runCommand('git', ['status', '--porcelain'], {
136
+ cwd,
137
+ logger,
138
+ verboseLabel: 'git',
139
+ verboseCommand: 'git status --porcelain'
140
+ });
141
+ if (status.exitCode !== 0) {
142
+ logger?.warn(`无法获取 git 状态: ${status.stderr || status.stdout}`);
143
+ return false;
144
+ }
145
+ return status.stdout.trim().length === 0;
146
+ }
147
+
148
+ /**
149
+ * 判断分支是否已推送到远端。
150
+ */
151
+ export async function isBranchPushed(branchName: string, cwd: string, logger: Logger): Promise<boolean> {
152
+ const upstream = await getUpstreamBranch(branchName, cwd, logger);
153
+ if (!upstream) return false;
154
+
155
+ const countResult = await runCommand('git', ['rev-list', '--left-right', '--count', `${upstream}...${branchName}`], {
156
+ cwd,
157
+ logger,
158
+ verboseLabel: 'git',
159
+ verboseCommand: `git rev-list --left-right --count ${upstream}...${branchName}`
160
+ });
161
+ if (countResult.exitCode !== 0) {
162
+ logger.warn(`无法比较分支 ${branchName} 与 ${upstream}: ${countResult.stderr || countResult.stdout}`);
163
+ return false;
164
+ }
165
+
166
+ const [behindStr, aheadStr] = countResult.stdout.trim().split(/\s+/);
167
+ const ahead = Number.parseInt(aheadStr ?? '0', 10);
168
+ if (Number.isNaN(ahead)) {
169
+ logger.warn(`无法解析分支推送状态: ${countResult.stdout}`);
170
+ return false;
171
+ }
172
+ if (ahead > 0) {
173
+ logger.warn(`分支 ${branchName} 仍有 ${ahead} 个本地提交未推送`);
174
+ return false;
175
+ }
176
+ return true;
177
+ }
178
+
179
+ function normalizeCommitTitle(title: string): string {
180
+ return title.replace(/\s+/g, ' ').trim();
181
+ }
182
+
183
+ function normalizeCommitBody(body?: string): string | undefined {
184
+ if (!body) return undefined;
185
+ const normalized = body.replace(/\r\n?/g, '\n').trim();
186
+ return normalized.length > 0 ? normalized : undefined;
187
+ }
188
+
189
+ function formatCommitCommand(message: CommitMessage): string {
190
+ const title = normalizeCommitTitle(message.title) || 'chore: 更新迭代产出';
191
+ const parts = ['git commit -m', JSON.stringify(title)];
192
+ const body = normalizeCommitBody(message.body);
193
+ if (body) {
194
+ parts.push('-m', JSON.stringify(body));
195
+ }
196
+ return parts.join(' ');
197
+ }
198
+
199
+ function buildCommitArgs(message: CommitMessage): string[] {
200
+ const title = normalizeCommitTitle(message.title) || 'chore: 更新迭代产出';
201
+ const args = ['commit', '-m', title];
202
+ const body = normalizeCommitBody(message.body);
203
+ if (body) {
204
+ args.push('-m', body);
205
+ }
206
+ return args;
207
+ }
208
+
209
+ /**
210
+ * 提交当前变更。
211
+ */
212
+ export async function commitAll(message: CommitMessage, cwd: string, logger: Logger): Promise<void> {
213
+ const add = await runCommand('git', ['add', '-A'], {
214
+ cwd,
215
+ logger,
216
+ verboseLabel: 'git',
217
+ verboseCommand: 'git add -A'
218
+ });
219
+ if (add.exitCode !== 0) {
220
+ throw new Error(`git add 失败: ${add.stderr}`);
221
+ }
222
+ const commit = await runCommand('git', buildCommitArgs(message), {
223
+ cwd,
224
+ logger,
225
+ verboseLabel: 'git',
226
+ verboseCommand: formatCommitCommand(message)
227
+ });
228
+ if (commit.exitCode !== 0) {
229
+ logger.warn(`git commit 跳过或失败: ${commit.stderr}`);
230
+ return;
231
+ }
232
+ logger.success('已提交当前变更');
233
+ }
234
+
235
+ /**
236
+ * 推送分支到远端。
237
+ */
238
+ export async function pushBranch(branchName: string, cwd: string, logger: Logger): Promise<void> {
239
+ const push = await runCommand('git', ['push', '-u', 'origin', branchName], {
240
+ cwd,
241
+ logger,
242
+ verboseLabel: 'git',
243
+ verboseCommand: `git push -u origin ${branchName}`
244
+ });
245
+ if (push.exitCode !== 0) {
246
+ throw new Error(`git push 失败: ${push.stderr}`);
247
+ }
248
+ logger.success(`已推送分支 ${branchName}`);
249
+ }
250
+
251
+ /**
252
+ * 删除 worktree 并清理。
253
+ */
254
+ export async function removeWorktree(worktreePath: string, repoRoot: string, logger: Logger): Promise<void> {
255
+ const remove = await runCommand('git', ['worktree', 'remove', '--force', worktreePath], {
256
+ cwd: repoRoot,
257
+ logger,
258
+ verboseLabel: 'git',
259
+ verboseCommand: `git worktree remove --force ${worktreePath}`
260
+ });
261
+ if (remove.exitCode !== 0) {
262
+ throw new Error(`删除 worktree 失败: ${remove.stderr || remove.stdout}`);
263
+ }
264
+
265
+ const prune = await runCommand('git', ['worktree', 'prune'], {
266
+ cwd: repoRoot,
267
+ logger,
268
+ verboseLabel: 'git',
269
+ verboseCommand: 'git worktree prune'
270
+ });
271
+ if (prune.exitCode !== 0) {
272
+ logger.warn(`worktree prune 失败: ${prune.stderr || prune.stdout}`);
273
+ }
274
+
275
+ logger.success(`已删除 worktree: ${worktreePath}`);
276
+ }
277
+
278
+ /**
279
+ * 生成默认分支名。
280
+ */
281
+ export function generateBranchName(): string {
282
+ const now = new Date();
283
+ const stamp = `${now.getFullYear()}${(now.getMonth() + 1).toString().padStart(2, '0')}${now.getDate().toString().padStart(2, '0')}-${now.getHours().toString().padStart(2, '0')}${now.getMinutes().toString().padStart(2, '0')}`;
284
+ return `wheel-aii/${stamp}`;
285
+ }