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,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
+ }