kaloko 0.4.9 → 0.4.11
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 +88 -4
- package/package.json +1 -1
- package/schemas/config.v1.json +35 -0
- package/schemas/scenario.v1.json +30 -1
- package/skills/kaloko/SKILL.md +22 -4
- package/skills/kaloko/reference/acceptance.md +16 -0
- package/skills/kaloko/reference/ci.md +70 -0
- package/skills/kaloko/reference/collaboration.md +9 -1
- package/skills/kaloko/reference/environments.md +5 -0
- package/skills/kaloko/reference/execution.md +56 -0
- package/skills/kaloko/reference/import.md +66 -0
- package/skills/kaloko/reference/mcp.md +2 -0
- package/skills/kaloko/reference/recording.md +95 -0
- package/src/cli.mjs +467 -100
- package/src/lib/affected-core.mjs +242 -0
- package/src/lib/affected.mjs +233 -0
- package/src/lib/anim.mjs +125 -0
- package/src/lib/apps/recording-native.mjs +212 -0
- package/src/lib/build.mjs +60 -4
- package/src/lib/ci.mjs +207 -0
- package/src/lib/config.mjs +2 -0
- package/src/lib/doctor.mjs +5 -1
- package/src/lib/evaluate.mjs +4 -2
- package/src/lib/git.mjs +6 -2
- package/src/lib/heal.mjs +207 -0
- package/src/lib/import.mjs +374 -0
- package/src/lib/init-detect.mjs +219 -0
- package/src/lib/onboarding.mjs +15 -8
- package/src/lib/pr.mjs +24 -2
- package/src/lib/recording.mjs +373 -0
- package/src/lib/scenario.mjs +2 -1
- package/src/lib/service.mjs +7 -1
- package/src/lib/trace.mjs +109 -0
- package/src/lib/unzip.mjs +31 -0
- package/src/lib/upload.mjs +114 -0
- package/src/lib/video-cli.mjs +209 -0
- package/src/lib/video.mjs +427 -0
- package/src/lib/webm.mjs +144 -0
- package/viewer/index.html +186 -7
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Smarter `kaloko init` (#26): what the project is (framework, dev server command and URL, locales from the i18n
|
|
2
|
+
// config, main routes) and a first scenario that walks on the first try. Reads files only; never runs the project.
|
|
3
|
+
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { routeOf } from './affected-core.mjs'
|
|
6
|
+
|
|
7
|
+
const read = (root, f) => readFile(join(root, f), 'utf8').catch(() => null)
|
|
8
|
+
const exists = (root, f) => stat(join(root, f)).then(() => true, () => false)
|
|
9
|
+
const firstOf = async (root, files) => { for (const f of files) { const t = await read(root, f); if (t != null) return { file: f, text: t } } return null }
|
|
10
|
+
|
|
11
|
+
const FRAMEWORKS = [
|
|
12
|
+
['next', 'Next.js', ['next'], 3000],
|
|
13
|
+
['nuxt', 'Nuxt', ['nuxt', 'nuxt3'], 3000],
|
|
14
|
+
['sveltekit', 'SvelteKit', ['@sveltejs/kit'], 5173],
|
|
15
|
+
['remix', 'Remix / React Router', ['@remix-run/dev', '@remix-run/react', '@react-router/dev'], 5173],
|
|
16
|
+
['astro', 'Astro', ['astro'], 4321],
|
|
17
|
+
['gatsby', 'Gatsby', ['gatsby'], 8000],
|
|
18
|
+
['angular', 'Angular', ['@angular/core'], 4200],
|
|
19
|
+
['cra', 'Create React App', ['react-scripts'], 3000],
|
|
20
|
+
['vite', 'Vite', ['vite'], 5173]
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
/** Framework, package manager and dev server (command, port, URL). */
|
|
24
|
+
async function detectStack (root) {
|
|
25
|
+
const pkgText = await read(root, 'package.json')
|
|
26
|
+
let pkg = null
|
|
27
|
+
try { pkg = pkgText ? JSON.parse(pkgText) : null } catch {}
|
|
28
|
+
const deps = { ...(pkg?.dependencies || {}), ...(pkg?.devDependencies || {}) }
|
|
29
|
+
const pm = (await exists(root, 'pnpm-lock.yaml')) ? 'pnpm' : (await exists(root, 'yarn.lock')) ? 'yarn' : (await exists(root, 'bun.lockb')) || (await exists(root, 'bun.lock')) ? 'bun' : 'npm'
|
|
30
|
+
const runCmd = (script) => (pm === 'npm' ? `npm run ${script}` : pm === 'bun' ? `bun run ${script}` : `${pm} ${script}`)
|
|
31
|
+
let framework = null
|
|
32
|
+
for (const [id, name, names, port] of FRAMEWORKS) if (names.some((n) => deps[n])) { framework = { id, name, port }; break }
|
|
33
|
+
const gem = await read(root, 'Gemfile')
|
|
34
|
+
if (!framework && gem && /^\s*gem\s+['"]rails['"]/m.test(gem)) framework = { id: 'rails', name: 'Rails', port: 3000 }
|
|
35
|
+
if (!framework && await exists(root, 'manage.py')) framework = { id: 'django', name: 'Django', port: 8000 }
|
|
36
|
+
if (!framework && await exists(root, 'artisan')) framework = { id: 'laravel', name: 'Laravel', port: 8000 }
|
|
37
|
+
let command = null; let port = framework?.port || 3000
|
|
38
|
+
if (pkg?.scripts) {
|
|
39
|
+
const script = ['dev', 'start', 'serve', 'develop'].find((s) => pkg.scripts[s])
|
|
40
|
+
if (script) {
|
|
41
|
+
command = runCmd(script)
|
|
42
|
+
const m = pkg.scripts[script].match(/(?:--port[ =]|-p\s+|PORT=)(\d{2,5})/)
|
|
43
|
+
if (m) port = Number(m[1])
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (framework?.id === 'rails') command = (await exists(root, 'bin/dev')) ? 'bin/dev' : 'bin/rails server'
|
|
47
|
+
if (framework?.id === 'django') command = 'python manage.py runserver'
|
|
48
|
+
if (framework?.id === 'laravel') command = 'php artisan serve'
|
|
49
|
+
// a port in the bundler config wins over the default
|
|
50
|
+
const vite = await firstOf(root, ['vite.config.ts', 'vite.config.js', 'vite.config.mjs'])
|
|
51
|
+
const vp = vite?.text.match(/server\s*:\s*{[^}]*port\s*:\s*(\d{2,5})/)
|
|
52
|
+
if (vp && !/(?:--port|-p\s|PORT=)/.test(pkg?.scripts?.dev || '')) port = Number(vp[1])
|
|
53
|
+
const nuxtCfg = framework?.id === 'nuxt' ? await firstOf(root, ['nuxt.config.ts', 'nuxt.config.js']) : null
|
|
54
|
+
const np = nuxtCfg?.text.match(/devServer\s*:\s*{[^}]*port\s*:\s*(\d{2,5})/)
|
|
55
|
+
if (np) port = Number(np[1])
|
|
56
|
+
return { framework, pm, command, port, url: `http://localhost:${port}`, name: pkg?.name || null }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const LOCALE = /^[a-z]{2}(?:-[A-Za-z]{2,4})?$/
|
|
60
|
+
const listOf = (src) => [...String(src || '').matchAll(/['"`]([a-z]{2}(?:-[A-Za-z]{2,4})?)['"`]/g)].map((m) => m[1])
|
|
61
|
+
const codesOf = (src) => { const codes = [...String(src || '').matchAll(/code\s*:\s*['"`]([a-z]{2}(?:-[A-Za-z]{2,4})?)['"`]/g)].map((m) => m[1]); return codes.length ? codes : listOf(src) }
|
|
62
|
+
const ordered = (list, def) => { const u = [...new Set(list)]; return def && u.includes(def) ? [def, ...u.filter((l) => l !== def)] : u }
|
|
63
|
+
|
|
64
|
+
/** Locales from the i18n config: { list, default, source, prefix: 'always'|'except-default'|'none' } or null. */
|
|
65
|
+
async function detectLocales (root, framework) {
|
|
66
|
+
// Next.js pages router i18n
|
|
67
|
+
const next = await firstOf(root, ['next.config.js', 'next.config.mjs', 'next.config.ts'])
|
|
68
|
+
const ni = next?.text.match(/i18n\s*:\s*{([\s\S]*?)}/)
|
|
69
|
+
if (ni) {
|
|
70
|
+
const list = listOf((ni[1].match(/locales\s*:\s*\[([^\]]*)\]/) || [])[1])
|
|
71
|
+
const def = (ni[1].match(/defaultLocale\s*:\s*['"`]([^'"`]+)/) || [])[1]
|
|
72
|
+
if (list.length) return { list: ordered(list, def), default: def || list[0], source: next.file, prefix: 'except-default' }
|
|
73
|
+
}
|
|
74
|
+
// next-intl, i18next routing files
|
|
75
|
+
const routing = await firstOf(root, ['src/i18n/routing.ts', 'src/i18n/routing.js', 'i18n/routing.ts', 'i18n/routing.js', 'src/i18n.ts', 'i18n.ts', 'src/i18n/config.ts', 'i18n/config.ts', 'src/middleware.ts', 'middleware.ts'])
|
|
76
|
+
const rl = routing?.text.match(/locales\s*:\s*\[([^\]]*)\]/) || routing?.text.match(/(?:export\s+)?const\s+locales\s*=\s*\[([^\]]*)\]/)
|
|
77
|
+
if (rl && listOf(rl[1]).length) {
|
|
78
|
+
const def = (routing.text.match(/defaultLocale\s*[:=]\s*['"`]([^'"`]+)/) || [])[1]
|
|
79
|
+
const prefix = /localePrefix\s*:\s*['"`](as-needed)['"`]/.test(routing.text) ? 'except-default' : /localePrefix\s*:\s*['"`]never['"`]/.test(routing.text) ? 'none' : 'always'
|
|
80
|
+
const list = listOf(rl[1])
|
|
81
|
+
return { list: ordered(list, def), default: def || list[0], source: routing.file, prefix }
|
|
82
|
+
}
|
|
83
|
+
// Nuxt i18n
|
|
84
|
+
const nuxt = framework?.id === 'nuxt' ? await firstOf(root, ['nuxt.config.ts', 'nuxt.config.js']) : null
|
|
85
|
+
const nx = nuxt?.text.match(/i18n\s*:\s*{([\s\S]*?)\n\s{0,4}}/)
|
|
86
|
+
if (nx) {
|
|
87
|
+
const block = nx[1]
|
|
88
|
+
const list = codesOf((block.match(/locales\s*:\s*\[([\s\S]*?)\]/) || [])[1])
|
|
89
|
+
const def = (block.match(/defaultLocale\s*:\s*['"`]([^'"`]+)/) || [])[1]
|
|
90
|
+
const strategy = (block.match(/strategy\s*:\s*['"`]([^'"`]+)/) || [])[1] || 'prefix_except_default'
|
|
91
|
+
if (list.length) return { list: ordered(list, def), default: def || list[0], source: nuxt.file, prefix: strategy === 'prefix' ? 'always' : strategy === 'no_prefix' ? 'none' : 'except-default' }
|
|
92
|
+
}
|
|
93
|
+
// Paraglide / inlang (SvelteKit and others)
|
|
94
|
+
const inlang = await read(root, 'project.inlang/settings.json')
|
|
95
|
+
if (inlang) {
|
|
96
|
+
try {
|
|
97
|
+
const s = JSON.parse(inlang); const list = s.locales || s.languageTags || []; const def = s.baseLocale || s.sourceLanguageTag
|
|
98
|
+
if (list.length) return { list: ordered(list, def), default: def || list[0], source: 'project.inlang/settings.json', prefix: 'except-default' }
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
// Rails
|
|
102
|
+
if (framework?.id === 'rails') {
|
|
103
|
+
const app = await read(root, 'config/application.rb') || ''
|
|
104
|
+
const avail = (app.match(/available_locales\s*=\s*\[([^\]]*)\]/) || [])[1]
|
|
105
|
+
const def = (app.match(/default_locale\s*=\s*:?['"]?([a-z]{2}(?:-[A-Z]{2})?)/) || [])[1]
|
|
106
|
+
let list = avail ? [...avail.matchAll(/:?['"]?([a-z]{2}(?:-[A-Z]{2})?)['"]?/g)].map((m) => m[1]) : []
|
|
107
|
+
if (!list.length) list = (await readdir(join(root, 'config/locales')).catch(() => [])).map((f) => f.replace(/\.(ya?ml|rb)$/, '')).filter((f) => LOCALE.test(f))
|
|
108
|
+
const routes = await read(root, 'config/routes.rb') || ''
|
|
109
|
+
if (list.length) return { list: ordered(list, def), default: def || list[0], source: avail ? 'config/application.rb' : 'config/locales', prefix: /scope\s+['"]\(?:locale\)?['"]|scope\s+['"]\/?:locale['"]/.test(routes) ? (/\(:locale\)/.test(routes) ? 'except-default' : 'always') : 'none' }
|
|
110
|
+
}
|
|
111
|
+
// message folders: messages/en.json, locales/cs.json, public/locales/en/…, src/locales/de.ts
|
|
112
|
+
for (const dir of ['messages', 'locales', 'lang', 'src/locales', 'src/messages', 'src/lang', 'public/locales', 'i18n', 'src/i18n']) {
|
|
113
|
+
const names = (await readdir(join(root, dir)).catch(() => [])).map((f) => f.replace(/\.(json|ya?ml|ts|js)$/, '')).filter((f) => LOCALE.test(f))
|
|
114
|
+
if (names.length) {
|
|
115
|
+
const appDir = await firstOf(root, ['app/[locale]/page.tsx', 'src/app/[locale]/page.tsx', 'app/[locale]/layout.tsx', 'src/app/[locale]/layout.tsx', 'app/[lang]/page.tsx', 'src/app/[lang]/page.tsx'])
|
|
116
|
+
return { list: ordered(names, names.includes('en') ? 'en' : null), default: names.includes('en') ? 'en' : names[0], source: dir, prefix: appDir ? 'always' : 'none' }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const SOURCE_DIRS = ['app', 'src/app', 'pages', 'src/pages', 'src/routes', 'app/routes', 'app/pages']
|
|
123
|
+
|
|
124
|
+
async function filesUnder (root, dir, out = [], depth = 0) {
|
|
125
|
+
if (depth > 8 || out.length > 5000) return out
|
|
126
|
+
let entries = []
|
|
127
|
+
try { entries = await readdir(join(root, dir), { withFileTypes: true }) } catch { return out }
|
|
128
|
+
for (const e of entries) {
|
|
129
|
+
const p = `${dir}/${e.name}`
|
|
130
|
+
if (e.isDirectory()) { if (!['node_modules', 'api', 'components', 'lib'].includes(e.name) && !e.name.startsWith('.')) await filesUnder(root, p, out, depth + 1) } else out.push(p)
|
|
131
|
+
}
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Static routes from route files (any locale segment removed) or Rails routes; '/' first, shallow before deep. */
|
|
136
|
+
async function detectRoutes (root, framework) {
|
|
137
|
+
const found = new Map()
|
|
138
|
+
if (framework?.id === 'rails') {
|
|
139
|
+
const text = await read(root, 'config/routes.rb') || ''
|
|
140
|
+
if (/^\s*root\b/m.test(text)) found.set('/', 'config/routes.rb')
|
|
141
|
+
for (const m of text.matchAll(/^\s*(?:get|resources|resource)\s+[:'"]\/?([a-z][\w/-]*)['"]?/gm)) found.set('/' + m[1].replace(/_/g, '-'), 'config/routes.rb')
|
|
142
|
+
} else {
|
|
143
|
+
for (const dir of SOURCE_DIRS) {
|
|
144
|
+
for (const f of await filesUnder(root, dir)) {
|
|
145
|
+
if (!/\.(m?[jt]sx?|vue|svelte|astro|mdx?)$/.test(f) || /\/(_|\[\.\.\.)/.test(f) && !/\/\[(locale|lang)\]\//.test(f)) continue
|
|
146
|
+
const r = routeOf(f)
|
|
147
|
+
if (!r || r.scope !== 'page' || /\/api(\/|$)/.test(r.route)) continue
|
|
148
|
+
const route = r.route.replace(/^\/:(locale|lang|lng)(?=\/|$)/, '') || '/'
|
|
149
|
+
if (/[:*]/.test(route)) continue
|
|
150
|
+
if (!found.has(route)) found.set(route, f)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const skip = /^\/(api|admin|_|auth|oauth|callback|404|500|error)(\/|$)/
|
|
155
|
+
return [...found].filter(([p]) => !skip.test(p)).map(([path, file]) => ({ path, file })).sort((a, b) => (a.path === '/' ? -1 : b.path === '/' ? 1 : a.path.split('/').length - b.path.split('/').length || a.path.localeCompare(b.path)))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Everything init can tell about the project. */
|
|
159
|
+
export async function detectProject (root) {
|
|
160
|
+
const stack = await detectStack(root)
|
|
161
|
+
const locales = await detectLocales(root, stack.framework)
|
|
162
|
+
const routes = await detectRoutes(root, stack.framework)
|
|
163
|
+
return { ...stack, locales, routes }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const words = (p) => p === '/' ? 'Home' : p.split('/').filter(Boolean).pop().replace(/[-_]+/g, ' ').replace(/^./, (c) => c.toUpperCase())
|
|
167
|
+
const slug = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
|
|
168
|
+
|
|
169
|
+
/** A path per locale for the locale strategy: prefix always, except the default locale, or none. */
|
|
170
|
+
export function localizedPath (path, locales) {
|
|
171
|
+
if (!locales || locales.prefix === 'none' || locales.list.length < 2) return path
|
|
172
|
+
return Object.fromEntries(locales.list.map((l) => [l, locales.prefix === 'except-default' && l === locales.default ? path : `/${l}${path === '/' ? '' : path}`]))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The first scenario: the main routes, one screen each, that a walk can open as they are. */
|
|
176
|
+
export function initScenario (d, { max = 6, project = null } = {}) {
|
|
177
|
+
const routes = (d.routes.length ? d.routes : [{ path: '/', file: null }]).slice(0, max)
|
|
178
|
+
const name = project || d.name || 'app'
|
|
179
|
+
const ids = new Set()
|
|
180
|
+
const nodes = routes.map((r, i) => {
|
|
181
|
+
let id = r.path === '/' ? 'home' : slug(r.path) || `page-${i + 1}`
|
|
182
|
+
while (ids.has(id)) id += `-${i + 1}`
|
|
183
|
+
ids.add(id)
|
|
184
|
+
const title = words(r.path)
|
|
185
|
+
return {
|
|
186
|
+
id, kind: 'screen', title, path: localizedPath(r.path, d.locales),
|
|
187
|
+
purpose: `A visitor sees the ${title.toLowerCase()} page and can tell what to do next.`,
|
|
188
|
+
execution: i === 0
|
|
189
|
+
? { strategy: 'playwright', script: 'steps/consent.mjs', instructions: `Open ${r.path} as a new visitor and accept the consent dialog if there is one.` }
|
|
190
|
+
: { strategy: 'playwright', script: 'steps/open.mjs', instructions: `Open ${r.path}.` },
|
|
191
|
+
criteria: [
|
|
192
|
+
{ id: 'AC1', text: 'No errors in the browser console', check: { type: 'deterministic', assert: 'no_console_errors' } },
|
|
193
|
+
{ id: 'AC2', text: 'The page makes its purpose clear', check: { type: 'semantic', question: `Does the first screen make clear what the ${title.toLowerCase()} page offers and what the visitor can do next?`, scope: 'main, body' } }
|
|
194
|
+
]
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
return {
|
|
198
|
+
schema: 'qawalk.scenario.v1',
|
|
199
|
+
id: `${slug(name).toUpperCase() || 'APP'}-MAIN`,
|
|
200
|
+
title: `Main pages of ${name}`,
|
|
201
|
+
kind: 'flow', version: 1,
|
|
202
|
+
locales: d.locales?.list?.slice(0, 6) || ['en'],
|
|
203
|
+
viewports: { desktop: [1280, 800], mobile: [390, 844] },
|
|
204
|
+
environments: ['local'],
|
|
205
|
+
lanes: ['Visitor'],
|
|
206
|
+
nodes,
|
|
207
|
+
edges: nodes.slice(1).map((n) => ({ from: nodes[0].id, to: n.id, label: 'visit', kind: 'alt' }))
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** One paragraph for the terminal. */
|
|
212
|
+
export function formatDetection (d, root) {
|
|
213
|
+
const lines = [`Detected ${d.framework ? d.framework.name : 'no known framework'}${d.command ? `; dev server: ${d.command}` : ''} → ${d.url}`]
|
|
214
|
+
if (d.locales) lines.push(` locales: ${d.locales.list.join(', ')} (from ${d.locales.source}; ${d.locales.prefix === 'none' ? 'no locale in the URL' : d.locales.prefix === 'always' ? 'every locale prefixes the URL' : `/${d.locales.list.find((l) => l !== d.locales.default) || 'xx'}/… except ${d.locales.default}`})`)
|
|
215
|
+
else lines.push(' locales: none found in an i18n config (the scenario uses en)')
|
|
216
|
+
lines.push(d.routes.length ? ` routes: ${d.routes.slice(0, 8).map((r) => r.path).join(', ')}${d.routes.length > 8 ? `, +${d.routes.length - 8} more` : ''}` : ' routes: none found in route files (the scenario starts at /)')
|
|
217
|
+
return lines.join('\n')
|
|
218
|
+
}
|
|
219
|
+
|
package/src/lib/onboarding.mjs
CHANGED
|
@@ -10,16 +10,23 @@ export const realOrg = (org) => (typeof org === 'string' && /^[a-z0-9][a-z0-9-]{
|
|
|
10
10
|
/** Address of the organization's sample run on the service. */
|
|
11
11
|
export const sampleUrl = (base, org) => `${String(base).replace(/\/$/, '')}/${org}/sample`
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Lines printed at the end of `kaloko init`. `devServer` ({ command, url }) when init found the app's dev server but it
|
|
15
|
+
* is not answering yet; `scenario` the path of the first scenario init drafted, which then is the first real flow.
|
|
16
|
+
*/
|
|
17
|
+
export function initNextSteps ({ base, org, loggedIn = false, devServer = null, scenario = null }) {
|
|
15
18
|
const o = realOrg(org)
|
|
16
|
-
|
|
17
|
-
'
|
|
18
|
-
loggedIn ? ' 1. Signed in already (KALOKO_TOKEN found).' : ' 1. npx kaloko login (approve a token in the browser; or put KALOKO_TOKEN from Settings → API tokens into .env)',
|
|
19
|
-
o ? ` 2. Try a review on the sample run: ${sampleUrl(base, o)}` : ' 2. Set org: in kaloko.config.yml, then open <service>/<org>/sample to try a review on the sample run.',
|
|
20
|
-
' 3. Your first real flow: npx kaloko draft <your app URL> (public pages), or ask your agent:',
|
|
21
|
-
` "${FIRST_FLOW_PROMPT}"`
|
|
19
|
+
const steps = [
|
|
20
|
+
loggedIn ? 'Signed in already (KALOKO_TOKEN found).' : 'npx kaloko login (approve a token in the browser; or put KALOKO_TOKEN from Settings → API tokens into .env)'
|
|
22
21
|
]
|
|
22
|
+
if (devServer) steps.push(`${devServer.command || 'start your app'} (the dev server is not answering on ${devServer.url} yet)`)
|
|
23
|
+
steps.push(o ? `Try a review on the sample run: ${sampleUrl(base, o)}` : 'Set org: in kaloko.config.yml, then open <service>/<org>/sample to try a review on the sample run.')
|
|
24
|
+
if (scenario) steps.push(`Your first real flow: npx kaloko start --scenario ${scenario} --env local && npx kaloko walk`)
|
|
25
|
+
else steps.push('Your first real flow: npx kaloko draft <your app URL> (public pages), or ask your agent:', ` "${FIRST_FLOW_PROMPT}"`)
|
|
26
|
+
const lines = ['Next:']
|
|
27
|
+
let n = 0
|
|
28
|
+
for (const s of steps) lines.push(s.startsWith(' "') ? ` ${s}` : ` ${++n}. ${s}`)
|
|
29
|
+
return lines
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
/** Lines printed after a successful `kaloko login`. */
|
package/src/lib/pr.mjs
CHANGED
|
@@ -52,8 +52,11 @@ export function prCommentBody ({ manifest, url, at = new Date() }) {
|
|
|
52
52
|
* Creates or updates the scenario's comment on the current branch's PR.
|
|
53
53
|
* @returns {{ ok: boolean, action?: 'created'|'updated', url?: string, reason?: string }}
|
|
54
54
|
*/
|
|
55
|
-
export async function upsertPrComment ({ manifest, url, cwd, run = defaultRun }) {
|
|
56
|
-
|
|
55
|
+
export async function upsertPrComment ({ manifest, url, cwd, run = defaultRun, vars = process.env, fetchImpl = fetch }) {
|
|
56
|
+
// GitLab merge request pipelines: a note on the merge request through the API
|
|
57
|
+
if (vars.GITLAB_CI && vars.CI_MERGE_REQUEST_IID) return upsertMrNote({ manifest, url, vars, fetchImpl })
|
|
58
|
+
// in CI the checkout is often a detached commit: KALOKO_PR (kaloko ci preview-url sets it) names the pull request
|
|
59
|
+
const view = await run('gh', ['pr', 'view', ...(vars.KALOKO_PR ? [String(vars.KALOKO_PR)] : []), '--json', 'number,url,headRepository,headRepositoryOwner'], cwd)
|
|
57
60
|
if (view == null) return { ok: false, reason: 'gh is not installed, not signed in, or this branch has no pull request' }
|
|
58
61
|
let pr
|
|
59
62
|
try { pr = JSON.parse(view) } catch { return { ok: false, reason: 'gh pr view returned no JSON' } }
|
|
@@ -73,6 +76,25 @@ export async function upsertPrComment ({ manifest, url, cwd, run = defaultRun })
|
|
|
73
76
|
return r == null ? { ok: false, reason: 'could not create the comment' } : { ok: true, action: 'created', url: r.trim() || pr.url }
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
/** One note per scenario on a GitLab merge request (KALOKO_GITLAB_TOKEN: a project access token with the api scope). */
|
|
80
|
+
export async function upsertMrNote ({ manifest, url, vars = process.env, fetchImpl = fetch }) {
|
|
81
|
+
const token = vars.KALOKO_GITLAB_TOKEN || vars.GITLAB_TOKEN
|
|
82
|
+
if (!token) return { ok: false, reason: 'set KALOKO_GITLAB_TOKEN (a project access token with the api scope) to comment on the merge request' }
|
|
83
|
+
const notes = `${vars.CI_API_V4_URL}/projects/${encodeURIComponent(vars.CI_PROJECT_ID)}/merge_requests/${vars.CI_MERGE_REQUEST_IID}/notes`
|
|
84
|
+
const headers = { 'PRIVATE-TOKEN': token, 'Content-Type': 'application/json' }
|
|
85
|
+
const body = prCommentBody({ manifest, url })
|
|
86
|
+
const link = (id) => `${vars.CI_MERGE_REQUEST_PROJECT_URL || vars.CI_PROJECT_URL}/-/merge_requests/${vars.CI_MERGE_REQUEST_IID}#note_${id}`
|
|
87
|
+
try {
|
|
88
|
+
const list = await fetchImpl(`${notes}?per_page=100&sort=desc`, { headers })
|
|
89
|
+
if (!list.ok) return { ok: false, reason: `GitLab answered ${list.status} listing notes` }
|
|
90
|
+
const mine = (await list.json()).find((n) => String(n.body || '').includes(marker(manifest.scenario.id)))
|
|
91
|
+
const res = await fetchImpl(mine ? `${notes}/${mine.id}` : notes, { method: mine ? 'PUT' : 'POST', headers, body: JSON.stringify({ body }) })
|
|
92
|
+
if (!res.ok) return { ok: false, reason: `GitLab answered ${res.status}` }
|
|
93
|
+
const note = await res.json()
|
|
94
|
+
return { ok: true, action: mine ? 'updated' : 'created', url: link(note.id || mine?.id) }
|
|
95
|
+
} catch (e) { return { ok: false, reason: e.message } }
|
|
96
|
+
}
|
|
97
|
+
|
|
76
98
|
async function defaultRun (cmd, args, cwd) {
|
|
77
99
|
try { const { stdout } = await exec(cmd, args, { cwd, timeout: 30000, maxBuffer: 4 * 1024 * 1024 }); return stdout } catch { return null }
|
|
78
100
|
}
|