infra-kit 0.1.101 → 0.1.105
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-be37e426-6fc8-47f4-8178-221c8494551c.jsonl +3 -0
- package/.omc/state/agent-replay-c967c819-3d1c-447b-ab48-56a8448ef9f8.jsonl +2 -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/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/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 +81 -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 +41 -39
- 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/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/doctor.ts +3 -3
- 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 +77 -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__/migrate-config.test.ts +160 -0
- package/src/commands/init/init.ts +48 -35
- 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 +8 -67
- package/src/commands/worktrees-sync/worktrees-sync.ts +3 -5
- package/src/entry/cli.ts +49 -6
- 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/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/lib/worktrees/index.ts +1 -0
- package/src/lib/worktrees/remove-worktrees.ts +65 -0
- 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
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import fs from 'node:fs/promises'
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
-
import
|
|
5
|
-
import { z } from 'zod/v4'
|
|
4
|
+
import { z } from 'zod'
|
|
6
5
|
|
|
7
6
|
import { getProjectRoot, getRepoName } from 'src/lib/git-utils'
|
|
8
7
|
|
|
9
|
-
const INFRA_KIT_CONFIG_FILE = 'infra-kit.
|
|
8
|
+
const INFRA_KIT_CONFIG_FILE = 'infra-kit.json'
|
|
10
9
|
|
|
11
10
|
const USER_CONFIG_DIR_NAME = '.infra-kit'
|
|
12
|
-
const USER_GLOBAL_CONFIG_FILE = 'config.
|
|
11
|
+
const USER_GLOBAL_CONFIG_FILE = 'config.json'
|
|
13
12
|
const USER_PROJECTS_DIR = 'projects'
|
|
14
13
|
|
|
15
14
|
// envManagement
|
|
@@ -62,7 +61,7 @@ const worktreesConfigSchema = z.object({
|
|
|
62
61
|
openInCmux: z.boolean().optional(),
|
|
63
62
|
})
|
|
64
63
|
|
|
65
|
-
const infraKitConfigSchema = z.object({
|
|
64
|
+
export const infraKitConfigSchema = z.object({
|
|
66
65
|
environments: z.array(z.string().min(1)).min(1),
|
|
67
66
|
envManagement: envManagementSchema,
|
|
68
67
|
ide: ideSchema.optional(),
|
|
@@ -70,7 +69,7 @@ const infraKitConfigSchema = z.object({
|
|
|
70
69
|
worktrees: worktreesConfigSchema.optional(),
|
|
71
70
|
})
|
|
72
71
|
|
|
73
|
-
const infraKitOverrideConfigSchema = infraKitConfigSchema.partial()
|
|
72
|
+
export const infraKitOverrideConfigSchema = infraKitConfigSchema.partial()
|
|
74
73
|
|
|
75
74
|
export type InfraKitConfig = z.infer<typeof infraKitConfigSchema>
|
|
76
75
|
|
|
@@ -79,7 +78,7 @@ export interface InfraKitConfigPaths {
|
|
|
79
78
|
main: string
|
|
80
79
|
/** User-scope global overrides applied to every project. */
|
|
81
80
|
userGlobal: string
|
|
82
|
-
/** User-scope per-project overrides — `<userProjectsDir>/<projectName>/infra-kit.
|
|
81
|
+
/** User-scope per-project overrides — `<userProjectsDir>/<projectName>/infra-kit.json`. */
|
|
83
82
|
userProject: string
|
|
84
83
|
/** Repo basename (`path.basename(projectRoot)`) used to namespace the user-project file. */
|
|
85
84
|
projectName: string
|
|
@@ -100,9 +99,9 @@ let cached: CacheEntry | null = null
|
|
|
100
99
|
* @example
|
|
101
100
|
* const paths = await getInfraKitConfigPaths()
|
|
102
101
|
* // {
|
|
103
|
-
* // main: '/Users/arthur/projects/api/infra-kit.
|
|
104
|
-
* // userGlobal: '/Users/arthur/.infra-kit/config.
|
|
105
|
-
* // userProject: '/Users/arthur/.infra-kit/projects/api/infra-kit.
|
|
102
|
+
* // main: '/Users/arthur/projects/api/infra-kit.json',
|
|
103
|
+
* // userGlobal: '/Users/arthur/.infra-kit/config.json',
|
|
104
|
+
* // userProject: '/Users/arthur/.infra-kit/projects/api/infra-kit.json',
|
|
106
105
|
* // projectName: 'api',
|
|
107
106
|
* // }
|
|
108
107
|
*/
|
|
@@ -120,19 +119,19 @@ export const getInfraKitConfigPaths = async (): Promise<InfraKitConfigPaths> =>
|
|
|
120
119
|
}
|
|
121
120
|
|
|
122
121
|
/**
|
|
123
|
-
* Read and validate `infra-kit.
|
|
122
|
+
* Read and validate `infra-kit.json`, with optional override layers shallow-merged
|
|
124
123
|
* on top in this order (later wins):
|
|
125
|
-
* 1. project `infra-kit.
|
|
126
|
-
* 2. `~/.infra-kit/config.
|
|
127
|
-
* 3. `~/.infra-kit/projects/<repo-name>/infra-kit.
|
|
124
|
+
* 1. project `infra-kit.json` — committed source of truth
|
|
125
|
+
* 2. `~/.infra-kit/config.json` — user-global defaults
|
|
126
|
+
* 3. `~/.infra-kit/projects/<repo-name>/infra-kit.json` — user-scope per-project overrides
|
|
128
127
|
*
|
|
129
128
|
* Top-level keys (entire capability sections like `ide`, `envManagement`)
|
|
130
129
|
* replace wholesale. Results are cached per file mtimes so the long-running
|
|
131
130
|
* MCP server picks up edits without a restart.
|
|
132
131
|
*
|
|
133
132
|
* @example
|
|
134
|
-
* // infra-kit.
|
|
135
|
-
* // ~/.infra-kit/config.
|
|
133
|
+
* // infra-kit.json: { "environments": ["dev"], "envManagement": { "provider": "doppler", "config": { "name": "p" } } }
|
|
134
|
+
* // ~/.infra-kit/config.json: { "ide": { "provider": "cursor", "config": { "mode": "windows" } } }
|
|
136
135
|
* const cfg = await getInfraKitConfig()
|
|
137
136
|
* // => { environments: ['dev'], envManagement: {...}, ide: { provider: 'cursor', config: { mode: 'windows' } } }
|
|
138
137
|
*/
|
|
@@ -145,7 +144,18 @@ export const getInfraKitConfig = async (): Promise<InfraKitConfig> => {
|
|
|
145
144
|
mainStat = await fs.stat(paths.main)
|
|
146
145
|
} catch {
|
|
147
146
|
cached = null
|
|
148
|
-
|
|
147
|
+
|
|
148
|
+
// Bridge the YAML→JSON cutover: if a legacy infra-kit.yml is sitting where
|
|
149
|
+
// the JSON config should be, point the user at the one-shot migration.
|
|
150
|
+
const legacyYmlPath = paths.main.replace(/\.json$/, '.yml')
|
|
151
|
+
|
|
152
|
+
if (await statIfExists(legacyYmlPath)) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`infra-kit.json not found at ${paths.main}. A legacy infra-kit.yml exists — run \`infra-kit init\` to convert it.`,
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
throw new Error(`infra-kit.json not found at ${paths.main}`)
|
|
149
159
|
}
|
|
150
160
|
|
|
151
161
|
const [userGlobalStat, userProjectStat] = await Promise.all([
|
|
@@ -164,10 +174,10 @@ export const getInfraKitConfig = async (): Promise<InfraKitConfig> => {
|
|
|
164
174
|
}
|
|
165
175
|
|
|
166
176
|
const layers: ConfigLayer[] = [
|
|
167
|
-
{ label: 'infra-kit.
|
|
168
|
-
{ label: '~/.infra-kit/config.
|
|
177
|
+
{ label: 'infra-kit.json', path: paths.main, required: true },
|
|
178
|
+
{ label: '~/.infra-kit/config.json', path: paths.userGlobal, required: false },
|
|
169
179
|
{
|
|
170
|
-
label: `~/.infra-kit/projects/${paths.projectName}/infra-kit.
|
|
180
|
+
label: `~/.infra-kit/projects/${paths.projectName}/infra-kit.json`,
|
|
171
181
|
path: paths.userProject,
|
|
172
182
|
required: false,
|
|
173
183
|
},
|
|
@@ -224,8 +234,8 @@ const statIfExists = async (filePath: string): Promise<Awaited<ReturnType<typeof
|
|
|
224
234
|
* `fs.readFile` that returns `null` instead of throwing on ENOENT.
|
|
225
235
|
*
|
|
226
236
|
* @example
|
|
227
|
-
* const raw = await readIfExists('/missing.
|
|
228
|
-
* const raw = await readIfExists('/exists.
|
|
237
|
+
* const raw = await readIfExists('/missing.json') // => null
|
|
238
|
+
* const raw = await readIfExists('/exists.json') // => '{ "environments": ["dev"] }\n'
|
|
229
239
|
*/
|
|
230
240
|
const readIfExists = async (filePath: string): Promise<string | null> => {
|
|
231
241
|
try {
|
|
@@ -261,17 +271,18 @@ interface ConfigLayer {
|
|
|
261
271
|
}
|
|
262
272
|
|
|
263
273
|
/**
|
|
264
|
-
* Read a single layer of the merge chain: parse the
|
|
274
|
+
* Read a single layer of the merge chain: parse the JSON if the file exists
|
|
265
275
|
* and validate it against the override schema. Returns `null` if an optional
|
|
266
|
-
* layer is missing; throws if the layer is required or invalid.
|
|
276
|
+
* layer is missing; throws if the layer is required, malformed, or invalid.
|
|
277
|
+
* An empty/whitespace-only file is treated as `{}` (JSON.parse would throw).
|
|
267
278
|
*
|
|
268
279
|
* @example
|
|
269
|
-
* await loadLayer({ label: '~/.infra-kit/config.
|
|
280
|
+
* await loadLayer({ label: '~/.infra-kit/config.json', path: '/missing.json', required: false })
|
|
270
281
|
* // => null
|
|
271
282
|
*
|
|
272
283
|
* @example
|
|
273
|
-
* // /home/me/.infra-kit/config.
|
|
274
|
-
* await loadLayer({ label: '~/.infra-kit/config.
|
|
284
|
+
* // /home/me/.infra-kit/config.json: '{ "ide": { "provider": "cursor", "config": { "mode": "windows" } } }'
|
|
285
|
+
* await loadLayer({ label: '~/.infra-kit/config.json', path: '/home/me/.infra-kit/config.json', required: false })
|
|
275
286
|
* // => { ide: { provider: 'cursor', config: { mode: 'windows' } } }
|
|
276
287
|
*/
|
|
277
288
|
const loadLayer = async (layer: ConfigLayer): Promise<Record<string, unknown> | null> => {
|
|
@@ -285,7 +296,14 @@ const loadLayer = async (layer: ConfigLayer): Promise<Record<string, unknown> |
|
|
|
285
296
|
return null
|
|
286
297
|
}
|
|
287
298
|
|
|
288
|
-
|
|
299
|
+
let parsedRaw: unknown
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
parsedRaw = raw.trim() === '' ? {} : JSON.parse(raw)
|
|
303
|
+
} catch (err) {
|
|
304
|
+
throw new Error(`Invalid JSON in ${layer.label} at ${layer.path}: ${(err as Error).message}`)
|
|
305
|
+
}
|
|
306
|
+
|
|
289
307
|
const result = infraKitOverrideConfigSchema.safeParse(parsedRaw)
|
|
290
308
|
|
|
291
309
|
if (!result.success) {
|
|
@@ -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
|
+
})
|