tspl-cli 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -2
- package/bin/tspl.js +10 -0
- package/package.json +41 -4
- package/src/active-org.js +49 -0
- package/src/analysis/analyzer.js +61 -0
- package/src/analysis/extract.js +37 -0
- package/src/analysis/file-filter.js +69 -0
- package/src/analysis/git-reader.js +108 -0
- package/src/analysis/html-parser.js +46 -0
- package/src/analysis/js-parser.js +473 -0
- package/src/analysis/py-parser.js +66 -0
- package/src/analysis/repo-index.js +107 -0
- package/src/api-client.js +108 -0
- package/src/browser-tests.js +209 -0
- package/src/commands/analyze.command.js +81 -0
- package/src/commands/auth.command.js +250 -0
- package/src/commands/build.command.js +267 -0
- package/src/commands/connect.command.js +270 -0
- package/src/commands/engines.command.js +136 -0
- package/src/commands/find.command.js +107 -0
- package/src/commands/init.command.js +79 -0
- package/src/commands/link.command.js +65 -0
- package/src/commands/mcp.command.js +12 -0
- package/src/commands/member.command.js +82 -0
- package/src/commands/plan.command.js +161 -0
- package/src/commands/project.command.js +191 -0
- package/src/commands/setup.command.js +63 -0
- package/src/commands/sync.command.js +59 -0
- package/src/commands/task.command.js +475 -0
- package/src/commands/usage.command.js +105 -0
- package/src/config-store.js +255 -0
- package/src/connect/claude-skill.md +30 -0
- package/src/engines.js +218 -0
- package/src/find/graph.js +272 -0
- package/src/find/search.js +473 -0
- package/src/find/text.js +86 -0
- package/src/git-helper.js +102 -0
- package/src/index.js +91 -0
- package/src/mcp/server.js +708 -0
- package/src/project-config.js +197 -0
- package/src/project-init.js +447 -0
- package/src/providers/github.js +249 -0
- package/src/providers/index.js +83 -0
- package/src/runtime-check.js +18 -0
- package/src/setup.js +180 -0
- package/src/shared/approaches.js +44 -0
- package/src/shared/constants.js +89 -0
- package/src/shared/index.js +8 -0
- package/src/shared/workflow-progress.js +67 -0
- package/src/shared/workflow.js +381 -0
- package/src/stack-detector.js +47 -0
- package/src/task/approaches.js +53 -0
- package/src/task/branch-choice.js +69 -0
- package/src/task/brief.js +115 -0
- package/src/task/build.js +549 -0
- package/src/task/check.js +415 -0
- package/src/task/conventions.js +113 -0
- package/src/task/gate.js +88 -0
- package/src/task/hooks.js +82 -0
- package/src/task/plan-draft.js +43 -0
- package/src/task/plan-page.js +123 -0
- package/src/task/plan.js +293 -0
- package/src/task/planning-context.js +165 -0
- package/src/task/refine.js +73 -0
- package/src/workflow/agents.js +336 -0
- package/src/workflow/checkout-lock.js +92 -0
- package/src/workflow/client.js +82 -0
- package/src/workflow/controller.js +585 -0
- package/src/workflow/evidence.js +276 -0
- package/src/workflow/pr-body.js +52 -0
- package/src/workflow/process.js +82 -0
- package/src/workflow/review.js +128 -0
- package/src/workflow/runtime.js +148 -0
- package/src/workflow/wait.js +58 -0
- package/src/workspace.js +250 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Fs = require('fs')
|
|
4
|
+
const Path = require('path')
|
|
5
|
+
const { validateProjectConfig, validateCheck, hashValue } = require('./shared')
|
|
6
|
+
|
|
7
|
+
const { identify } = require('./git-helper')
|
|
8
|
+
|
|
9
|
+
const CONFIG_DIR = '.tspl'
|
|
10
|
+
const CONFIG_FILE = 'project.json'
|
|
11
|
+
const INSTRUCTIONS_FILE = 'instructions.md'
|
|
12
|
+
|
|
13
|
+
const inside = (dir, parent) => dir === parent || dir.startsWith(`${parent}${Path.sep}`)
|
|
14
|
+
|
|
15
|
+
const real = file => {
|
|
16
|
+
try {
|
|
17
|
+
return Fs.realpathSync(file)
|
|
18
|
+
} catch (err) {
|
|
19
|
+
if (err.code === 'ENOENT') return null
|
|
20
|
+
throw err
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const readConfigFile = root => {
|
|
25
|
+
const file = Path.join(root, CONFIG_DIR, CONFIG_FILE)
|
|
26
|
+
let parsed
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(Fs.readFileSync(file, 'utf8'))
|
|
29
|
+
} catch (err) {
|
|
30
|
+
throw new Error(`Cannot read ${file}: ${err.message}`)
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
return validateProjectConfig(parsed)
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw new Error(`${file} is not valid: ${err.message}`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The folder the repo paths are relative to: the manifest folder, or its parent when the manifest lives in one child repo. */
|
|
40
|
+
const workspaceRoot = (root, config) => {
|
|
41
|
+
if (!config.home) return root
|
|
42
|
+
if (Path.basename(root) !== config.home) {
|
|
43
|
+
throw new Error(`The project file says it lives in "${config.home}" but it was found in ${root}.`)
|
|
44
|
+
}
|
|
45
|
+
return Path.dirname(root)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const childManifest = (dir, start) => {
|
|
49
|
+
let entries
|
|
50
|
+
try {
|
|
51
|
+
entries = Fs.readdirSync(dir, { withFileTypes: true })
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
for (const entry of entries.filter(item => item.isDirectory() && !item.name.startsWith('.')).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
56
|
+
const root = Path.join(dir, entry.name)
|
|
57
|
+
if (!Fs.existsSync(Path.join(root, CONFIG_DIR, CONFIG_FILE))) continue
|
|
58
|
+
let config
|
|
59
|
+
try {
|
|
60
|
+
config = readConfigFile(root)
|
|
61
|
+
} catch (err) {
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if (config.home !== entry.name) continue
|
|
65
|
+
const belongs = start === dir || config.repos.some(repo => inside(start, Path.resolve(dir, repo.path)))
|
|
66
|
+
if (belongs) return { root, workspace: dir, config }
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const findProjectConfig = cwd => {
|
|
72
|
+
const start = real(Path.resolve(cwd))
|
|
73
|
+
let dir = start
|
|
74
|
+
while (dir) {
|
|
75
|
+
if (Fs.existsSync(Path.join(dir, CONFIG_DIR, CONFIG_FILE))) {
|
|
76
|
+
const config = readConfigFile(dir)
|
|
77
|
+
return { root: dir, workspace: workspaceRoot(dir, config), config }
|
|
78
|
+
}
|
|
79
|
+
if (!Fs.existsSync(Path.join(dir, '.git'))) {
|
|
80
|
+
const nested = childManifest(dir, start)
|
|
81
|
+
if (nested) return nested
|
|
82
|
+
}
|
|
83
|
+
const parent = Path.dirname(dir)
|
|
84
|
+
if (parent === dir) return null
|
|
85
|
+
dir = parent
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const sameIdentity = (expected, actual) =>
|
|
91
|
+
Boolean(
|
|
92
|
+
(expected.fingerprint && expected.fingerprint === actual.fingerprint) ||
|
|
93
|
+
(expected.remoteHash && expected.remoteHash === actual.remoteHash),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Maps each repo of the project file to a local checkout. The default location comes from the
|
|
98
|
+
* committed relative path; a personal link may point elsewhere. Either way the checkout must be
|
|
99
|
+
* the repository the project file names, so two projects with equally named repos never mix.
|
|
100
|
+
*/
|
|
101
|
+
const resolveRepoPaths = (root, config, links, deps = {}) => {
|
|
102
|
+
const identifyRepo = deps.identify || identify
|
|
103
|
+
const rootReal = real(workspaceRoot(root, config))
|
|
104
|
+
if (links && links.projectId && links.projectId !== config.project.id) {
|
|
105
|
+
throw new Error(`The local links belong to another project (identity mismatch for ${config.organization}/${config.project.slug}).`)
|
|
106
|
+
}
|
|
107
|
+
const map = new Map()
|
|
108
|
+
config.repos.forEach(repo => {
|
|
109
|
+
const override = links && links.repos && links.repos[repo.id] && links.repos[repo.id].path
|
|
110
|
+
let target
|
|
111
|
+
if (override && Fs.existsSync(override)) {
|
|
112
|
+
target = real(override)
|
|
113
|
+
} else {
|
|
114
|
+
const candidate = real(Path.resolve(rootReal, repo.path))
|
|
115
|
+
if (candidate && !inside(candidate, rootReal)) {
|
|
116
|
+
throw new Error(`${repo.name}: ${repo.path} resolves outside the workspace.`)
|
|
117
|
+
}
|
|
118
|
+
target = candidate
|
|
119
|
+
}
|
|
120
|
+
if (!target) return
|
|
121
|
+
if (!sameIdentity(repo, identifyRepo(target))) {
|
|
122
|
+
throw new Error(`${repo.name}: the checkout at ${target} does not match the repository identity in the project file.`)
|
|
123
|
+
}
|
|
124
|
+
map.set(repo.name, target)
|
|
125
|
+
})
|
|
126
|
+
return map
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const readProjectInstructions = (root, config) => {
|
|
130
|
+
const rootReal = real(root)
|
|
131
|
+
const files = config.instructions.map(relative => {
|
|
132
|
+
const file = real(Path.resolve(rootReal, relative))
|
|
133
|
+
if (!file) throw new Error(`Instruction file ${relative} is missing. Restore it or remove it from ${CONFIG_DIR}/${CONFIG_FILE}.`)
|
|
134
|
+
if (!inside(file, rootReal)) throw new Error(`Instruction file ${relative} resolves outside the project.`)
|
|
135
|
+
return { path: relative, content: Fs.readFileSync(file, 'utf8') }
|
|
136
|
+
})
|
|
137
|
+
return {
|
|
138
|
+
text: files.map(file => file.content.trim()).filter(Boolean).join('\n\n'),
|
|
139
|
+
hash: hashValue(files),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Project rules for briefs; empty for a folder without a project file. A broken file is an error, not silence. */
|
|
144
|
+
const projectInstructionsText = cwd => {
|
|
145
|
+
const found = findProjectConfig(cwd)
|
|
146
|
+
return found ? readProjectInstructions(found.root, found.config).text : ''
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const snapshotProject = (root, config, taskTests = {}) => {
|
|
150
|
+
const names = config.repos.map(repo => repo.name)
|
|
151
|
+
const taskChecks = (taskTests.checks || []).map((check, i) =>
|
|
152
|
+
validateCheck({ attested: false, criteria: [], ...check }, names, `taskTests.checks[${i}]`),
|
|
153
|
+
)
|
|
154
|
+
const ids = new Set(config.checks.map(check => check.id))
|
|
155
|
+
taskChecks.forEach(check => {
|
|
156
|
+
if (ids.has(check.id)) throw new Error(`Duplicate check id "${check.id}" between the project and the task tests.`)
|
|
157
|
+
ids.add(check.id)
|
|
158
|
+
})
|
|
159
|
+
return {
|
|
160
|
+
version: 1,
|
|
161
|
+
configHash: hashValue(config),
|
|
162
|
+
checks: [...config.checks, ...taskChecks],
|
|
163
|
+
manualCriteria: [...(taskTests.manualCriteria || [])],
|
|
164
|
+
acceptance: taskTests.acceptance || 0,
|
|
165
|
+
maxRepairRounds: config.workflow.maxRepairRounds,
|
|
166
|
+
instructionsHash: readProjectInstructions(root, config).hash,
|
|
167
|
+
repos: config.repos,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const writeFileSafely = (file, content) => {
|
|
172
|
+
Fs.mkdirSync(Path.dirname(file), { recursive: true })
|
|
173
|
+
const temp = `${file}.${process.pid}.tmp`
|
|
174
|
+
Fs.writeFileSync(temp, content)
|
|
175
|
+
Fs.renameSync(temp, file)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const writeProjectConfig = (root, config, instructions) => {
|
|
179
|
+
const dir = Path.join(root, CONFIG_DIR)
|
|
180
|
+
writeFileSafely(Path.join(dir, CONFIG_FILE), `${JSON.stringify(validateProjectConfig(config), null, 2)}\n`)
|
|
181
|
+
if (instructions !== undefined && instructions !== null) writeFileSafely(Path.join(dir, INSTRUCTIONS_FILE), instructions)
|
|
182
|
+
const ignore = Path.join(dir, '.gitignore')
|
|
183
|
+
if (!Fs.existsSync(ignore)) writeFileSafely(ignore, 'local/\n*.local.json\n*.log\n')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
CONFIG_DIR,
|
|
188
|
+
CONFIG_FILE,
|
|
189
|
+
INSTRUCTIONS_FILE,
|
|
190
|
+
workspaceRoot,
|
|
191
|
+
findProjectConfig,
|
|
192
|
+
resolveRepoPaths,
|
|
193
|
+
readProjectInstructions,
|
|
194
|
+
projectInstructionsText,
|
|
195
|
+
snapshotProject,
|
|
196
|
+
writeProjectConfig,
|
|
197
|
+
}
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Fs = require('fs')
|
|
4
|
+
const Path = require('path')
|
|
5
|
+
const { TEST_CATEGORIES, validateProjectConfig, WORKFLOW_DEFAULTS } = require('./shared')
|
|
6
|
+
|
|
7
|
+
const { ensureBrowserTests } = require('./browser-tests')
|
|
8
|
+
const { remoteUrl, normalizeGitUrl } = require('./git-helper')
|
|
9
|
+
const {
|
|
10
|
+
CONFIG_DIR,
|
|
11
|
+
CONFIG_FILE,
|
|
12
|
+
INSTRUCTIONS_FILE,
|
|
13
|
+
findProjectConfig,
|
|
14
|
+
resolveRepoPaths,
|
|
15
|
+
workspaceRoot,
|
|
16
|
+
writeProjectConfig,
|
|
17
|
+
} = require('./project-config')
|
|
18
|
+
|
|
19
|
+
const REFERENCE_START = '<!-- tspl:project:start -->'
|
|
20
|
+
const REFERENCE_END = '<!-- tspl:project:end -->'
|
|
21
|
+
const INSTRUCTION_FILES = ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md', '.cursorrules', '.github/copilot-instructions.md']
|
|
22
|
+
const PLACEHOLDER_SCRIPT = /no test specified|exit\s+1\s*$|^\s*echo\b/i
|
|
23
|
+
const SCRIPTS = [
|
|
24
|
+
['test', 'unit'],
|
|
25
|
+
['test:unit', 'unit'],
|
|
26
|
+
['test:integration', 'integration'],
|
|
27
|
+
['test:e2e', 'e2e'],
|
|
28
|
+
['e2e', 'e2e'],
|
|
29
|
+
['lint', 'lint'],
|
|
30
|
+
['typecheck', 'typecheck'],
|
|
31
|
+
['type-check', 'typecheck'],
|
|
32
|
+
['build', 'build'],
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const readJson = file => {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(Fs.readFileSync(file, 'utf8'))
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const packageManager = dir => {
|
|
44
|
+
const pkg = readJson(Path.join(dir, 'package.json')) || {}
|
|
45
|
+
if (typeof pkg.packageManager === 'string') return pkg.packageManager.split('@')[0]
|
|
46
|
+
if (Fs.existsSync(Path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm'
|
|
47
|
+
if (Fs.existsSync(Path.join(dir, 'yarn.lock'))) return 'yarn'
|
|
48
|
+
if (Fs.existsSync(Path.join(dir, 'bun.lockb')) || Fs.existsSync(Path.join(dir, 'bun.lock'))) return 'bun'
|
|
49
|
+
return 'npm'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const workspaceDirs = repoPath => {
|
|
53
|
+
const pkg = readJson(Path.join(repoPath, 'package.json'))
|
|
54
|
+
const globs = pkg && (Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces && pkg.workspaces.packages) || []
|
|
55
|
+
const dirs = []
|
|
56
|
+
globs.forEach(glob => {
|
|
57
|
+
const clean = String(glob).replace(/\/+$/, '')
|
|
58
|
+
if (clean.endsWith('/*')) {
|
|
59
|
+
const parent = Path.join(repoPath, clean.slice(0, -2))
|
|
60
|
+
if (!Fs.existsSync(parent)) return
|
|
61
|
+
Fs.readdirSync(parent, { withFileTypes: true })
|
|
62
|
+
.filter(entry => entry.isDirectory() && Fs.existsSync(Path.join(parent, entry.name, 'package.json')))
|
|
63
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
64
|
+
.forEach(entry => dirs.push(Path.posix.join(clean.slice(0, -2), entry.name)))
|
|
65
|
+
} else if (!clean.includes('*') && Fs.existsSync(Path.join(repoPath, clean, 'package.json'))) {
|
|
66
|
+
dirs.push(clean)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
return dirs
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const command = (manager, script) => {
|
|
73
|
+
if (script === 'test') return `${manager} test`
|
|
74
|
+
return `${manager} ${manager === 'yarn' ? '' : 'run '}${script}`.replace(' ', ' ')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const parserFor = (category, script) => {
|
|
78
|
+
if (!TEST_CATEGORIES.includes(category)) return { parser: 'exit-code', needsAttestation: false }
|
|
79
|
+
if (/node\s+(--[\w-]+\s+)*--test\b/.test(script)) return { parser: 'node-tap', needsAttestation: false }
|
|
80
|
+
if (/\bjest\b/.test(script)) return { parser: 'jest-json', needsAttestation: false }
|
|
81
|
+
return { parser: 'custom', needsAttestation: true }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const slug = value => value.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase()
|
|
85
|
+
|
|
86
|
+
const fromPackage = (repoPath, cwd, manager, workspaces) => {
|
|
87
|
+
const pkg = readJson(Path.join(repoPath, cwd, 'package.json'))
|
|
88
|
+
if (!pkg || !pkg.scripts) return []
|
|
89
|
+
const found = []
|
|
90
|
+
SCRIPTS.forEach(([name, category]) => {
|
|
91
|
+
const script = pkg.scripts[name]
|
|
92
|
+
if (typeof script !== 'string' || !script.trim()) return
|
|
93
|
+
if (PLACEHOLDER_SCRIPT.test(script.trim())) return
|
|
94
|
+
if (cwd === '.' && workspaces && /--workspaces|\s-ws\b|--workspace\b|\bworkspaces\b/.test(script)) return
|
|
95
|
+
if (found.some(item => item.category === category && category !== 'unit')) return
|
|
96
|
+
if (found.some(item => item.category === 'unit') && category === 'unit' && name !== 'test') return
|
|
97
|
+
const { parser, needsAttestation } = parserFor(category, script)
|
|
98
|
+
found.push({
|
|
99
|
+
id: `${category}${cwd === '.' ? '' : `-${slug(cwd)}`}`,
|
|
100
|
+
repo: '',
|
|
101
|
+
cwd,
|
|
102
|
+
category,
|
|
103
|
+
command: command(manager, name),
|
|
104
|
+
timeoutMs: WORKFLOW_DEFAULTS.checkTimeoutMs,
|
|
105
|
+
required: true,
|
|
106
|
+
parser,
|
|
107
|
+
attested: false,
|
|
108
|
+
criteria: [],
|
|
109
|
+
needsAttestation,
|
|
110
|
+
script,
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
return found
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const PLAYWRIGHT_CONFIG = /^playwright\.config\.[cm]?[jt]s$/
|
|
117
|
+
|
|
118
|
+
/** A Playwright setup becomes a browser check that reports each test case and keeps its screenshots and videos. */
|
|
119
|
+
const playwrightCheck = (repoPath, cwd) => {
|
|
120
|
+
const dir = Path.join(repoPath, cwd)
|
|
121
|
+
const pkg = readJson(Path.join(dir, 'package.json')) || {}
|
|
122
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
123
|
+
let hasConfig = false
|
|
124
|
+
try {
|
|
125
|
+
hasConfig = Fs.readdirSync(dir).some(name => PLAYWRIGHT_CONFIG.test(name))
|
|
126
|
+
} catch (err) {
|
|
127
|
+
hasConfig = false
|
|
128
|
+
}
|
|
129
|
+
if (!deps['@playwright/test'] && !hasConfig) return null
|
|
130
|
+
return {
|
|
131
|
+
id: `e2e-browser${cwd === '.' ? '' : `-${slug(cwd)}`}`,
|
|
132
|
+
repo: '',
|
|
133
|
+
cwd,
|
|
134
|
+
category: 'e2e',
|
|
135
|
+
command: 'npx playwright test --reporter=json',
|
|
136
|
+
timeoutMs: WORKFLOW_DEFAULTS.checkTimeoutMs,
|
|
137
|
+
required: true,
|
|
138
|
+
parser: 'playwright-json',
|
|
139
|
+
attested: false,
|
|
140
|
+
criteria: [],
|
|
141
|
+
artifacts: 'test-results',
|
|
142
|
+
needsAttestation: false,
|
|
143
|
+
script: 'playwright test',
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Reads package scripts without running them. Checks with needsAttestation use a test runner tspl cannot count. */
|
|
148
|
+
const discoverChecks = repoPath => {
|
|
149
|
+
const manager = packageManager(repoPath)
|
|
150
|
+
const dirs = workspaceDirs(repoPath)
|
|
151
|
+
const hasWorkspaces = dirs.length > 0
|
|
152
|
+
const found = [
|
|
153
|
+
...fromPackage(repoPath, '.', manager, hasWorkspaces),
|
|
154
|
+
...dirs.flatMap(dir => fromPackage(repoPath, dir, manager, false)),
|
|
155
|
+
]
|
|
156
|
+
;['.', ...dirs].forEach(cwd => {
|
|
157
|
+
const browser = playwrightCheck(repoPath, cwd)
|
|
158
|
+
if (!browser) return
|
|
159
|
+
// a script that already runs Playwright is replaced by the reporting command
|
|
160
|
+
const index = found.findIndex(check => check.cwd === cwd && /playwright/.test(check.script))
|
|
161
|
+
if (index !== -1) found.splice(index, 1)
|
|
162
|
+
found.push(browser)
|
|
163
|
+
})
|
|
164
|
+
return found
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Web apps without browser tests get Playwright set up, unless the user said --no-e2e. Returns the folders set up. */
|
|
168
|
+
const setUpBrowserTests = (repoPath, deps, options) => {
|
|
169
|
+
if (options.e2e === false) return []
|
|
170
|
+
const manager = packageManager(repoPath)
|
|
171
|
+
const installed = []
|
|
172
|
+
;['.', ...workspaceDirs(repoPath)].forEach(cwd => {
|
|
173
|
+
const dir = Path.join(repoPath, cwd)
|
|
174
|
+
const result = ensureBrowserTests(dir, manager, deps.run, deps.log)
|
|
175
|
+
if (result.status === 'installed') installed.push(dir)
|
|
176
|
+
if (result.status === 'failed') (deps.log || console.log)(` ! Browser tests were not set up in ${dir}: ${result.reason}. Run tspl init again to retry, or --no-e2e to skip.`)
|
|
177
|
+
})
|
|
178
|
+
return installed
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const githubRepository = dir => {
|
|
182
|
+
const url = remoteUrl(dir)
|
|
183
|
+
if (!url) return null
|
|
184
|
+
const match = normalizeGitUrl(url).match(/^github\.com\/([^/]+\/[^/]+)$/)
|
|
185
|
+
return match ? match[1] : null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const existingInstructionFiles = (root, repoPaths) => {
|
|
189
|
+
const found = []
|
|
190
|
+
const candidates = [root, ...repoPaths.filter(dir => dir !== root && dir.startsWith(`${root}${Path.sep}`))]
|
|
191
|
+
candidates.forEach(dir => {
|
|
192
|
+
INSTRUCTION_FILES.forEach(name => {
|
|
193
|
+
if (Fs.existsSync(Path.join(dir, name))) found.push(Path.relative(root, Path.join(dir, name)).split(Path.sep).join('/'))
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
return found
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const DEFAULT_INSTRUCTIONS = [
|
|
200
|
+
'# Project instructions for tspl',
|
|
201
|
+
'',
|
|
202
|
+
'These rules apply to every AI-assisted task in this project. They add to, and never weaken, the project\'s',
|
|
203
|
+
'existing instruction files and the permissions enforced by the tspl API.',
|
|
204
|
+
'',
|
|
205
|
+
'## Ready for review means',
|
|
206
|
+
'',
|
|
207
|
+
'- Every required check in `.tspl/project.json` ran on the exact commits of the pull request and passed.',
|
|
208
|
+
'- A separate review of the same commits passed.',
|
|
209
|
+
'- Required CI checks on the pull request passed for those commits.',
|
|
210
|
+
'',
|
|
211
|
+
'## Rules',
|
|
212
|
+
'',
|
|
213
|
+
'- Keep to the approved plan; ask for a plan change instead of editing other files.',
|
|
214
|
+
'- Add or update tests for the behaviour you change.',
|
|
215
|
+
'',
|
|
216
|
+
].join('\n')
|
|
217
|
+
|
|
218
|
+
const readiness = (config, localPaths) => {
|
|
219
|
+
const problems = []
|
|
220
|
+
const warnings = []
|
|
221
|
+
if (!config.checks.some(check => check.required && TEST_CATEGORIES.includes(check.category))) {
|
|
222
|
+
problems.push('No required test check is configured. Add one to .tspl/project.json (category unit, integration or e2e); a tested PR needs it.')
|
|
223
|
+
}
|
|
224
|
+
config.repos.forEach(repo => {
|
|
225
|
+
if (!localPaths.has(repo.name)) problems.push(`Repo ${repo.name} is not cloned on this machine.`)
|
|
226
|
+
if (!config.checks.some(check => check.repo === repo.name)) warnings.push(`Repo ${repo.name} has no checks.`)
|
|
227
|
+
if (repo.delivery.provider !== 'github') warnings.push(`Repo ${repo.name}: automatic pull requests need GitHub; other providers only get a compare link.`)
|
|
228
|
+
})
|
|
229
|
+
return { ready: problems.length === 0, problems, warnings }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const describeChange = (before, after) => {
|
|
233
|
+
const lines = []
|
|
234
|
+
const ids = new Set(before.checks.map(check => check.id))
|
|
235
|
+
after.checks.filter(check => !ids.has(check.id)).forEach(check => lines.push(`+ check ${check.id}: ${check.command} (${check.repo}, ${check.cwd})`))
|
|
236
|
+
const names = new Set(before.repos.map(repo => repo.name))
|
|
237
|
+
after.repos.filter(repo => !names.has(repo.name)).forEach(repo => lines.push(`+ repo ${repo.name} at ${repo.path}`))
|
|
238
|
+
const files = new Set(before.instructions)
|
|
239
|
+
after.instructions.filter(file => !files.has(file)).forEach(file => lines.push(`+ instructions ${file}`))
|
|
240
|
+
return lines
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const ask = async (deps, options, message, fallback) => (options.yes ? fallback : deps.prompts.confirm({ message, default: fallback }))
|
|
244
|
+
|
|
245
|
+
const buildChecks = async (repo, discovered, deps, options) => {
|
|
246
|
+
const chosen = []
|
|
247
|
+
for (const found of discovered) {
|
|
248
|
+
const label = `${repo.name}: ${found.command}${found.cwd === '.' ? '' : ` (in ${found.cwd})`} [${found.category}]`
|
|
249
|
+
if (!(await ask(deps, options, `Use this check? ${label}`, !found.needsAttestation))) continue
|
|
250
|
+
let attested = false
|
|
251
|
+
if (found.needsAttestation) {
|
|
252
|
+
attested = !options.yes && (await deps.prompts.confirm({
|
|
253
|
+
message: `tspl cannot count test results for "${found.script}". Confirm this command really runs tests and exits non-zero when one fails?`,
|
|
254
|
+
default: false,
|
|
255
|
+
}))
|
|
256
|
+
if (!attested) continue
|
|
257
|
+
}
|
|
258
|
+
const { needsAttestation, script, ...check } = found
|
|
259
|
+
chosen.push({ ...check, repo: repo.name, id: `${check.id}-${repo.name}`, attested })
|
|
260
|
+
}
|
|
261
|
+
return chosen
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const referenceBlock = '\n\nProject rules for AI-assisted tasks are kept in `.tspl/instructions.md` (managed by tspl).'
|
|
265
|
+
|
|
266
|
+
const addManagedReference = file => {
|
|
267
|
+
const current = Fs.existsSync(file) ? Fs.readFileSync(file, 'utf8') : ''
|
|
268
|
+
if (current.includes(REFERENCE_START)) return false
|
|
269
|
+
Fs.writeFileSync(file, `${current.trimEnd()}${current.trim() ? '\n\n' : ''}${REFERENCE_START}${referenceBlock}\n${REFERENCE_END}\n`)
|
|
270
|
+
return true
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const cancelled = (err, project) => {
|
|
274
|
+
if (err && err.name === 'ExitPromptError') {
|
|
275
|
+
return new Error(`Cancelled. No project files were written or changed.${project ? ` ${project.name} was already linked on this machine.` : ''}`)
|
|
276
|
+
}
|
|
277
|
+
return err
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const isGit = dir => Fs.existsSync(Path.join(dir, '.git'))
|
|
281
|
+
|
|
282
|
+
const chooseManifestHome = async (folder, linked, deps, options) => {
|
|
283
|
+
if (isGit(folder)) return { root: folder, home: undefined }
|
|
284
|
+
const repoDirs = linked.map(repo => repo.path).filter(dir => Path.dirname(dir) === folder)
|
|
285
|
+
let chosen = options.manifestHome ? Path.resolve(folder, options.manifestHome) : null
|
|
286
|
+
if (!chosen && !options.yes && repoDirs.length) {
|
|
287
|
+
chosen = await deps.prompts.select({
|
|
288
|
+
message: `${folder} is not a Git repository, so files saved there would not be shared. Which repository should hold the shared .tspl files?`,
|
|
289
|
+
choices: repoDirs.map(dir => ({ name: Path.basename(dir), value: dir })),
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
if (!chosen) return null
|
|
293
|
+
if (!repoDirs.includes(chosen)) throw new Error(`${chosen} is not one of the repositories in ${folder}.`)
|
|
294
|
+
return { root: chosen, home: Path.basename(chosen) }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const linkFromConfig = (project, root, config, deps) => {
|
|
298
|
+
const links = deps.links.read()
|
|
299
|
+
const key = `${deps.credentials.read().organization}/${project.slug}`
|
|
300
|
+
const paths = resolveRepoPaths(root, config, links[key])
|
|
301
|
+
const previous = links[key] || {}
|
|
302
|
+
const repos = { ...(previous.repos || {}) }
|
|
303
|
+
config.repos.forEach(repo => {
|
|
304
|
+
if (paths.has(repo.name)) repos[repo.id] = { name: repo.name, path: paths.get(repo.name) }
|
|
305
|
+
})
|
|
306
|
+
links[key] = { projectId: project._id, folder: previous.folder || workspaceRoot(root, config), repos, ignored: previous.ignored || [] }
|
|
307
|
+
deps.links.write(links)
|
|
308
|
+
return paths
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Confirms with the server that a project file belongs to a project this user can see; a file never grants access. */
|
|
312
|
+
const verifyServerIdentity = async (config, deps) => {
|
|
313
|
+
const credentials = deps.credentials.read()
|
|
314
|
+
if (credentials.organization !== config.organization) {
|
|
315
|
+
throw new Error(`This project belongs to organization "${config.organization}" but you are logged in to "${credentials.organization}".`)
|
|
316
|
+
}
|
|
317
|
+
let project
|
|
318
|
+
try {
|
|
319
|
+
project = await deps.request('GET', `/api/v1/projects/${encodeURIComponent(config.project.slug)}`)
|
|
320
|
+
} catch (err) {
|
|
321
|
+
if (err.statusCode === 404 || err.statusCode === 403) {
|
|
322
|
+
throw new Error(`The project file names project "${config.project.slug}", which you cannot access. Ask an owner, admin or PM to add you.`)
|
|
323
|
+
}
|
|
324
|
+
throw err
|
|
325
|
+
}
|
|
326
|
+
if (project._id !== config.project.id) {
|
|
327
|
+
throw new Error(`The project file's project id does not match "${config.project.slug}" on the server (identity mismatch).`)
|
|
328
|
+
}
|
|
329
|
+
const known = new Set((project.repos || []).map(repo => repo._id))
|
|
330
|
+
config.repos.forEach(repo => {
|
|
331
|
+
if (!known.has(repo.id)) throw new Error(`Repo ${repo.name} is not part of project ${config.project.slug} on the server (identity mismatch).`)
|
|
332
|
+
})
|
|
333
|
+
return project
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Initializes (or re-adopts) the project in a folder. Machine login must exist; no project file can add access.
|
|
338
|
+
* Returns { root, config, created, readiness } or null when the user declined to create a project.
|
|
339
|
+
*/
|
|
340
|
+
const initializeProject = async (folderArg, options = {}, deps) => {
|
|
341
|
+
if (!deps.credentials.read().accessToken) throw new Error('You are not logged in. Run: tspl setup')
|
|
342
|
+
const folder = Fs.realpathSync(Path.resolve(folderArg || process.cwd()))
|
|
343
|
+
|
|
344
|
+
const existing = findProjectConfig(folder)
|
|
345
|
+
if (existing) {
|
|
346
|
+
const project = await verifyServerIdentity(existing.config, deps)
|
|
347
|
+
const paths = linkFromConfig(project, existing.root, existing.config, deps)
|
|
348
|
+
const discovered = []
|
|
349
|
+
let browserTestsAdded = false
|
|
350
|
+
for (const repo of existing.config.repos) {
|
|
351
|
+
const dir = paths.get(repo.name)
|
|
352
|
+
if (!dir) continue
|
|
353
|
+
if (setUpBrowserTests(dir, deps, options).length) browserTestsAdded = true
|
|
354
|
+
const known = new Set(existing.config.checks.filter(check => check.repo === repo.name).map(check => `${check.cwd}|${check.command}`))
|
|
355
|
+
const fresh = discoverChecks(dir).filter(check => !known.has(`${check.cwd}|${check.command}`))
|
|
356
|
+
discovered.push(...(await buildChecks(repo, fresh, deps, options)))
|
|
357
|
+
}
|
|
358
|
+
const merged = validateProjectConfig({ ...existing.config, checks: [...existing.config.checks, ...discovered.filter(check => !existing.config.checks.some(item => item.id === check.id))] })
|
|
359
|
+
const changes = describeChange(existing.config, merged)
|
|
360
|
+
let config = existing.config
|
|
361
|
+
if (changes.length) {
|
|
362
|
+
console.log(`Proposed changes to ${CONFIG_DIR}/${CONFIG_FILE}:\n${changes.map(line => ` ${line}`).join('\n')}`)
|
|
363
|
+
// browser tests tspl just set up are applied without asking again
|
|
364
|
+
if (await ask(deps, options, 'Apply them?', browserTestsAdded)) {
|
|
365
|
+
writeProjectConfig(existing.root, merged)
|
|
366
|
+
config = merged
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return { root: existing.root, config, created: false, readiness: readiness(config, paths) }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const linked = await deps.setupProject(folder, options)
|
|
373
|
+
if (!linked) return null
|
|
374
|
+
const { project } = linked
|
|
375
|
+
const localRepos = linked.linked
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const manifest = await chooseManifestHome(folder, localRepos, deps, options)
|
|
379
|
+
if (!manifest) {
|
|
380
|
+
return {
|
|
381
|
+
root: null,
|
|
382
|
+
config: null,
|
|
383
|
+
created: false,
|
|
384
|
+
readiness: {
|
|
385
|
+
ready: false,
|
|
386
|
+
problems: [`${folder} is not a Git repository. Choose the repository that should hold the shared .tspl files (tspl init --manifest-home <repo>); tspl will not create a Git repository for you.`],
|
|
387
|
+
warnings: [],
|
|
388
|
+
},
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const workspace = manifest.home ? folder : manifest.root
|
|
392
|
+
|
|
393
|
+
const repos = []
|
|
394
|
+
const checks = []
|
|
395
|
+
for (const local of localRepos) {
|
|
396
|
+
const delivery = githubRepository(local.path)
|
|
397
|
+
const repo = {
|
|
398
|
+
id: local._id,
|
|
399
|
+
name: local.name,
|
|
400
|
+
fingerprint: local.fingerprint || undefined,
|
|
401
|
+
remoteHash: local.remoteHash || undefined,
|
|
402
|
+
path: Path.relative(workspace, local.path).split(Path.sep).join('/') || '.',
|
|
403
|
+
delivery: { provider: delivery ? 'github' : 'manual', repository: delivery || local.name, requiredChecks: [], ciPolicy: 'discover' },
|
|
404
|
+
}
|
|
405
|
+
repos.push(repo)
|
|
406
|
+
setUpBrowserTests(local.path, deps, options)
|
|
407
|
+
checks.push(...(await buildChecks(repo, discoverChecks(local.path), deps, options)))
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const instructionFiles = existingInstructionFiles(manifest.root, localRepos.map(repo => repo.path))
|
|
411
|
+
const config = validateProjectConfig({
|
|
412
|
+
schemaVersion: 1,
|
|
413
|
+
...(manifest.home ? { home: manifest.home } : {}),
|
|
414
|
+
organization: deps.credentials.read().organization,
|
|
415
|
+
project: { id: project._id, slug: project.slug },
|
|
416
|
+
repos,
|
|
417
|
+
checks,
|
|
418
|
+
workflow: WORKFLOW_DEFAULTS,
|
|
419
|
+
instructions: [`${CONFIG_DIR}/${INSTRUCTIONS_FILE}`, ...instructionFiles],
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
const instructionsPath = Path.join(manifest.root, CONFIG_DIR, INSTRUCTIONS_FILE)
|
|
423
|
+
writeProjectConfig(manifest.root, config, Fs.existsSync(instructionsPath) ? undefined : DEFAULT_INSTRUCTIONS)
|
|
424
|
+
if (!options.yes && instructionFiles.length) {
|
|
425
|
+
for (const file of instructionFiles) {
|
|
426
|
+
if (await deps.prompts.confirm({ message: `Add a short, clearly marked pointer to the project rules in ${file}? Nothing else in it changes.`, default: false })) {
|
|
427
|
+
addManagedReference(Path.join(manifest.root, file))
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const paths = resolveRepoPaths(manifest.root, config, deps.links.read()[`${config.organization}/${project.slug}`])
|
|
433
|
+
return { root: manifest.root, config, created: true, readiness: readiness(config, paths) }
|
|
434
|
+
} catch (err) {
|
|
435
|
+
throw cancelled(err, project)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
module.exports = {
|
|
440
|
+
discoverChecks,
|
|
441
|
+
initializeProject,
|
|
442
|
+
addManagedReference,
|
|
443
|
+
readiness,
|
|
444
|
+
existingInstructionFiles,
|
|
445
|
+
REFERENCE_START,
|
|
446
|
+
REFERENCE_END,
|
|
447
|
+
}
|