infra-kit 0.1.102 → 0.1.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintcache +1 -1
- package/.omc/state/agent-replay-0a58307d-2a37-4c69-851c-83a646502d62.jsonl +1 -0
- package/.omc/state/agent-replay-11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc.jsonl +16 -0
- package/.omc/state/agent-replay-4cf1c186-81b2-497c-b002-d7f84e7839f3.jsonl +9 -0
- package/.omc/state/agent-replay-5c4ab554-64f1-42ae-83e3-21e0237e955c.jsonl +11 -0
- package/.omc/state/agent-replay-a60ac2ec-afbd-449f-a540-6df287392fc2.jsonl +1 -0
- package/.omc/state/agent-replay-afc6290b-40d3-4bef-b3b6-14484c034ab9.jsonl +14 -0
- package/.omc/state/agent-replay-be37e426-6fc8-47f4-8178-221c8494551c.jsonl +3 -0
- package/.omc/state/agent-replay-c967c819-3d1c-447b-ab48-56a8448ef9f8.jsonl +2 -0
- package/.omc/state/agent-replay-e947a3c6-989d-4a60-91dd-6b0ddd827b2d.jsonl +3 -0
- package/.omc/state/idle-notif-cooldown.json +3 -0
- package/.omc/state/last-tool-error.json +4 -4
- package/.omc/state/mission-state.json +53 -0
- package/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/pre-tool-advisory-throttle.json +18 -0
- package/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/subagent-tracking-state.json +7 -0
- package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/last-tool-error-state.json +7 -0
- package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/mission-state.json +117 -0
- package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/pre-tool-advisory-throttle.json +42 -0
- package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/subagent-tracking-state.json +53 -0
- package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/last-tool-error-state.json +7 -0
- package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/pre-tool-advisory-throttle.json +18 -0
- package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/subagent-tracking-state.json +7 -0
- package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/mission-state.json +117 -0
- package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/pre-tool-advisory-throttle.json +18 -0
- package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/subagent-tracking-state.json +17 -0
- package/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/pre-tool-advisory-throttle.json +18 -0
- package/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/subagent-tracking-state.json +7 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/last-tool-error-state.json +7 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/mission-state.json +89 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/pre-tool-advisory-throttle.json +34 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/ralph-state.json +13 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/skill-active-state.json +15 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/subagent-tracking-state.json +35 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/ultrawork-state.json +11 -0
- package/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/pre-tool-advisory-throttle.json +10 -0
- package/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/subagent-tracking-state.json +7 -0
- package/.omc/state/sessions/e947a3c6-989d-4a60-91dd-6b0ddd827b2d/last-tool-error-state.json +7 -0
- package/.omc/state/sessions/e947a3c6-989d-4a60-91dd-6b0ddd827b2d/pre-tool-advisory-throttle.json +10 -0
- package/.omc/state/sessions/e947a3c6-989d-4a60-91dd-6b0ddd827b2d/subagent-tracking-state.json +26 -0
- package/.omc/state/subagent-tracking.json +14 -4
- package/.turbo/turbo-build.log +7 -0
- package/.turbo/turbo-check.log +14 -0
- package/.turbo/turbo-prettier-fix.log +2 -1
- package/.turbo/turbo-test.log +28 -5
- package/.turbo/turbo-validate.log +14 -0
- package/dist/cli.js +88 -74
- package/dist/cli.js.map +4 -4
- package/dist/entry/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +7 -0
- package/dist/lib/package-config/package-config.d.ts +71 -0
- package/dist/mcp.js +43 -41
- package/dist/mcp.js.map +4 -4
- package/eslint.config.js +1 -1
- package/infra-kit.config.ts +5 -0
- package/package.json +20 -13
- package/scripts/build.js +32 -3
- package/src/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/pre-tool-advisory-throttle.json +18 -0
- package/src/commands/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/pre-tool-advisory-throttle.json +14 -0
- package/src/commands/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/pre-tool-advisory-throttle.json +18 -0
- package/src/commands/audit/__tests__/audit.test.ts +59 -0
- package/src/commands/audit/audit.ts +177 -0
- package/src/commands/audit/index.ts +1 -0
- package/src/commands/config/config.ts +49 -7
- package/src/commands/doctor/__tests__/agent-files.test.ts +110 -0
- package/src/commands/doctor/doctor.ts +69 -4
- package/src/commands/env-clear/env-clear.ts +1 -1
- package/src/commands/env-list/env-list.ts +3 -3
- package/src/commands/env-load/env-load.ts +1 -1
- package/src/commands/env-status/env-status.ts +1 -1
- package/src/commands/gh-merge-dev/gh-merge-dev.ts +3 -8
- package/src/commands/gh-release-deliver/gh-release-deliver.ts +47 -21
- package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +13 -7
- package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +12 -6
- package/src/commands/gh-release-list/gh-release-list.ts +19 -8
- package/src/commands/init/__tests__/agent-files.test.ts +147 -0
- package/src/commands/init/__tests__/migrate-config.test.ts +160 -0
- package/src/commands/init/agent-files.ts +199 -0
- package/src/commands/init/index.ts +7 -0
- package/src/commands/init/init.ts +82 -60
- package/src/commands/init/migrate-config.ts +146 -0
- package/src/commands/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/src/commands/release-create/__tests__/release-create.test.ts +55 -0
- package/src/commands/release-create/release-create.ts +142 -38
- package/src/commands/release-desc-edit/release-desc-edit.ts +28 -8
- package/src/commands/version/version.ts +1 -1
- package/src/commands/worktrees-add/worktrees-add.ts +7 -12
- package/src/commands/worktrees-list/worktrees-list.ts +13 -5
- package/src/commands/worktrees-open/worktrees-open.ts +1 -1
- package/src/commands/worktrees-remove/worktrees-remove.ts +6 -10
- package/src/commands/worktrees-sync/worktrees-sync.ts +3 -5
- package/src/entry/cli.ts +50 -7
- package/src/entry/index.ts +5 -0
- package/src/integrations/cmux/open-workspace-with-layout.ts +4 -4
- package/src/integrations/cmux/workspace-title.ts +10 -4
- package/src/integrations/doppler/doppler-project.ts +1 -1
- package/src/integrations/gh/gh-release-prs/__tests__/gh-release-prs.test.ts +115 -0
- package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +49 -32
- package/src/lib/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/pre-tool-advisory-throttle.json +14 -0
- package/src/lib/constants/index.ts +15 -0
- package/src/lib/git-utils/__tests__/git-utils.test.ts +49 -0
- package/src/lib/git-utils/git-utils.ts +3 -1
- package/src/lib/infra-kit-config/__tests__/infra-kit-config.test.ts +270 -0
- package/src/lib/infra-kit-config/index.ts +7 -1
- package/src/lib/infra-kit-config/infra-kit-config.ts +46 -28
- package/src/lib/managed-block/__tests__/managed-block.test.ts +121 -0
- package/src/lib/managed-block/index.ts +8 -0
- package/src/lib/managed-block/managed-block.ts +145 -0
- package/src/lib/package-config/__tests__/package-config.test.ts +95 -0
- package/src/lib/package-config/index.ts +3 -0
- package/src/lib/package-config/package-config-schema.ts +19 -0
- package/src/lib/package-config/package-config.ts +99 -0
- package/src/lib/package-validator/__tests__/package-validator.test.ts +263 -0
- package/src/lib/package-validator/checks/__tests__/checks.test.ts +130 -0
- package/src/lib/package-validator/checks/config-check.ts +30 -0
- package/src/lib/package-validator/checks/files-check.ts +29 -0
- package/src/lib/package-validator/checks/index.ts +4 -0
- package/src/lib/package-validator/checks/scripts-check.ts +23 -0
- package/src/lib/package-validator/checks/turbo-check.ts +47 -0
- package/src/lib/package-validator/fs-utils.ts +18 -0
- package/src/lib/package-validator/index.ts +3 -0
- package/src/lib/package-validator/loader/config-loader.ts +77 -0
- package/src/lib/package-validator/loader/index.ts +2 -0
- package/src/lib/package-validator/loader/package-discovery.ts +98 -0
- package/src/lib/package-validator/package-validator.ts +48 -0
- package/src/lib/package-validator/types.ts +15 -0
- package/src/lib/release-id/__tests__/release-id.test.ts +351 -0
- package/src/lib/release-id/__tests__/versioned-regression.test.ts +69 -0
- package/src/lib/release-id/index.ts +15 -0
- package/src/lib/release-id/release-id.ts +257 -0
- package/src/lib/release-utils/__tests__/release-utils.test.ts +122 -0
- package/src/lib/release-utils/index.ts +4 -0
- package/src/lib/release-utils/release-utils.ts +85 -17
- package/src/lib/version-utils/__tests__/load-existing-versions.test.ts +37 -0
- package/src/lib/version-utils/__tests__/next-version.test.ts +119 -13
- package/src/lib/version-utils/index.ts +3 -0
- package/src/lib/version-utils/load-existing-versions.ts +29 -10
- package/src/lib/version-utils/next-version.ts +67 -12
- package/src/lib/version-utils/version-utils.ts +13 -4
- package/src/mcp/tools/index.ts +2 -0
- package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/src/types.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/src/lib/__tests__/infra-kit-config.test.ts +0 -231
- /package/src/integrations/{clickup → linear}/.gitkeep +0 -0
- /package/src/lib/{__tests__ → constants/__tests__}/constants.test.ts +0 -0
- /package/src/lib/{constants.ts → constants/constants.ts} +0 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic "managed block" helpers — a marker-delimited region a tool owns and
|
|
3
|
+
* rewrites idempotently while never touching content outside the markers.
|
|
4
|
+
*
|
|
5
|
+
* This is the same mechanism `infra-kit init` already uses for the `~/.zshrc`
|
|
6
|
+
* shell block (`# -- infra-kit:begin -- … # -- infra-kit:end --`), lifted here so
|
|
7
|
+
* it can be reused for the repo agent-instruction files (`AGENTS.md`,
|
|
8
|
+
* `CLAUDE.md` import region, `.cursor/rules`). It mirrors the design of OMC's
|
|
9
|
+
* `<!-- OMC:START --> … <!-- OMC:END -->` CLAUDE.md installer.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Where a freshly upserted block lands relative to existing content.
|
|
14
|
+
*
|
|
15
|
+
* - `replace-in-place`: if the block already exists, rewrite it where it sits
|
|
16
|
+
* (surrounding text untouched, verbatim); if absent, append it at end-of-file.
|
|
17
|
+
* - `append-end`: strip any existing block from wherever it is, then append the
|
|
18
|
+
* fresh block at end-of-file (i.e. always relocate to the end). This preserves
|
|
19
|
+
* the historical `~/.zshrc` behavior of `removeExistingBlock` + append.
|
|
20
|
+
*/
|
|
21
|
+
export type BlockPlacement = 'replace-in-place' | 'append-end'
|
|
22
|
+
|
|
23
|
+
export interface UpsertManagedBlockArgs {
|
|
24
|
+
/** Existing file content (`''` for a new file). */
|
|
25
|
+
content: string
|
|
26
|
+
/** Inner body to place between the markers (WITHOUT the markers). */
|
|
27
|
+
body: string
|
|
28
|
+
startMarker: string
|
|
29
|
+
endMarker: string
|
|
30
|
+
/** Defaults to `replace-in-place`. */
|
|
31
|
+
placement?: BlockPlacement
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether `content` contains a well-formed `start … end` block. A reversed pair
|
|
36
|
+
* (end before start) is treated as absent — the same guard `doctor.ts` applies
|
|
37
|
+
* to the zshrc block, ported here so corrupted markers never match.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* hasManagedBlock('a<!--s-->x<!--e-->b', '<!--s-->', '<!--e-->') // => true
|
|
41
|
+
* hasManagedBlock('<!--e--><!--s-->', '<!--s-->', '<!--e-->') // => false (reversed)
|
|
42
|
+
*/
|
|
43
|
+
export const hasManagedBlock = (content: string, startMarker: string, endMarker: string): boolean => {
|
|
44
|
+
const startIdx = content.indexOf(startMarker)
|
|
45
|
+
const endIdx = content.indexOf(endMarker)
|
|
46
|
+
|
|
47
|
+
return startIdx !== -1 && endIdx !== -1 && endIdx >= startIdx
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Remove the first complete `start … end` block, preserving surrounding text.
|
|
52
|
+
* Returns `null` when no well-formed block is present (no markers, or reversed),
|
|
53
|
+
* so callers can fall through to legacy-format handling — this matches the
|
|
54
|
+
* `string | null` contract of `init.ts`'s original `removeBetween`, with the
|
|
55
|
+
* added reversed-marker guard.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* removeManagedBlock('top\n<!--s-->mid<!--e-->\nbot', '<!--s-->', '<!--e-->')
|
|
59
|
+
* // => 'top\nbot'
|
|
60
|
+
*/
|
|
61
|
+
export const removeManagedBlock = (content: string, startMarker: string, endMarker: string): string | null => {
|
|
62
|
+
const startIdx = content.indexOf(startMarker)
|
|
63
|
+
const endIdx = content.indexOf(endMarker)
|
|
64
|
+
|
|
65
|
+
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) return null
|
|
66
|
+
|
|
67
|
+
// eslint-disable-next-line sonarjs/slow-regex
|
|
68
|
+
const before = content.slice(0, startIdx).replace(/\n+$/, '')
|
|
69
|
+
const after = content.slice(endIdx + endMarker.length).replace(/^\n+/, '')
|
|
70
|
+
|
|
71
|
+
return before + (after ? `\n${after}` : '')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read the version token that follows `versionPrefix` (e.g.
|
|
76
|
+
* `'<!-- infra-kit:version '`). Returns the token up to the next whitespace or
|
|
77
|
+
* `>`, or `null` if the prefix is absent. Mirrors OMC's `OMC:VERSION:` line.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* extractVersion('<!-- infra-kit:version 0.1.105 -->', '<!-- infra-kit:version ')
|
|
81
|
+
* // => '0.1.105'
|
|
82
|
+
*/
|
|
83
|
+
export const extractVersion = (content: string, versionPrefix: string): string | null => {
|
|
84
|
+
const idx = content.indexOf(versionPrefix)
|
|
85
|
+
|
|
86
|
+
if (idx === -1) return null
|
|
87
|
+
|
|
88
|
+
const rest = content.slice(idx + versionPrefix.length)
|
|
89
|
+
const match = rest.match(/^([^\s>]+)/)
|
|
90
|
+
|
|
91
|
+
return match ? match[1]! : null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Compose a full block string: `start\n{body}\nend`. Kept identical in shape to
|
|
96
|
+
* the historical `buildShellBlock()` output so doctor's exact-match comparison
|
|
97
|
+
* stays valid.
|
|
98
|
+
*/
|
|
99
|
+
export const buildManagedBlock = (startMarker: string, body: string, endMarker: string): string => {
|
|
100
|
+
return `${startMarker}\n${body}\n${endMarker}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Insert or update a managed block in `content`, preserving everything outside
|
|
105
|
+
* the markers. Idempotent: re-running with the same body yields the same block
|
|
106
|
+
* and never nests duplicates.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* // fresh file
|
|
110
|
+
* upsertManagedBlock({ content: '', body: 'hi', startMarker: '<!--s-->', endMarker: '<!--e-->' })
|
|
111
|
+
* // => '<!--s-->\nhi\n<!--e-->\n'
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* // existing block replaced in place, surrounding text kept
|
|
115
|
+
* upsertManagedBlock({
|
|
116
|
+
* content: 'top\n<!--s-->\nold\n<!--e-->\nbot',
|
|
117
|
+
* body: 'new', startMarker: '<!--s-->', endMarker: '<!--e-->',
|
|
118
|
+
* })
|
|
119
|
+
* // => 'top\n<!--s-->\nnew\n<!--e-->\nbot'
|
|
120
|
+
*/
|
|
121
|
+
export const upsertManagedBlock = ({
|
|
122
|
+
content,
|
|
123
|
+
body,
|
|
124
|
+
startMarker,
|
|
125
|
+
endMarker,
|
|
126
|
+
placement = 'replace-in-place',
|
|
127
|
+
}: UpsertManagedBlockArgs): string => {
|
|
128
|
+
const block = buildManagedBlock(startMarker, body, endMarker)
|
|
129
|
+
const present = hasManagedBlock(content, startMarker, endMarker)
|
|
130
|
+
|
|
131
|
+
if (placement === 'replace-in-place' && present) {
|
|
132
|
+
const startIdx = content.indexOf(startMarker)
|
|
133
|
+
const endIdx = content.indexOf(endMarker) + endMarker.length
|
|
134
|
+
|
|
135
|
+
return content.slice(0, startIdx) + block + content.slice(endIdx)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// append-end, or replace-in-place with no existing block: drop any stale block
|
|
139
|
+
// then append the fresh one at end-of-file.
|
|
140
|
+
const stripped = present ? (removeManagedBlock(content, startMarker, endMarker) ?? content) : content
|
|
141
|
+
// eslint-disable-next-line sonarjs/slow-regex
|
|
142
|
+
const base = stripped.replace(/\n+$/, '')
|
|
143
|
+
|
|
144
|
+
return base.length > 0 ? `${base}\n${block}\n` : `${block}\n`
|
|
145
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_RULES, ROOT_DEFAULT_RULES, defineConfig, resolvePackageConfig } from '../package-config'
|
|
4
|
+
import { packageConfigSchema } from '../package-config-schema'
|
|
5
|
+
|
|
6
|
+
describe('defineConfig', () => {
|
|
7
|
+
it('returns an object input unchanged (identity)', () => {
|
|
8
|
+
const input = { requiredScripts: ['build'] }
|
|
9
|
+
|
|
10
|
+
expect(defineConfig(input)).toBe(input)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('returns a factory input unchanged so the loader can resolve it', () => {
|
|
14
|
+
const factory = () => {
|
|
15
|
+
return { requiredFiles: ['a.txt'] }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
expect(defineConfig(factory)).toBe(factory)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('resolvePackageConfig', () => {
|
|
23
|
+
it('falls back to defaults for every unset key', () => {
|
|
24
|
+
const rules = resolvePackageConfig({})
|
|
25
|
+
|
|
26
|
+
expect(rules.requiredScripts).toEqual(DEFAULT_RULES.requiredScripts)
|
|
27
|
+
expect(rules.requiredFiles).toEqual(DEFAULT_RULES.requiredFiles)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('replaces a key wholesale when provided, including an empty array opt-out', () => {
|
|
31
|
+
const rules = resolvePackageConfig({ requiredScripts: [], requiredFiles: ['serverless.common.yml'] })
|
|
32
|
+
|
|
33
|
+
expect(rules.requiredScripts).toEqual([])
|
|
34
|
+
expect(rules.requiredFiles).toEqual(['serverless.common.yml'])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('does not share the default array reference with the resolved result', () => {
|
|
38
|
+
const rules = resolvePackageConfig({})
|
|
39
|
+
|
|
40
|
+
rules.requiredScripts.push('mutated')
|
|
41
|
+
|
|
42
|
+
expect(DEFAULT_RULES.requiredScripts).not.toContain('mutated')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('falls back to the supplied baseline (root) for unset keys, including turbo tasks', () => {
|
|
46
|
+
const rules = resolvePackageConfig({}, ROOT_DEFAULT_RULES)
|
|
47
|
+
|
|
48
|
+
expect(rules.requiredScripts).toEqual(ROOT_DEFAULT_RULES.requiredScripts)
|
|
49
|
+
expect(rules.turboTasks).toEqual(ROOT_DEFAULT_RULES.turboTasks)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('lets a config override turbo.requiredTasks', () => {
|
|
53
|
+
const rules = resolvePackageConfig({ turbo: { requiredTasks: ['build'] } }, ROOT_DEFAULT_RULES)
|
|
54
|
+
|
|
55
|
+
expect(rules.turboTasks).toEqual(['build'])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('defaults turboTasks to an empty array for packages', () => {
|
|
59
|
+
const rules = resolvePackageConfig({})
|
|
60
|
+
|
|
61
|
+
expect(rules.turboTasks).toEqual([])
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('packageConfigSchema', () => {
|
|
66
|
+
it('rejects unknown keys so config typos surface as errors', () => {
|
|
67
|
+
const result = packageConfigSchema.safeParse({ requiredScript: ['build'] })
|
|
68
|
+
|
|
69
|
+
expect(result.success).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('rejects a non-array requiredScripts', () => {
|
|
73
|
+
const result = packageConfigSchema.safeParse({ requiredScripts: 'build' })
|
|
74
|
+
|
|
75
|
+
expect(result.success).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('accepts a well-formed config', () => {
|
|
79
|
+
const result = packageConfigSchema.safeParse({ requiredScripts: ['build'], requiredFiles: ['tsconfig.json'] })
|
|
80
|
+
|
|
81
|
+
expect(result.success).toBe(true)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('accepts a turbo.requiredTasks block', () => {
|
|
85
|
+
const result = packageConfigSchema.safeParse({ turbo: { requiredTasks: ['build', 'validate'] } })
|
|
86
|
+
|
|
87
|
+
expect(result.success).toBe(true)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('rejects an unknown key inside turbo', () => {
|
|
91
|
+
const result = packageConfigSchema.safeParse({ turbo: { tasks: ['build'] } })
|
|
92
|
+
|
|
93
|
+
expect(result.success).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { DEFAULT_RULES, defineConfig, resolvePackageConfig, ROOT_DEFAULT_RULES } from './package-config'
|
|
2
|
+
export type { InfraKitPackageConfig, InfraKitPackageConfigInput, ResolvedPackageRules } from './package-config'
|
|
3
|
+
export { packageConfigSchema } from './package-config-schema'
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schema for the resolved (post-factory) package config object. `strictObject`
|
|
5
|
+
* rejects unknown keys so typos in `infra-kit.config.ts` surface as validation
|
|
6
|
+
* errors instead of being silently ignored.
|
|
7
|
+
*
|
|
8
|
+
* Kept in its own module — separate from the public `defineConfig`/types entry —
|
|
9
|
+
* so the published `infra-kit` type surface stays free of a `zod` import.
|
|
10
|
+
*/
|
|
11
|
+
export const packageConfigSchema = z.strictObject({
|
|
12
|
+
requiredScripts: z.array(z.string().min(1)).optional(),
|
|
13
|
+
requiredFiles: z.array(z.string().min(1)).optional(),
|
|
14
|
+
turbo: z
|
|
15
|
+
.strictObject({
|
|
16
|
+
requiredTasks: z.array(z.string().min(1)).optional(),
|
|
17
|
+
})
|
|
18
|
+
.optional(),
|
|
19
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation rules for a single workspace package, declared in its
|
|
3
|
+
* `infra-kit.config.js`. Every field is optional: a key left unset falls back to
|
|
4
|
+
* the active baseline ({@link DEFAULT_RULES} for packages, {@link ROOT_DEFAULT_RULES}
|
|
5
|
+
* for the monorepo root), and a key set replaces that default wholesale (per-key,
|
|
6
|
+
* no array concatenation) so a package can opt out with an explicit empty array.
|
|
7
|
+
*
|
|
8
|
+
* Most packages need none of these — the standard rules live in the baseline, so
|
|
9
|
+
* a typical config is just `defineConfig(() => ({}))`.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* // infra-kit.config.js
|
|
13
|
+
* import { defineConfig } from 'infra-kit'
|
|
14
|
+
*
|
|
15
|
+
* export default defineConfig(() => ({}))
|
|
16
|
+
*/
|
|
17
|
+
export interface InfraKitPackageConfig {
|
|
18
|
+
/** Scripts that must be present in the package's package.json `scripts` map. */
|
|
19
|
+
requiredScripts?: string[]
|
|
20
|
+
/** Files (relative to the package root) that must exist on disk. */
|
|
21
|
+
requiredFiles?: string[]
|
|
22
|
+
/** Turborepo expectations — only meaningful where a turbo.json lives (the root). */
|
|
23
|
+
turbo?: {
|
|
24
|
+
/** Tasks that must be defined in turbo.json `tasks`. */
|
|
25
|
+
requiredTasks?: string[]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Accepted shapes for a package config's default export — mirrors Vite's
|
|
31
|
+
* `defineConfig` input: a plain object, a sync factory, or an async factory.
|
|
32
|
+
*/
|
|
33
|
+
export type InfraKitPackageConfigInput =
|
|
34
|
+
| InfraKitPackageConfig
|
|
35
|
+
| (() => InfraKitPackageConfig)
|
|
36
|
+
| (() => Promise<InfraKitPackageConfig>)
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Identity helper that gives `infra-kit.config.js` authors full type inference
|
|
40
|
+
* and editor autocomplete without changing the value — exactly like Vite's
|
|
41
|
+
* `defineConfig`. Resolution of the factory form happens in the loader, not here.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* export default defineConfig(() => ({}))
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* export default defineConfig(() => ({ requiredScripts: [] }))
|
|
48
|
+
*/
|
|
49
|
+
export const defineConfig = (config: InfraKitPackageConfigInput): InfraKitPackageConfigInput => {
|
|
50
|
+
return config
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Fully-resolved rules with every defaultable field present. */
|
|
54
|
+
export interface ResolvedPackageRules {
|
|
55
|
+
requiredScripts: string[]
|
|
56
|
+
requiredFiles: string[]
|
|
57
|
+
turboTasks: string[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Baseline rules for a standard TypeScript workspace package, applied to any key
|
|
62
|
+
* a package leaves unset. These are the "under the hood" defaults so a conforming
|
|
63
|
+
* package's config can stay empty; non-standard packages override the relevant key.
|
|
64
|
+
*/
|
|
65
|
+
export const DEFAULT_RULES: Readonly<ResolvedPackageRules> = {
|
|
66
|
+
requiredScripts: ['build', 'ts-check', 'eslint-check', 'prettier-check', 'test'],
|
|
67
|
+
requiredFiles: ['tsconfig.json', 'eslint.config.js', 'readme.md'],
|
|
68
|
+
turboTasks: [],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Baseline rules for the monorepo root (`infra-kit audit --root`). Checks the
|
|
73
|
+
* root commands, the workspace/turbo files, and that the turbo pipeline defines
|
|
74
|
+
* the expected tasks — so the root's own config can also stay empty.
|
|
75
|
+
*/
|
|
76
|
+
export const ROOT_DEFAULT_RULES: Readonly<ResolvedPackageRules> = {
|
|
77
|
+
requiredScripts: ['build', 'dev', 'test', 'qa', 'check', 'fix'],
|
|
78
|
+
requiredFiles: ['turbo.json', 'pnpm-workspace.yaml'],
|
|
79
|
+
turboTasks: ['build', 'test', 'ts-check', 'eslint-check', 'prettier-check', 'check'],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Merge a parsed package config over a baseline. Each key is replaced wholesale
|
|
84
|
+
* when the package provides it, otherwise the baseline value is used.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* resolvePackageConfig({ requiredScripts: [] })
|
|
88
|
+
* // => { requiredScripts: [], requiredFiles: [...DEFAULT_RULES.requiredFiles], turboTasks: [] }
|
|
89
|
+
*/
|
|
90
|
+
export const resolvePackageConfig = (
|
|
91
|
+
config: InfraKitPackageConfig,
|
|
92
|
+
baseline: Readonly<ResolvedPackageRules> = DEFAULT_RULES,
|
|
93
|
+
): ResolvedPackageRules => {
|
|
94
|
+
return {
|
|
95
|
+
requiredScripts: config.requiredScripts ?? [...baseline.requiredScripts],
|
|
96
|
+
requiredFiles: config.requiredFiles ?? [...baseline.requiredFiles],
|
|
97
|
+
turboTasks: config.turbo?.requiredTasks ?? [...baseline.turboTasks],
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { DEFAULT_RULES, ROOT_DEFAULT_RULES } from 'src/lib/package-config'
|
|
7
|
+
|
|
8
|
+
import { discoverPackages, loadPackageConfig, validatePackage } from '../package-validator'
|
|
9
|
+
|
|
10
|
+
const tmpDirs: string[] = []
|
|
11
|
+
|
|
12
|
+
const makeTmpDir = (): string => {
|
|
13
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pkg-validator-'))
|
|
14
|
+
|
|
15
|
+
tmpDirs.push(dir)
|
|
16
|
+
|
|
17
|
+
return dir
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PackageFixture {
|
|
21
|
+
packageJson?: Record<string, unknown>
|
|
22
|
+
config?: string
|
|
23
|
+
files?: Record<string, string>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const writePackage = (dir: string, fixture: PackageFixture): void => {
|
|
27
|
+
const packageJson = fixture.packageJson ?? { name: '@x/pkg', type: 'module' }
|
|
28
|
+
|
|
29
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(packageJson, null, 2))
|
|
30
|
+
|
|
31
|
+
if (fixture.config !== undefined) {
|
|
32
|
+
fs.writeFileSync(path.join(dir, 'infra-kit.config.ts'), fixture.config)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const [name, content] of Object.entries(fixture.files ?? {})) {
|
|
36
|
+
fs.writeFileSync(path.join(dir, name), content)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
while (tmpDirs.length > 0) {
|
|
42
|
+
const dir = tmpDirs.pop()
|
|
43
|
+
|
|
44
|
+
if (dir) {
|
|
45
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('loadPackageConfig', () => {
|
|
51
|
+
it('throws when infra-kit.config.js is missing', async () => {
|
|
52
|
+
const dir = makeTmpDir()
|
|
53
|
+
|
|
54
|
+
writePackage(dir, {})
|
|
55
|
+
|
|
56
|
+
await expect(loadPackageConfig(dir)).rejects.toThrow(/not found/)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('loads an object default export and merges defaults', async () => {
|
|
60
|
+
const dir = makeTmpDir()
|
|
61
|
+
|
|
62
|
+
writePackage(dir, { config: 'export default { requiredScripts: [] }' })
|
|
63
|
+
|
|
64
|
+
const rules = await loadPackageConfig(dir)
|
|
65
|
+
|
|
66
|
+
expect(rules.requiredScripts).toEqual([])
|
|
67
|
+
expect(rules.requiredFiles).toEqual(DEFAULT_RULES.requiredFiles)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('resolves a factory (function) default export', async () => {
|
|
71
|
+
const dir = makeTmpDir()
|
|
72
|
+
|
|
73
|
+
writePackage(dir, { config: "export default () => ({ requiredFiles: ['a.txt'] })" })
|
|
74
|
+
|
|
75
|
+
const rules = await loadPackageConfig(dir)
|
|
76
|
+
|
|
77
|
+
expect(rules.requiredFiles).toEqual(['a.txt'])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('rejects an invalid config shape with a descriptive error', async () => {
|
|
81
|
+
const dir = makeTmpDir()
|
|
82
|
+
|
|
83
|
+
writePackage(dir, { config: "export default { requiredScripts: 'build' }" })
|
|
84
|
+
|
|
85
|
+
await expect(loadPackageConfig(dir)).rejects.toThrow(/Invalid/)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('rejects an unknown key (typo protection)', async () => {
|
|
89
|
+
const dir = makeTmpDir()
|
|
90
|
+
|
|
91
|
+
writePackage(dir, { config: 'export default { requiredScript: [] }' })
|
|
92
|
+
|
|
93
|
+
await expect(loadPackageConfig(dir)).rejects.toThrow(/Invalid/)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('validatePackage', () => {
|
|
98
|
+
it('passes when config, required scripts, and required files are all satisfied', async () => {
|
|
99
|
+
const dir = makeTmpDir()
|
|
100
|
+
|
|
101
|
+
writePackage(dir, {
|
|
102
|
+
packageJson: { name: '@x/ok', type: 'module', scripts: { build: 'x' } },
|
|
103
|
+
config: "export default { requiredScripts: ['build'], requiredFiles: ['tsconfig.json'] }",
|
|
104
|
+
files: { 'tsconfig.json': '{}' },
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const result = await validatePackage(dir)
|
|
108
|
+
|
|
109
|
+
expect(result.passed).toBe(true)
|
|
110
|
+
expect(result.packageName).toBe('@x/ok')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('applies the under-the-hood defaults when the config is empty', async () => {
|
|
114
|
+
const dir = makeTmpDir()
|
|
115
|
+
|
|
116
|
+
writePackage(dir, {
|
|
117
|
+
packageJson: {
|
|
118
|
+
name: '@x/std',
|
|
119
|
+
type: 'module',
|
|
120
|
+
scripts: { build: 'x', 'ts-check': 'x', 'eslint-check': 'x', 'prettier-check': 'x', test: 'x' },
|
|
121
|
+
},
|
|
122
|
+
config: 'export default {}',
|
|
123
|
+
files: { 'tsconfig.json': '{}', 'eslint.config.js': '', 'readme.md': '' },
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const result = await validatePackage(dir)
|
|
127
|
+
|
|
128
|
+
expect(result.passed).toBe(true)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('fails when a required script is missing', async () => {
|
|
132
|
+
const dir = makeTmpDir()
|
|
133
|
+
|
|
134
|
+
writePackage(dir, {
|
|
135
|
+
packageJson: { name: '@x/no-script', type: 'module', scripts: { build: 'x' } },
|
|
136
|
+
config: "export default { requiredScripts: ['build', 'ts-check'], requiredFiles: [] }",
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const result = await validatePackage(dir)
|
|
140
|
+
|
|
141
|
+
expect(result.passed).toBe(false)
|
|
142
|
+
expect(result.checks).toContainEqual(expect.objectContaining({ name: 'script:ts-check', status: 'fail' }))
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('fails when a required file is missing', async () => {
|
|
146
|
+
const dir = makeTmpDir()
|
|
147
|
+
|
|
148
|
+
writePackage(dir, {
|
|
149
|
+
packageJson: { name: '@x/no-file', type: 'module', scripts: {} },
|
|
150
|
+
config: "export default { requiredScripts: [], requiredFiles: ['tsconfig.json'] }",
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const result = await validatePackage(dir)
|
|
154
|
+
|
|
155
|
+
expect(result.passed).toBe(false)
|
|
156
|
+
expect(result.checks).toContainEqual(expect.objectContaining({ name: 'file:tsconfig.json', status: 'fail' }))
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('fails with only the config check when infra-kit.config.ts is missing', async () => {
|
|
160
|
+
const dir = makeTmpDir()
|
|
161
|
+
|
|
162
|
+
writePackage(dir, { packageJson: { name: '@x/no-config', type: 'module' } })
|
|
163
|
+
|
|
164
|
+
const result = await validatePackage(dir)
|
|
165
|
+
|
|
166
|
+
expect(result.passed).toBe(false)
|
|
167
|
+
expect(result.checks).toHaveLength(1)
|
|
168
|
+
expect(result.checks[0]).toMatchObject({ name: 'infra-kit.config.ts', status: 'fail' })
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe('validatePackage — root / turbo', () => {
|
|
173
|
+
it('passes the root baseline when scripts, files, and turbo tasks are present', async () => {
|
|
174
|
+
const dir = makeTmpDir()
|
|
175
|
+
|
|
176
|
+
writePackage(dir, {
|
|
177
|
+
packageJson: {
|
|
178
|
+
name: 'monorepo',
|
|
179
|
+
type: 'module',
|
|
180
|
+
scripts: { build: 'x', dev: 'x', test: 'x', qa: 'x', check: 'x', fix: 'x' },
|
|
181
|
+
},
|
|
182
|
+
config: 'export default {}',
|
|
183
|
+
files: {
|
|
184
|
+
'pnpm-workspace.yaml': 'packages: []\n',
|
|
185
|
+
'turbo.json': JSON.stringify({
|
|
186
|
+
tasks: { build: {}, test: {}, 'ts-check': {}, 'eslint-check': {}, 'prettier-check': {}, check: {} },
|
|
187
|
+
}),
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const result = await validatePackage(dir, ROOT_DEFAULT_RULES)
|
|
192
|
+
|
|
193
|
+
expect(result.passed).toBe(true)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('fails when a required turbo task is missing from turbo.json', async () => {
|
|
197
|
+
const dir = makeTmpDir()
|
|
198
|
+
|
|
199
|
+
writePackage(dir, {
|
|
200
|
+
packageJson: { name: 'monorepo', type: 'module' },
|
|
201
|
+
config: "export default { requiredScripts: [], requiredFiles: [], turbo: { requiredTasks: ['build', 'lint'] } }",
|
|
202
|
+
files: { 'turbo.json': JSON.stringify({ tasks: { build: {} } }) },
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const result = await validatePackage(dir)
|
|
206
|
+
|
|
207
|
+
expect(result.passed).toBe(false)
|
|
208
|
+
expect(result.checks).toContainEqual(expect.objectContaining({ name: 'turbo:lint', status: 'fail' }))
|
|
209
|
+
expect(result.checks).toContainEqual(expect.objectContaining({ name: 'turbo:build', status: 'pass' }))
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('accepts a root task keyed as //#name in turbo.json', async () => {
|
|
213
|
+
const dir = makeTmpDir()
|
|
214
|
+
|
|
215
|
+
writePackage(dir, {
|
|
216
|
+
packageJson: { name: 'monorepo', type: 'module' },
|
|
217
|
+
config: "export default { requiredScripts: [], requiredFiles: [], turbo: { requiredTasks: ['check-root'] } }",
|
|
218
|
+
files: { 'turbo.json': JSON.stringify({ tasks: { '//#check-root': {} } }) },
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
const result = await validatePackage(dir)
|
|
222
|
+
|
|
223
|
+
expect(result.checks).toContainEqual(expect.objectContaining({ name: 'turbo:check-root', status: 'pass' }))
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('discoverPackages', () => {
|
|
228
|
+
it('expands non-vendor globs and excludes vendor and negations', async () => {
|
|
229
|
+
const root = makeTmpDir()
|
|
230
|
+
|
|
231
|
+
fs.writeFileSync(
|
|
232
|
+
path.join(root, 'pnpm-workspace.yaml'),
|
|
233
|
+
'packages:\n - apps/*/*\n - packages/*\n - vendor/packages/*\n - "!**/test/**"\n',
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
const dirs = ['apps/infra-kit/cli', 'packages/p1', 'vendor/packages/v1']
|
|
237
|
+
|
|
238
|
+
for (const dir of dirs) {
|
|
239
|
+
const full = path.join(root, dir)
|
|
240
|
+
|
|
241
|
+
fs.mkdirSync(full, { recursive: true })
|
|
242
|
+
fs.writeFileSync(path.join(full, 'package.json'), '{}')
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const found = await discoverPackages(root)
|
|
246
|
+
|
|
247
|
+
expect(found).toContain(path.join(root, 'apps/infra-kit/cli'))
|
|
248
|
+
expect(found).toContain(path.join(root, 'packages/p1'))
|
|
249
|
+
expect(found).not.toContain(path.join(root, 'vendor/packages/v1'))
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('omits directories that lack a package.json', async () => {
|
|
253
|
+
const root = makeTmpDir()
|
|
254
|
+
|
|
255
|
+
fs.writeFileSync(path.join(root, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n')
|
|
256
|
+
|
|
257
|
+
fs.mkdirSync(path.join(root, 'packages/empty'), { recursive: true })
|
|
258
|
+
|
|
259
|
+
const found = await discoverPackages(root)
|
|
260
|
+
|
|
261
|
+
expect(found).not.toContain(path.join(root, 'packages/empty'))
|
|
262
|
+
})
|
|
263
|
+
})
|