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.
Files changed (75) hide show
  1. package/README.md +127 -2
  2. package/bin/tspl.js +10 -0
  3. package/package.json +41 -4
  4. package/src/active-org.js +49 -0
  5. package/src/analysis/analyzer.js +61 -0
  6. package/src/analysis/extract.js +37 -0
  7. package/src/analysis/file-filter.js +69 -0
  8. package/src/analysis/git-reader.js +108 -0
  9. package/src/analysis/html-parser.js +46 -0
  10. package/src/analysis/js-parser.js +473 -0
  11. package/src/analysis/py-parser.js +66 -0
  12. package/src/analysis/repo-index.js +107 -0
  13. package/src/api-client.js +108 -0
  14. package/src/browser-tests.js +209 -0
  15. package/src/commands/analyze.command.js +81 -0
  16. package/src/commands/auth.command.js +250 -0
  17. package/src/commands/build.command.js +267 -0
  18. package/src/commands/connect.command.js +270 -0
  19. package/src/commands/engines.command.js +136 -0
  20. package/src/commands/find.command.js +107 -0
  21. package/src/commands/init.command.js +79 -0
  22. package/src/commands/link.command.js +65 -0
  23. package/src/commands/mcp.command.js +12 -0
  24. package/src/commands/member.command.js +82 -0
  25. package/src/commands/plan.command.js +161 -0
  26. package/src/commands/project.command.js +191 -0
  27. package/src/commands/setup.command.js +63 -0
  28. package/src/commands/sync.command.js +59 -0
  29. package/src/commands/task.command.js +475 -0
  30. package/src/commands/usage.command.js +105 -0
  31. package/src/config-store.js +255 -0
  32. package/src/connect/claude-skill.md +30 -0
  33. package/src/engines.js +218 -0
  34. package/src/find/graph.js +272 -0
  35. package/src/find/search.js +473 -0
  36. package/src/find/text.js +86 -0
  37. package/src/git-helper.js +102 -0
  38. package/src/index.js +91 -0
  39. package/src/mcp/server.js +708 -0
  40. package/src/project-config.js +197 -0
  41. package/src/project-init.js +447 -0
  42. package/src/providers/github.js +249 -0
  43. package/src/providers/index.js +83 -0
  44. package/src/runtime-check.js +18 -0
  45. package/src/setup.js +180 -0
  46. package/src/shared/approaches.js +44 -0
  47. package/src/shared/constants.js +89 -0
  48. package/src/shared/index.js +8 -0
  49. package/src/shared/workflow-progress.js +67 -0
  50. package/src/shared/workflow.js +381 -0
  51. package/src/stack-detector.js +47 -0
  52. package/src/task/approaches.js +53 -0
  53. package/src/task/branch-choice.js +69 -0
  54. package/src/task/brief.js +115 -0
  55. package/src/task/build.js +549 -0
  56. package/src/task/check.js +415 -0
  57. package/src/task/conventions.js +113 -0
  58. package/src/task/gate.js +88 -0
  59. package/src/task/hooks.js +82 -0
  60. package/src/task/plan-draft.js +43 -0
  61. package/src/task/plan-page.js +123 -0
  62. package/src/task/plan.js +293 -0
  63. package/src/task/planning-context.js +165 -0
  64. package/src/task/refine.js +73 -0
  65. package/src/workflow/agents.js +336 -0
  66. package/src/workflow/checkout-lock.js +92 -0
  67. package/src/workflow/client.js +82 -0
  68. package/src/workflow/controller.js +585 -0
  69. package/src/workflow/evidence.js +276 -0
  70. package/src/workflow/pr-body.js +52 -0
  71. package/src/workflow/process.js +82 -0
  72. package/src/workflow/review.js +128 -0
  73. package/src/workflow/runtime.js +148 -0
  74. package/src/workflow/wait.js +58 -0
  75. 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
+ }