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,108 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
readConfig,
|
|
5
|
+
readCredentials,
|
|
6
|
+
writeCredentials,
|
|
7
|
+
} = require('./config-store')
|
|
8
|
+
|
|
9
|
+
class ApiError extends Error {
|
|
10
|
+
constructor(message, statusCode) {
|
|
11
|
+
super(message)
|
|
12
|
+
this.statusCode = statusCode
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const originOf = url => {
|
|
17
|
+
try {
|
|
18
|
+
return new URL(url).origin
|
|
19
|
+
} catch (err) {
|
|
20
|
+
return String(url)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const credentialsFor = apiUrl => {
|
|
25
|
+
const credentials = readCredentials()
|
|
26
|
+
if (credentials.apiUrl && originOf(credentials.apiUrl) !== originOf(apiUrl)) {
|
|
27
|
+
throw new ApiError(`You are logged in to ${originOf(credentials.apiUrl)}, not ${originOf(apiUrl)}. Run: tspl login`, 401)
|
|
28
|
+
}
|
|
29
|
+
return credentials
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const send = async (apiUrl, method, path, body, accessToken) => {
|
|
33
|
+
const payload = body === undefined ? undefined : JSON.stringify(body)
|
|
34
|
+
const attempt = () =>
|
|
35
|
+
fetch(`${apiUrl}${path}`, {
|
|
36
|
+
method,
|
|
37
|
+
headers: {
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
40
|
+
},
|
|
41
|
+
body: payload,
|
|
42
|
+
})
|
|
43
|
+
let res
|
|
44
|
+
try {
|
|
45
|
+
res = await attempt()
|
|
46
|
+
} catch (first) {
|
|
47
|
+
// A kept-alive connection the server already closed (for example after minutes of running tests) fails once;
|
|
48
|
+
// the request never reached the server, so it is sent again on a fresh connection.
|
|
49
|
+
try {
|
|
50
|
+
res = await attempt()
|
|
51
|
+
} catch (err) {
|
|
52
|
+
const cause = err.cause ? err.cause.code || err.cause.message : err.message
|
|
53
|
+
throw new ApiError(`Cannot reach the TSPL API at ${apiUrl}${cause ? ` (${cause})` : ''}`, 0)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const text = await res.text()
|
|
58
|
+
const data = text ? JSON.parse(text) : null
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
throw new ApiError((data && data.message) || res.statusText, res.status)
|
|
61
|
+
}
|
|
62
|
+
return data
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const refreshTokens = async apiUrl => {
|
|
66
|
+
const credentials = credentialsFor(apiUrl)
|
|
67
|
+
if (!credentials.refreshToken) {
|
|
68
|
+
throw new ApiError('You are not logged in. Run: tspl login', 401)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const tokens = await send(apiUrl, 'POST', '/api/v1/auth/refresh', {
|
|
73
|
+
refreshToken: credentials.refreshToken,
|
|
74
|
+
})
|
|
75
|
+
writeCredentials({ ...credentials, ...tokens })
|
|
76
|
+
return tokens.accessToken
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (err.statusCode === 401) {
|
|
79
|
+
throw new ApiError('Your session has expired. Run: tspl login', 401)
|
|
80
|
+
}
|
|
81
|
+
throw err
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const request = async (method, path, body) => {
|
|
86
|
+
const { apiUrl } = readConfig()
|
|
87
|
+
const credentials = credentialsFor(apiUrl)
|
|
88
|
+
if (!credentials.accessToken) {
|
|
89
|
+
throw new ApiError('You are not logged in. Run: tspl login', 401)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
return await send(apiUrl, method, path, body, credentials.accessToken)
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err.statusCode !== 401) {
|
|
96
|
+
throw err
|
|
97
|
+
}
|
|
98
|
+
const accessToken = await refreshTokens(apiUrl)
|
|
99
|
+
return send(apiUrl, method, path, body, accessToken)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = {
|
|
104
|
+
ApiError,
|
|
105
|
+
originOf,
|
|
106
|
+
send,
|
|
107
|
+
request,
|
|
108
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Fs = require('fs')
|
|
4
|
+
const Path = require('path')
|
|
5
|
+
const { spawnSync } = require('child_process')
|
|
6
|
+
|
|
7
|
+
const PLAYWRIGHT_VERSION = '1.55.1'
|
|
8
|
+
const PLAYWRIGHT_CONFIG = /^playwright\.config\.[cm]?[jt]s$/
|
|
9
|
+
|
|
10
|
+
const readJson = file => {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(Fs.readFileSync(file, 'utf8'))
|
|
13
|
+
} catch (err) {
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const runScript = (manager, script) => (script === 'start' ? `${manager} start` : `${manager} run ${script}`)
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How to start this package's web app for browser tests, or null when it is not a web app.
|
|
22
|
+
* Only frameworks whose dev server port is known by default; anything else is left alone.
|
|
23
|
+
*/
|
|
24
|
+
const webApp = (dir, manager) => {
|
|
25
|
+
const pkg = readJson(Path.join(dir, 'package.json'))
|
|
26
|
+
if (!pkg) return null
|
|
27
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
28
|
+
const scripts = pkg.scripts || {}
|
|
29
|
+
const has = name => Object.prototype.hasOwnProperty.call(deps, name)
|
|
30
|
+
if (has('react-native') || has('expo') || has('electron')) return null
|
|
31
|
+
const script = name => (scripts[name] ? runScript(manager, name) : null)
|
|
32
|
+
if (has('next')) return { framework: 'Next.js', command: script('dev') || 'npx next dev', port: 3000 }
|
|
33
|
+
if (has('nuxt')) return { framework: 'Nuxt', command: script('dev') || 'npx nuxi dev', port: 3000 }
|
|
34
|
+
if (has('@angular/core')) return { framework: 'Angular', command: script('start') || 'npx ng serve', port: 4200 }
|
|
35
|
+
if (has('react-scripts')) return { framework: 'Create React App', command: script('start') || 'npx react-scripts start', port: 3000 }
|
|
36
|
+
if (has('@sveltejs/kit') || (has('vite') && (has('react') || has('vue') || has('svelte') || has('solid-js') || has('preact')))) {
|
|
37
|
+
return { framework: 'Vite', command: script('dev') || 'npx vite', port: 5173 }
|
|
38
|
+
}
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const hasDependency = dir => {
|
|
43
|
+
const pkg = readJson(Path.join(dir, 'package.json')) || {}
|
|
44
|
+
return !!{ ...pkg.dependencies, ...pkg.devDependencies }['@playwright/test']
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A Playwright config means the project already chose its browser test setup; tspl never rewrites it. */
|
|
48
|
+
const hasPlaywright = dir => {
|
|
49
|
+
try {
|
|
50
|
+
return Fs.readdirSync(dir).some(name => PLAYWRIGHT_CONFIG.test(name))
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const installCommand = manager => {
|
|
57
|
+
const spec = `@playwright/test@${PLAYWRIGHT_VERSION}`
|
|
58
|
+
if (manager === 'yarn') return ['yarn', ['add', '-D', spec]]
|
|
59
|
+
if (manager === 'pnpm') return ['pnpm', ['add', '-D', spec]]
|
|
60
|
+
if (manager === 'bun') return ['bun', ['add', '-d', spec]]
|
|
61
|
+
return ['npm', ['install', '-D', spec]]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const configFile = app => `import { defineConfig, devices } from '@playwright/test'
|
|
65
|
+
|
|
66
|
+
// Set up by tspl init. Name each test after the test case it proves ("TC1: ...") and tspl links its
|
|
67
|
+
// result and screenshots to that case in the test report.
|
|
68
|
+
export default defineConfig({
|
|
69
|
+
testDir: './e2e',
|
|
70
|
+
outputDir: './test-results',
|
|
71
|
+
fullyParallel: true,
|
|
72
|
+
retries: 0,
|
|
73
|
+
use: {
|
|
74
|
+
baseURL: process.env.BASE_URL || 'http://localhost:${app.port}',
|
|
75
|
+
screenshot: 'on',
|
|
76
|
+
trace: 'retain-on-failure',
|
|
77
|
+
},
|
|
78
|
+
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
|
79
|
+
webServer: process.env.BASE_URL
|
|
80
|
+
? undefined
|
|
81
|
+
: {
|
|
82
|
+
command: '${app.command}',
|
|
83
|
+
url: 'http://localhost:${app.port}',
|
|
84
|
+
reuseExistingServer: true,
|
|
85
|
+
timeout: 120000,
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
`
|
|
89
|
+
|
|
90
|
+
const SMOKE_TEST = `import { expect, test } from '@playwright/test'
|
|
91
|
+
|
|
92
|
+
// Set up by tspl init: keeps the browser check meaningful before any task adds its own tests.
|
|
93
|
+
test('smoke: the home page loads', async ({ page }) => {
|
|
94
|
+
await page.goto('/')
|
|
95
|
+
await expect(page.locator('body')).toBeVisible()
|
|
96
|
+
await page.screenshot({ path: test.info().outputPath('home.png'), fullPage: true })
|
|
97
|
+
})
|
|
98
|
+
`
|
|
99
|
+
|
|
100
|
+
const GITIGNORE_LINES = ['/test-results/', '/playwright-report/', '/blob-report/', '/playwright/.cache/']
|
|
101
|
+
|
|
102
|
+
const ignoreOutputs = dir => {
|
|
103
|
+
const file = Path.join(dir, '.gitignore')
|
|
104
|
+
const current = Fs.existsSync(file) ? Fs.readFileSync(file, 'utf8') : ''
|
|
105
|
+
const lines = new Set(current.split('\n').map(line => line.trim()))
|
|
106
|
+
const missing = GITIGNORE_LINES.filter(line => !lines.has(line) && !lines.has(line.replace(/^\//, '')))
|
|
107
|
+
if (!missing.length) return
|
|
108
|
+
Fs.writeFileSync(file, `${current.trimEnd()}${current.trim() ? '\n\n' : ''}# Playwright (added by tspl init)\n${missing.join('\n')}\n`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const VITEST_CONFIG = `import { configDefaults, defineConfig } from 'vitest/config'
|
|
112
|
+
|
|
113
|
+
// Set up by tspl init: browser tests in e2e/ run with Playwright, not with the unit test runner.
|
|
114
|
+
export default defineConfig({
|
|
115
|
+
test: {
|
|
116
|
+
exclude: [...configDefaults.exclude, 'e2e/**'],
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
`
|
|
120
|
+
|
|
121
|
+
const hasFile = (dir, pattern) => {
|
|
122
|
+
try {
|
|
123
|
+
return Fs.readdirSync(dir).some(name => pattern.test(name))
|
|
124
|
+
} catch (err) {
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Vitest and Jest both pick up *.spec.ts by default, so they would also run (and fail on) the Playwright tests in e2e/.
|
|
131
|
+
* Adds the exclusion where tspl can do it without rewriting a config the project wrote itself; otherwise says what to add.
|
|
132
|
+
*/
|
|
133
|
+
const separateUnitTests = (dir, log = console.log) => {
|
|
134
|
+
if (!Fs.existsSync(Path.join(dir, 'e2e'))) return []
|
|
135
|
+
const pkgFile = Path.join(dir, 'package.json')
|
|
136
|
+
const pkg = readJson(pkgFile) || {}
|
|
137
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
138
|
+
const changed = []
|
|
139
|
+
if (deps.vitest) {
|
|
140
|
+
if (hasFile(dir, /^vitest\.config\.[cm]?[jt]s$/) || hasFile(dir, /^vite\.config\.[cm]?[jt]s$/)) {
|
|
141
|
+
const existing = Fs.readdirSync(dir).find(name => /^vite(st)?\.config\.[cm]?[jt]s$/.test(name))
|
|
142
|
+
if (!/e2e/.test(Fs.readFileSync(Path.join(dir, existing), 'utf8'))) {
|
|
143
|
+
log(` ! Add 'e2e/**' to test.exclude in ${existing} so Vitest does not run the Playwright tests.`)
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
Fs.writeFileSync(Path.join(dir, 'vitest.config.ts'), VITEST_CONFIG)
|
|
147
|
+
changed.push('vitest.config.ts')
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (deps.jest || deps['ts-jest']) {
|
|
151
|
+
if (hasFile(dir, /^jest\.config\.[cm]?[jt]s(on)?$/)) {
|
|
152
|
+
const existing = Fs.readdirSync(dir).find(name => /^jest\.config\./.test(name))
|
|
153
|
+
if (!/e2e/.test(Fs.readFileSync(Path.join(dir, existing), 'utf8'))) {
|
|
154
|
+
log(` ! Add '<rootDir>/e2e/' to testPathIgnorePatterns in ${existing} so Jest does not run the Playwright tests.`)
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
const jest = pkg.jest || {}
|
|
158
|
+
const ignore = jest.testPathIgnorePatterns || ['/node_modules/']
|
|
159
|
+
if (!ignore.some(item => /e2e/.test(item))) {
|
|
160
|
+
pkg.jest = { ...jest, testPathIgnorePatterns: [...ignore, '<rootDir>/e2e/'] }
|
|
161
|
+
Fs.writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`)
|
|
162
|
+
changed.push('package.json')
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return changed
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const defaultRun = (cmd, args, cwd) => {
|
|
170
|
+
const result = spawnSync(cmd, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' })
|
|
171
|
+
return result.status === 0
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Gives a web app browser tests so test cases get real screenshots: installs Playwright and Chromium,
|
|
176
|
+
* writes a config that screenshots every test and starts the dev server, and adds one smoke test.
|
|
177
|
+
* Never touches a package that already has a Playwright config; a half-finished setup (package installed, no config)
|
|
178
|
+
* is completed. Returns what happened, never throws for a failed install.
|
|
179
|
+
*/
|
|
180
|
+
const ensureBrowserTests = (dir, manager, run = defaultRun, log = console.log) => {
|
|
181
|
+
if (hasPlaywright(dir)) {
|
|
182
|
+
const separated = separateUnitTests(dir, log)
|
|
183
|
+
return separated.length ? { status: 'separated', files: separated } : { status: 'present' }
|
|
184
|
+
}
|
|
185
|
+
const app = webApp(dir, manager)
|
|
186
|
+
if (!app) return { status: 'not-web' }
|
|
187
|
+
log(`Setting up browser tests (Playwright) for this ${app.framework} app in ${dir}`)
|
|
188
|
+
const [cmd, args] = installCommand(manager)
|
|
189
|
+
if (!hasDependency(dir) && !run(cmd, args, dir)) return { status: 'failed', reason: `${cmd} ${args.join(' ')} failed` }
|
|
190
|
+
if (!run('npx', ['playwright', 'install', 'chromium'], dir)) return { status: 'failed', reason: 'npx playwright install chromium failed' }
|
|
191
|
+
const config = Path.join(dir, 'playwright.config.ts')
|
|
192
|
+
if (!Fs.existsSync(config)) Fs.writeFileSync(config, configFile(app))
|
|
193
|
+
const smoke = Path.join(dir, 'e2e', 'smoke.spec.ts')
|
|
194
|
+
if (!Fs.existsSync(Path.join(dir, 'e2e'))) {
|
|
195
|
+
Fs.mkdirSync(Path.dirname(smoke), { recursive: true })
|
|
196
|
+
Fs.writeFileSync(smoke, SMOKE_TEST)
|
|
197
|
+
}
|
|
198
|
+
ignoreOutputs(dir)
|
|
199
|
+
separateUnitTests(dir, log)
|
|
200
|
+
return { status: 'installed', framework: app.framework }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = {
|
|
204
|
+
ensureBrowserTests,
|
|
205
|
+
webApp,
|
|
206
|
+
hasPlaywright,
|
|
207
|
+
separateUnitTests,
|
|
208
|
+
PLAYWRIGHT_VERSION,
|
|
209
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { setupProject } = require('../workspace')
|
|
4
|
+
const { refreshIndex, describeRepo } = require('../analysis/analyzer')
|
|
5
|
+
|
|
6
|
+
const LABELS = [
|
|
7
|
+
['routes', 'API routes'],
|
|
8
|
+
['models', 'models'],
|
|
9
|
+
['uiRoutes', 'UI routes'],
|
|
10
|
+
['screens', 'app screens'],
|
|
11
|
+
['components', 'components'],
|
|
12
|
+
['httpCalls', 'API calls'],
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
const SKIPPED = [
|
|
16
|
+
['skipped-secret', 'secret'],
|
|
17
|
+
['skipped-binary', 'binary'],
|
|
18
|
+
['skipped-generated', 'generated'],
|
|
19
|
+
['skipped-large', 'too large'],
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
const describe = result => {
|
|
23
|
+
const { stats } = result
|
|
24
|
+
const found = LABELS.filter(([key]) => stats[key]).map(([key, label]) => `${stats[key]} ${label}`)
|
|
25
|
+
const skipped = SKIPPED.filter(([key]) => stats.byStatus[key]).map(([key, label]) => `${stats.byStatus[key]} ${label}`)
|
|
26
|
+
const reused = result.codeFiles - result.newlyAnalyzed
|
|
27
|
+
return [
|
|
28
|
+
`${result.branch}@${result.commitSha.slice(0, 7)} (${result.date})`,
|
|
29
|
+
`${result.codeFiles} code files${reused ? ` (${reused} unchanged, reused)` : ''}`,
|
|
30
|
+
found.length ? found.join(', ') : 'no structure found',
|
|
31
|
+
skipped.length ? `skipped: ${skipped.join(', ')}` : null,
|
|
32
|
+
]
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.join(' · ')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const analyze = async options => {
|
|
38
|
+
const setup = await setupProject(process.cwd(), {})
|
|
39
|
+
if (!setup) {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { project } = setup
|
|
44
|
+
const repos = options.repo
|
|
45
|
+
? setup.linked.filter(repo => repo.name === options.repo)
|
|
46
|
+
: setup.linked
|
|
47
|
+
if (!repos.length) {
|
|
48
|
+
throw new Error(options.repo ? `"${options.repo}" is not linked in this folder` : 'No linked repos to analyze')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const started = Date.now()
|
|
52
|
+
let failed = 0
|
|
53
|
+
console.log(`Reading ${repos.length} repo(s) of ${project.name}. No AI is used in this step.\n`)
|
|
54
|
+
const fail = (repo, err) => {
|
|
55
|
+
failed += 1
|
|
56
|
+
const hint = /branch ".*" not found/.test(err.message)
|
|
57
|
+
? ` Fix it with: tspl project set-branch ${project.slug} ${repo.name} <branch>`
|
|
58
|
+
: ''
|
|
59
|
+
console.log(` ✗ ${repo.name.padEnd(22)} ${err.message}.${hint}`)
|
|
60
|
+
}
|
|
61
|
+
const index = await refreshIndex(project, setup.linked, (repo, repoIndex, err) => {
|
|
62
|
+
if (err && repos.includes(repo)) fail(repo, err)
|
|
63
|
+
})
|
|
64
|
+
repos.filter(repo => index.repos[repo._id]).forEach(repo => {
|
|
65
|
+
console.log(` ✓ ${repo.name.padEnd(22)} ${describe(describeRepo(index.repos[repo._id]))}`)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const seconds = ((Date.now() - started) / 1000).toFixed(1)
|
|
69
|
+
console.log(`\nDone in ${seconds}s${failed ? ` (${failed} failed)` : ''}. AI tokens used: 0.`)
|
|
70
|
+
console.log('The map stays on this computer (~/.tspl/index) and refreshes itself when tspl is used; you rarely need this command.')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
register(program) {
|
|
75
|
+
program
|
|
76
|
+
.command('analyze')
|
|
77
|
+
.description('Read the code of the project in this folder (no AI, no tokens)')
|
|
78
|
+
.option('--repo <name>', 'only this repo')
|
|
79
|
+
.action(analyze)
|
|
80
|
+
},
|
|
81
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Os = require('os')
|
|
4
|
+
const { input, password, select } = require('@inquirer/prompts')
|
|
5
|
+
const { CLIENT_TYPES } = require('../shared')
|
|
6
|
+
|
|
7
|
+
const { send, request, originOf } = require('../api-client')
|
|
8
|
+
const {
|
|
9
|
+
readConfig,
|
|
10
|
+
writeConfig,
|
|
11
|
+
readCredentials,
|
|
12
|
+
writeCredentials,
|
|
13
|
+
clearCredentials,
|
|
14
|
+
listAccounts,
|
|
15
|
+
accountFor,
|
|
16
|
+
saveAccount,
|
|
17
|
+
forgetAccount,
|
|
18
|
+
} = require('../config-store')
|
|
19
|
+
|
|
20
|
+
const readStdin = async () => {
|
|
21
|
+
const chunks = []
|
|
22
|
+
for await (const chunk of process.stdin) {
|
|
23
|
+
chunks.push(chunk)
|
|
24
|
+
}
|
|
25
|
+
return Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, '')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const login = async options => {
|
|
29
|
+
const config = readConfig()
|
|
30
|
+
const target = options.api ? options.api.replace(/\/+$/, '') : config.apiUrl
|
|
31
|
+
const moving = originOf(target) !== originOf(config.apiUrl)
|
|
32
|
+
|
|
33
|
+
const email = options.email || (await input({ message: 'Email:' }))
|
|
34
|
+
const secret =
|
|
35
|
+
options.password ||
|
|
36
|
+
(options.passwordStdin ? await readStdin() : await password({ message: 'Password:', mask: '*' }))
|
|
37
|
+
|
|
38
|
+
const attempt = org =>
|
|
39
|
+
send(target, 'POST', '/api/v1/auth/login', {
|
|
40
|
+
email,
|
|
41
|
+
password: secret,
|
|
42
|
+
org,
|
|
43
|
+
client: CLIENT_TYPES.CLI,
|
|
44
|
+
deviceName: Os.hostname(),
|
|
45
|
+
})
|
|
46
|
+
let result
|
|
47
|
+
try {
|
|
48
|
+
result = await attempt(options.org)
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const listed = /several organizations.*org:\s*(.+)$/i.exec(err.message)
|
|
51
|
+
if (!listed || options.org || !process.stdin.isTTY) throw err
|
|
52
|
+
const choices = listed[1].split(',').map(item => item.trim()).filter(Boolean)
|
|
53
|
+
result = await attempt(await select({ message: 'You belong to several organizations. Log in to which one?', choices: choices.map(value => ({ name: value, value })) }))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Saved logins belong to one API address. They are dropped only once the new address has accepted a login,
|
|
57
|
+
// so a wrong password never signs you out of the server you were using.
|
|
58
|
+
if (moving) {
|
|
59
|
+
clearCredentials()
|
|
60
|
+
listAccounts().forEach(forgetAccount)
|
|
61
|
+
}
|
|
62
|
+
config.apiUrl = target
|
|
63
|
+
writeConfig(config)
|
|
64
|
+
const credentials = {
|
|
65
|
+
apiUrl: config.apiUrl,
|
|
66
|
+
accessToken: result.accessToken,
|
|
67
|
+
refreshToken: result.refreshToken,
|
|
68
|
+
email: result.user.email,
|
|
69
|
+
organization: result.organization.slug,
|
|
70
|
+
}
|
|
71
|
+
writeCredentials(credentials)
|
|
72
|
+
saveAccount(credentials)
|
|
73
|
+
|
|
74
|
+
console.log(
|
|
75
|
+
`Logged in as ${result.user.email} (${result.role}) in ${result.organization.name}`,
|
|
76
|
+
)
|
|
77
|
+
if (result.user.passwordChangeRequired) {
|
|
78
|
+
console.log('You are using a temporary password. Set your own now: tspl password')
|
|
79
|
+
}
|
|
80
|
+
return result
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** No email is sent: the server sets a random temporary password and tells it to the person asking. */
|
|
84
|
+
const forgotPassword = async options => {
|
|
85
|
+
const config = readConfig()
|
|
86
|
+
const target = options.api ? options.api.replace(/\/+$/, '') : config.apiUrl
|
|
87
|
+
const email = options.email || (await input({ message: 'Email of the account:' }))
|
|
88
|
+
const result = await send(target, 'POST', '/api/v1/auth/forgot-password', { email })
|
|
89
|
+
console.log(`Temporary password for ${result.email}: ${result.temporaryPassword}`)
|
|
90
|
+
console.log('The old password no longer works, and the account was signed out everywhere.')
|
|
91
|
+
console.log(`Log in with it, then choose your own: tspl login --api ${target} --email ${result.email}`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const changePassword = async () => {
|
|
95
|
+
const currentPassword = await password({ message: 'Current password:', mask: '*' })
|
|
96
|
+
const newPassword = await password({ message: 'New password:', mask: '*' })
|
|
97
|
+
const repeat = await password({ message: 'New password again:', mask: '*' })
|
|
98
|
+
if (newPassword !== repeat) {
|
|
99
|
+
throw new Error('The two new passwords are different')
|
|
100
|
+
}
|
|
101
|
+
await request('PUT', '/api/v1/auth/password', { currentPassword, newPassword })
|
|
102
|
+
console.log('Password changed. Your other sessions were logged out.')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const logout = async (options = {}) => {
|
|
106
|
+
if (options.all) {
|
|
107
|
+
for (const account of listAccounts()) {
|
|
108
|
+
try {
|
|
109
|
+
await send(account.apiUrl || readConfig().apiUrl, 'POST', '/api/v1/auth/logout', undefined, account.accessToken)
|
|
110
|
+
} catch (err) {
|
|
111
|
+
// an expired or unreachable session ends by itself
|
|
112
|
+
}
|
|
113
|
+
forgetAccount(account)
|
|
114
|
+
}
|
|
115
|
+
clearCredentials()
|
|
116
|
+
console.log('Logged out of every account on this computer.')
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
const credentials = readCredentials()
|
|
120
|
+
if (credentials.accessToken) {
|
|
121
|
+
try {
|
|
122
|
+
await request('POST', '/api/v1/auth/logout')
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (err.statusCode !== 401) {
|
|
125
|
+
throw err
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const { email, organization } = readCredentials()
|
|
130
|
+
clearCredentials()
|
|
131
|
+
console.log(email ? `Logged out ${email} (${organization}).` : 'Logged out')
|
|
132
|
+
const others = listAccounts()
|
|
133
|
+
if (others.length) console.log(`Other saved logins: ${others.map(item => `${item.email} (${item.organization})`).join(', ')}. Switch with: tspl account`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const whoami = async () => {
|
|
137
|
+
const me = await request('GET', '/api/v1/auth/me')
|
|
138
|
+
const { apiUrl } = readConfig()
|
|
139
|
+
console.log(`${me.user.name} <${me.user.email}>`)
|
|
140
|
+
console.log(`Organization: ${me.organization.name} (${me.organization.slug})`)
|
|
141
|
+
console.log(`Role: ${me.role}`)
|
|
142
|
+
console.log(`API: ${apiUrl}`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const sameServer = (a, b) => originOf(a || '') === originOf(b || '')
|
|
146
|
+
|
|
147
|
+
const onThisServer = () => listAccounts().filter(account => sameServer(account.apiUrl, readConfig().apiUrl))
|
|
148
|
+
|
|
149
|
+
const label = account => `${account.email} · ${account.organization}`
|
|
150
|
+
|
|
151
|
+
const listOrganizations = () => {
|
|
152
|
+
const current = readCredentials()
|
|
153
|
+
const accounts = onThisServer()
|
|
154
|
+
if (!accounts.length) {
|
|
155
|
+
console.log('No saved logins. Log in: tspl login')
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
accounts.forEach(account => {
|
|
159
|
+
const using = account.organization === current.organization && account.email === current.email
|
|
160
|
+
console.log(`${using ? '*' : ' '} ${account.organization.padEnd(24)} ${account.email}`)
|
|
161
|
+
})
|
|
162
|
+
console.log('Switch with: tspl account')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const switchTo = account => {
|
|
166
|
+
writeCredentials(account)
|
|
167
|
+
console.log(`Now using ${account.email} in ${account.organization}. Project folders of other organizations still use their own login.`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const useOrganization = target => {
|
|
171
|
+
const accounts = onThisServer()
|
|
172
|
+
const account = accounts.find(item => `${item.organization}|${item.email}` === target || item.email === target) || accountFor(target)
|
|
173
|
+
if (!account || !sameServer(account.apiUrl, readConfig().apiUrl)) {
|
|
174
|
+
throw new Error(`No saved login for "${target}" on this API. Log in: tspl login`)
|
|
175
|
+
}
|
|
176
|
+
switchTo(account)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** One place to switch account, add one, or log out. */
|
|
180
|
+
const account = async () => {
|
|
181
|
+
const current = readCredentials()
|
|
182
|
+
const accounts = onThisServer()
|
|
183
|
+
const using = item => item.organization === current.organization && item.email === current.email
|
|
184
|
+
console.log(current.email ? `Using ${label(current)} on ${readConfig().apiUrl}` : `Not logged in (${readConfig().apiUrl})`)
|
|
185
|
+
const choice = await select({
|
|
186
|
+
message: 'What do you want to do?',
|
|
187
|
+
choices: [
|
|
188
|
+
...accounts.filter(item => !using(item)).map(item => ({ name: `Switch to ${label(item)}`, value: { switch: item } })),
|
|
189
|
+
{ name: 'Log in with another account', value: 'login' },
|
|
190
|
+
...(current.email ? [{ name: `Log out ${label(current)}`, value: 'logout' }] : []),
|
|
191
|
+
...(accounts.length > 1 ? [{ name: 'Log out of every account', value: 'logout-all' }] : []),
|
|
192
|
+
{ name: 'Cancel', value: 'cancel' },
|
|
193
|
+
],
|
|
194
|
+
})
|
|
195
|
+
if (choice === 'cancel') return
|
|
196
|
+
if (choice === 'login') return login({})
|
|
197
|
+
if (choice === 'logout') return logout({})
|
|
198
|
+
if (choice === 'logout-all') return logout({ all: true })
|
|
199
|
+
switchTo(choice.switch)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = {
|
|
203
|
+
login,
|
|
204
|
+
forgotPassword,
|
|
205
|
+
register(program) {
|
|
206
|
+
program
|
|
207
|
+
.command('login')
|
|
208
|
+
.description('Log in to the TSPL API')
|
|
209
|
+
.option('--api <url>', 'TSPL API address, saved for next time')
|
|
210
|
+
.option('--email <email>', 'your email')
|
|
211
|
+
.option('--org <slug>', 'organization, if you belong to several')
|
|
212
|
+
.option('--password-stdin', 'read the password from standard input')
|
|
213
|
+
.action(login)
|
|
214
|
+
|
|
215
|
+
program
|
|
216
|
+
.command('logout')
|
|
217
|
+
.description('Log out the account in use (--all: every saved account)')
|
|
218
|
+
.option('--all', 'log out of every account saved on this computer')
|
|
219
|
+
.action(logout)
|
|
220
|
+
|
|
221
|
+
const accountCommand = program
|
|
222
|
+
.command('account')
|
|
223
|
+
.alias('switch')
|
|
224
|
+
.description('Switch account, log in with another one, or log out')
|
|
225
|
+
.action(account)
|
|
226
|
+
accountCommand.command('list').description('Saved logins; * is the one in use').action(listOrganizations)
|
|
227
|
+
accountCommand.command('use <email-or-organization>').description('Switch without the menu').action(useOrganization)
|
|
228
|
+
|
|
229
|
+
program
|
|
230
|
+
.command('password')
|
|
231
|
+
.description('Change your password')
|
|
232
|
+
.action(changePassword)
|
|
233
|
+
|
|
234
|
+
program
|
|
235
|
+
.command('forgot-password')
|
|
236
|
+
.description('Forgot your password? Get a random temporary one (no email is sent); you choose your own at the next login')
|
|
237
|
+
.option('--api <url>', 'TSPL API address (default: the one in use)')
|
|
238
|
+
.option('--email <email>', 'the email of the account')
|
|
239
|
+
.action(forgotPassword)
|
|
240
|
+
|
|
241
|
+
const org = program.command('org').description('Organizations you are logged in to (one person can belong to several)').action(listOrganizations)
|
|
242
|
+
org.command('list').description('Saved logins; * is the one in use').action(listOrganizations)
|
|
243
|
+
org.command('use <organization>').description('Switch to another organization you already logged in to').action(useOrganization)
|
|
244
|
+
|
|
245
|
+
program
|
|
246
|
+
.command('whoami')
|
|
247
|
+
.description('Show who is logged in')
|
|
248
|
+
.action(whoami)
|
|
249
|
+
},
|
|
250
|
+
}
|