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,148 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Fs = require('fs')
|
|
4
|
+
const { confirm, input, search, select } = require('@inquirer/prompts')
|
|
5
|
+
|
|
6
|
+
const { request } = require('../api-client')
|
|
7
|
+
const { readActive, writeActive, readLinks, readCredentials, readRun, writeRun, readTask, writeTask } = require('../config-store')
|
|
8
|
+
const { findProject } = require('../workspace')
|
|
9
|
+
const { findProjectConfig, resolveRepoPaths, readProjectInstructions } = require('../project-config')
|
|
10
|
+
const { prepareBuild, blockers, runBuild, refreshChecklist } = require('../task/build')
|
|
11
|
+
const { askBranchChoice } = require('../task/branch-choice')
|
|
12
|
+
const { removeHooks } = require('../task/hooks')
|
|
13
|
+
const { engineFor } = require('../task/refine')
|
|
14
|
+
const { reviewPlan, withSnapshot } = require('../task/plan')
|
|
15
|
+
const { createProvider } = require('../providers')
|
|
16
|
+
const { runChecks, captureHeads } = require('./evidence')
|
|
17
|
+
const { implementTask, planTask, proposeTaskApproaches } = require('./agents')
|
|
18
|
+
const { planningContext, planningBrief } = require('../task/planning-context')
|
|
19
|
+
const { chooseForPlanning, renderApproaches } = require('../task/approaches')
|
|
20
|
+
const { saveDraft, takeDraft } = require('../task/plan-draft')
|
|
21
|
+
const { reviewTask } = require('./review')
|
|
22
|
+
const { claimCheckouts } = require('./checkout-lock')
|
|
23
|
+
const { workflowClient } = require('./client')
|
|
24
|
+
const { gitOps } = require('./controller')
|
|
25
|
+
|
|
26
|
+
const taskUrl = (projectId, key) => `/api/v1/projects/${projectId}/tasks/${encodeURIComponent(key)}`
|
|
27
|
+
|
|
28
|
+
const load = async ({ cwd, taskId }) => {
|
|
29
|
+
const found = findProjectConfig(cwd)
|
|
30
|
+
if (!found) throw new Error('This folder has no .tspl/project.json. Run: tspl init')
|
|
31
|
+
const { project, repos } = await findProject(cwd)
|
|
32
|
+
if (project._id !== found.config.project.id) {
|
|
33
|
+
throw new Error(`The project file is for project ${found.config.project.slug} (${found.config.project.id}), but this folder is linked to ${project.slug}. Check .tspl/project.json.`)
|
|
34
|
+
}
|
|
35
|
+
const links = readLinks()[`${readCredentials().organization}/${project.slug}`]
|
|
36
|
+
const paths = resolveRepoPaths(found.root, found.config, links)
|
|
37
|
+
const missing = found.config.repos.filter(repo => !paths.has(repo.name)).map(repo => repo.name)
|
|
38
|
+
if (missing.length) throw new Error(`These repos are not cloned on this computer: ${missing.join(', ')}. Clone them, then run: tspl init`)
|
|
39
|
+
void repos
|
|
40
|
+
const task = await request('GET', taskUrl(project._id, taskId))
|
|
41
|
+
return {
|
|
42
|
+
projectId: project._id,
|
|
43
|
+
project,
|
|
44
|
+
root: found.root,
|
|
45
|
+
config: found.config,
|
|
46
|
+
paths,
|
|
47
|
+
instructions: readProjectInstructions(found.root, found.config).text,
|
|
48
|
+
task,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const finishLocal = taskKey => {
|
|
53
|
+
const active = readActive()
|
|
54
|
+
;((active[taskKey] || {}).repos || []).forEach(repo => removeHooks(repo.path))
|
|
55
|
+
delete active[taskKey]
|
|
56
|
+
writeActive(active)
|
|
57
|
+
const local = readTask(taskKey)
|
|
58
|
+
if (local.id) writeTask({ ...local, status: 'done' })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Plans a task with the planning engine, checks the plan against the code and submits it for approval. */
|
|
62
|
+
const plan = async (cwd, ctx, { interactive = Boolean(process.stdin.isTTY) } = {}) => {
|
|
63
|
+
const { createContext } = require('../mcp/server')
|
|
64
|
+
const loaded = await createContext(cwd).load()
|
|
65
|
+
const engine = engineFor('plan')
|
|
66
|
+
const choice = await chooseForPlanning(ctx.projectId, ctx.task.key, {
|
|
67
|
+
signal: ctx.signal,
|
|
68
|
+
generate: async task => {
|
|
69
|
+
const brief = planningContext(loaded, task, { paths: ctx.paths })
|
|
70
|
+
const { draftPlan, ...proposal } = await proposeTaskApproaches({ engine: engine.name, model: engine.model, task, brief, paths: ctx.paths, instructions: ctx.instructions, timeoutMs: ctx.config.workflow.engineTimeoutMs, signal: ctx.signal, withPlan: true })
|
|
71
|
+
if (draftPlan) saveDraft(ctx.projectId, task, proposal, draftPlan, brief)
|
|
72
|
+
return proposal
|
|
73
|
+
},
|
|
74
|
+
choose: interactive ? async approaches => {
|
|
75
|
+
console.log(renderApproaches(approaches))
|
|
76
|
+
return select({ message: 'Which implementation approach should the plan use?', choices: approaches.options.map(option => ({ value: option.id, name: `${option.title}${option.id === approaches.recommendedId ? ' (Recommended)' : ''}` })) }, { signal: ctx.signal })
|
|
77
|
+
} : null,
|
|
78
|
+
})
|
|
79
|
+
if (choice.waitingForSelection) return choice
|
|
80
|
+
ctx.task = choice.task
|
|
81
|
+
const { text: brief, files: briefFiles } = planningBrief(loaded, ctx.task, { paths: ctx.paths })
|
|
82
|
+
const proposed = await planTask({ engine: engine.name, model: engine.model, task: ctx.task, brief, draft: takeDraft(ctx.projectId, ctx.task, brief), paths: ctx.paths, instructions: ctx.instructions, timeoutMs: ctx.config.workflow.engineTimeoutMs, signal: ctx.signal })
|
|
83
|
+
const review = reviewPlan(proposed, ctx.task.card, loaded.graph, { briefFiles })
|
|
84
|
+
const version = ((ctx.task.plan && ctx.task.plan.version) || 0) + 1
|
|
85
|
+
ctx.signal?.throwIfAborted()
|
|
86
|
+
await request('PUT', taskUrl(ctx.projectId, ctx.task.key), {
|
|
87
|
+
card: ctx.task.card,
|
|
88
|
+
assumptions: ctx.task.assumptions || [],
|
|
89
|
+
found: ctx.task.found || [],
|
|
90
|
+
used: ctx.task.used || [],
|
|
91
|
+
plan: { ...withSnapshot(review, ctx.task.card, cwd), approach: proposed.approach, version, basis: null, submittedAt: new Date().toISOString() },
|
|
92
|
+
})
|
|
93
|
+
return { note: review.warnings.length ? `${review.warnings.length} plan warning(s): see tspl plan show ${ctx.task.key}` : null }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Sends one screenshot or video to the task, tied to its check, commit and test case. */
|
|
97
|
+
const uploadArtifact = (projectId, key, file) =>
|
|
98
|
+
request('POST', `${taskUrl(projectId, key)}/artifacts`, {
|
|
99
|
+
name: file.name || require('path').basename(file.path),
|
|
100
|
+
contentType: file.contentType,
|
|
101
|
+
data: Fs.readFileSync(file.path).toString('base64'),
|
|
102
|
+
criterion: file.criterion || null,
|
|
103
|
+
checkId: file.checkId || null,
|
|
104
|
+
step: file.step ? String(file.step).slice(0, 500) : null,
|
|
105
|
+
headSha: file.headSha || null,
|
|
106
|
+
source: 'cli',
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const controllerDeps = (cwd, { interactive = Boolean(process.stdin.isTTY) } = {}) => ({
|
|
110
|
+
uploadArtifact,
|
|
111
|
+
load,
|
|
112
|
+
plan: ctx => plan(cwd, ctx, { interactive }),
|
|
113
|
+
client: (projectId, key) => workflowClient(projectId, key),
|
|
114
|
+
agents: { implementTask, reviewTask },
|
|
115
|
+
runChecks,
|
|
116
|
+
captureHeads,
|
|
117
|
+
engineFor,
|
|
118
|
+
ciWaitMs: 10 * 60 * 1000,
|
|
119
|
+
prepareBuild,
|
|
120
|
+
buildBlockers: blockers,
|
|
121
|
+
startBuild: runBuild,
|
|
122
|
+
refreshChecklist,
|
|
123
|
+
refreshTask: (projectId, key) => request('GET', taskUrl(projectId, key)),
|
|
124
|
+
git: gitOps,
|
|
125
|
+
providerFor: repoConfig => createProvider(repoConfig),
|
|
126
|
+
confirm: interactive ? message => confirm({ message: `${message}\nContinue?`, default: false }) : null,
|
|
127
|
+
chooseBranch: interactive ? proposal => askBranchChoice(proposal, { input, confirm, search }) : null,
|
|
128
|
+
claimCheckouts,
|
|
129
|
+
runs: { read: readRun, write: writeRun },
|
|
130
|
+
finishLocal,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const renderOutcome = result => {
|
|
134
|
+
const lines = [`Stage: ${result.stage}`]
|
|
135
|
+
result.blockers.forEach(line => lines.push(` ! ${line}`))
|
|
136
|
+
;(result.warnings || []).forEach(line => lines.push(` ! ${line}`))
|
|
137
|
+
;(result.prs || []).forEach(pr => lines.push(` PR ${pr.repo}: ${pr.url} (${pr.draft ? 'draft' : pr.state})`))
|
|
138
|
+
if (result.next.length) lines.push('Next:', ...result.next.map(line => ` ${line}`))
|
|
139
|
+
return lines.join('\n')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
controllerDeps,
|
|
144
|
+
renderOutcome,
|
|
145
|
+
finishLocal,
|
|
146
|
+
taskUrl,
|
|
147
|
+
uploadArtifact,
|
|
148
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stages where the run stops for a person somewhere else (the console or another terminal): approving a plan,
|
|
5
|
+
* confirming scope, recording a manual check, or CI. Anything else needs the developer at this keyboard.
|
|
6
|
+
*/
|
|
7
|
+
const waitsForOthers = result => {
|
|
8
|
+
if (['awaiting-approval', 'awaiting-ci'].includes(result.stage)) return true
|
|
9
|
+
if (result.stage === 'reviewing') return true
|
|
10
|
+
if (result.stage === 'planning') return result.blockers.some(line => /has not confirmed the scope/.test(line))
|
|
11
|
+
return false
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** What a person can change while we wait; when any of it moves, the run is worth trying again. */
|
|
15
|
+
const fingerprint = task => {
|
|
16
|
+
const workflow = task.workflow || {}
|
|
17
|
+
return JSON.stringify([
|
|
18
|
+
task.status,
|
|
19
|
+
task.scope ? task.scope.confirmed : null,
|
|
20
|
+
task.plan ? task.plan.version : null,
|
|
21
|
+
(task.decisions || []).length,
|
|
22
|
+
workflow.revision || 0,
|
|
23
|
+
(workflow.manualVerifications || []).length,
|
|
24
|
+
(workflow.prs || []).map(pr => [pr.headSha, (pr.checks || []).map(check => `${check.status}:${check.conclusion}`)]),
|
|
25
|
+
])
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const sleep = (ms, signal) =>
|
|
29
|
+
new Promise((resolve, reject) => {
|
|
30
|
+
if (signal && signal.aborted) return reject(signal.reason || new Error('aborted'))
|
|
31
|
+
const timer = setTimeout(resolve, ms)
|
|
32
|
+
if (signal) signal.addEventListener('abort', () => (clearTimeout(timer), reject(signal.reason || new Error('aborted'))), { once: true })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Runs the task, and whenever it stops for someone else, checks the server every `intervalMs` and runs it again
|
|
37
|
+
* as soon as something changed. CI is rechecked on every tick because its state lives with the git host.
|
|
38
|
+
* Returns the last outcome: ready, or the first stop that needs the developer.
|
|
39
|
+
*/
|
|
40
|
+
const runUntilDone = async ({ run, fetchTask, report, log, signal, intervalMs = 15000, waitMs = sleep }) => {
|
|
41
|
+
for (;;) {
|
|
42
|
+
const result = await run()
|
|
43
|
+
report(result)
|
|
44
|
+
if (result.stage === 'ready' || !waitsForOthers(result)) return result
|
|
45
|
+
let seen = fingerprint(await fetchTask())
|
|
46
|
+
log(`Waiting for ${result.stage === 'awaiting-ci' ? 'CI' : 'a person'}; checking every ${Math.round(intervalMs / 1000)}s and continuing by itself. Ctrl+C stops waiting.`)
|
|
47
|
+
for (;;) {
|
|
48
|
+
await waitMs(intervalMs, signal)
|
|
49
|
+
if (result.stage === 'awaiting-ci') break
|
|
50
|
+
const now = fingerprint(await fetchTask())
|
|
51
|
+
if (now !== seen) break
|
|
52
|
+
seen = now
|
|
53
|
+
}
|
|
54
|
+
log('Something changed; continuing.')
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { runUntilDone, waitsForOthers, fingerprint }
|
package/src/workspace.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Fs = require('fs')
|
|
4
|
+
const Path = require('path')
|
|
5
|
+
const { checkbox, confirm, input, select } = require('@inquirer/prompts')
|
|
6
|
+
|
|
7
|
+
const { request } = require('./api-client')
|
|
8
|
+
const { readCredentials, readLinks, writeLinks } = require('./config-store')
|
|
9
|
+
const { findRepos, defaultBranch, identify } = require('./git-helper')
|
|
10
|
+
const { detectStacks } = require('./stack-detector')
|
|
11
|
+
|
|
12
|
+
const scan = folder =>
|
|
13
|
+
findRepos(folder)
|
|
14
|
+
.map(dir => ({
|
|
15
|
+
dir,
|
|
16
|
+
name: Path.basename(dir),
|
|
17
|
+
branch: defaultBranch(dir),
|
|
18
|
+
stacks: detectStacks(dir),
|
|
19
|
+
...identify(dir),
|
|
20
|
+
}))
|
|
21
|
+
.filter(repo => repo.fingerprint || repo.remoteHash)
|
|
22
|
+
|
|
23
|
+
const sameRepo = (projectRepo, localRepo) =>
|
|
24
|
+
Boolean(
|
|
25
|
+
(projectRepo.fingerprint && projectRepo.fingerprint === localRepo.fingerprint) ||
|
|
26
|
+
(projectRepo.remoteHash && projectRepo.remoteHash === localRepo.remoteHash),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const toPayload = repo => ({
|
|
30
|
+
name: repo.name,
|
|
31
|
+
fingerprint: repo.fingerprint,
|
|
32
|
+
remoteHash: repo.remoteHash,
|
|
33
|
+
defaultBaseBranch: repo.branch || undefined,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const linkKey = project => `${readCredentials().organization}/${project.slug}`
|
|
37
|
+
|
|
38
|
+
const pickRepos = (message, repos, options) => {
|
|
39
|
+
if (options.yes) {
|
|
40
|
+
return repos
|
|
41
|
+
}
|
|
42
|
+
return checkbox({
|
|
43
|
+
message,
|
|
44
|
+
choices: repos.map(repo => ({ name: repo.name, value: repo, checked: true })),
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const createProject = async (folder, local, options) => {
|
|
49
|
+
const suggested = options.name || Path.basename(folder)
|
|
50
|
+
if (!options.yes) {
|
|
51
|
+
const create = await confirm({
|
|
52
|
+
message: `These ${local.length} repos are not in any project yet. Create a project for them?`,
|
|
53
|
+
default: true,
|
|
54
|
+
})
|
|
55
|
+
if (!create) {
|
|
56
|
+
return {}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const name = options.yes
|
|
61
|
+
? suggested
|
|
62
|
+
: await input({ message: 'Project name:', default: suggested })
|
|
63
|
+
const repos = await pickRepos('Include which repos?', local, options)
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const project = await request('POST', '/api/v1/projects', {
|
|
67
|
+
name,
|
|
68
|
+
repos: repos.map(toPayload),
|
|
69
|
+
})
|
|
70
|
+
return { project, excluded: local.filter(repo => !repos.includes(repo)) }
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (err.statusCode === 403) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
'No project uses these repos yet. Ask an owner, admin or PM to create one.',
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
throw err
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const NEW_PROJECT = '__new__'
|
|
82
|
+
|
|
83
|
+
/** Offers the projects that already exist before a new one is created. null means: create a new project. */
|
|
84
|
+
const pickExisting = async options => {
|
|
85
|
+
if (options.project) {
|
|
86
|
+
const found = await request('GET', `/api/v1/projects/${encodeURIComponent(options.project)}`)
|
|
87
|
+
return found
|
|
88
|
+
}
|
|
89
|
+
if (options.yes) return null
|
|
90
|
+
const projects = await request('GET', '/api/v1/projects')
|
|
91
|
+
if (!projects.length) return null
|
|
92
|
+
const picked = await select({
|
|
93
|
+
message: 'These repos are not in any project yet. Which project are they part of?',
|
|
94
|
+
choices: [
|
|
95
|
+
...projects.map(project => ({ name: `${project.name} (${project.repos.length} repos)`, value: project })),
|
|
96
|
+
{ name: 'None of these: create a new project', value: NEW_PROJECT },
|
|
97
|
+
],
|
|
98
|
+
})
|
|
99
|
+
return picked === NEW_PROJECT ? null : picked
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const chooseProject = async (matches, options) => {
|
|
103
|
+
if (matches.length === 1) {
|
|
104
|
+
return matches[0]
|
|
105
|
+
}
|
|
106
|
+
if (options.yes) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`These repos belong to several projects (${matches.map(p => p.slug).join(', ')}). Run without --yes to choose.`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
return select({
|
|
112
|
+
message: 'These repos belong to several projects. Which one are you working on?',
|
|
113
|
+
choices: matches.map(project => ({ name: project.name, value: project })),
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const addMissingRepos = async (project, local, ignored, options) => {
|
|
118
|
+
const missing = local.filter(
|
|
119
|
+
repo =>
|
|
120
|
+
!project.repos.some(r => sameRepo(r, repo)) &&
|
|
121
|
+
!ignored.includes(repo.fingerprint || repo.remoteHash),
|
|
122
|
+
)
|
|
123
|
+
if (!missing.length) {
|
|
124
|
+
return { project, declined: [] }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const chosen = await pickRepos(
|
|
128
|
+
`These repos are in this folder but not in "${project.name}". Add which?`,
|
|
129
|
+
missing,
|
|
130
|
+
options,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
let current = project
|
|
134
|
+
for (const repo of chosen) {
|
|
135
|
+
try {
|
|
136
|
+
current = await request(
|
|
137
|
+
'POST',
|
|
138
|
+
`/api/v1/projects/${current._id}/repos`,
|
|
139
|
+
toPayload(repo),
|
|
140
|
+
)
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.log(`Could not add ${repo.name}: ${err.message}`)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const declined = missing
|
|
147
|
+
.filter(repo => !chosen.includes(repo))
|
|
148
|
+
.map(repo => repo.fingerprint || repo.remoteHash)
|
|
149
|
+
return { project: current, declined }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const setupProject = async (folderArg, options = {}) => {
|
|
153
|
+
const folder = Path.resolve(folderArg || process.cwd())
|
|
154
|
+
const local = scan(folder)
|
|
155
|
+
if (!local.length) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`No git repos found in ${folder}. Run tspl inside a project folder or one of its repos.`,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const matches = await request('POST', '/api/v1/projects/match', {
|
|
162
|
+
fingerprints: local.map(r => r.fingerprint).filter(Boolean),
|
|
163
|
+
remoteHashes: local.map(r => r.remoteHash).filter(Boolean),
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
let project
|
|
167
|
+
let result
|
|
168
|
+
if (matches.length) {
|
|
169
|
+
project = await chooseProject(matches, options)
|
|
170
|
+
} else {
|
|
171
|
+
const existing = await pickExisting(options)
|
|
172
|
+
if (existing) {
|
|
173
|
+
project = existing
|
|
174
|
+
} else {
|
|
175
|
+
const created = await createProject(folder, local, options)
|
|
176
|
+
if (!created.project) {
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
project = created.project
|
|
180
|
+
result = {
|
|
181
|
+
project,
|
|
182
|
+
declined: created.excluded.map(repo => repo.fingerprint || repo.remoteHash),
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const links = readLinks()
|
|
188
|
+
const key = linkKey(project)
|
|
189
|
+
const previous = result ? {} : links[key] || {}
|
|
190
|
+
if (!result) {
|
|
191
|
+
result = await addMissingRepos(project, local, previous.ignored || [], options)
|
|
192
|
+
}
|
|
193
|
+
project = result.project
|
|
194
|
+
|
|
195
|
+
const repos = {}
|
|
196
|
+
const linked = []
|
|
197
|
+
project.repos.forEach(repo => {
|
|
198
|
+
const found = local.find(l => sameRepo(repo, l))
|
|
199
|
+
if (found) {
|
|
200
|
+
repos[repo._id] = { name: repo.name, path: found.dir }
|
|
201
|
+
linked.push({ ...repo, stacks: found.stacks, path: found.dir })
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
links[key] = {
|
|
206
|
+
projectId: project._id,
|
|
207
|
+
folder,
|
|
208
|
+
repos: { ...(previous.repos || {}), ...repos },
|
|
209
|
+
ignored: [...new Set([...(previous.ignored || []), ...result.declined])],
|
|
210
|
+
}
|
|
211
|
+
writeLinks(links)
|
|
212
|
+
|
|
213
|
+
return { project, folder, linked }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const localRepos = project => {
|
|
217
|
+
const known = (readLinks()[linkKey(project)] || {}).repos || {}
|
|
218
|
+
return project.repos
|
|
219
|
+
.filter(repo => known[repo._id] && Fs.existsSync(known[repo._id].path))
|
|
220
|
+
.map(repo => ({ ...repo, path: known[repo._id].path }))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const inside = (dir, parent) => Boolean(parent) && (dir === parent || dir.startsWith(`${parent}${Path.sep}`))
|
|
224
|
+
|
|
225
|
+
const findProject = async folder => {
|
|
226
|
+
const dir = Path.resolve(folder)
|
|
227
|
+
const [key, link] =
|
|
228
|
+
Object.entries(readLinks()).find(
|
|
229
|
+
([, entry]) =>
|
|
230
|
+
inside(dir, entry.folder) || Object.values(entry.repos || {}).some(repo => inside(dir, repo.path)),
|
|
231
|
+
) || []
|
|
232
|
+
if (!key) {
|
|
233
|
+
throw new Error(`${dir} is not set up for tspl yet. Run tspl in a terminal in this folder first.`)
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
const project = await request('GET', `/api/v1/projects/${encodeURIComponent(key.split('/').pop())}`)
|
|
237
|
+
return { project, repos: localRepos(project), offline: false }
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (err.statusCode === 0 && link.projectId) {
|
|
240
|
+
return { project: { _id: link.projectId, name: key.split('/').pop() }, repos: [], offline: true }
|
|
241
|
+
}
|
|
242
|
+
throw err
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = {
|
|
247
|
+
setupProject,
|
|
248
|
+
localRepos,
|
|
249
|
+
findProject,
|
|
250
|
+
}
|