@meistrari/tela-skills 1.3.5 → 1.4.0
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 +3 -0
- package/cli.ts +315 -61
- package/list-workspaces.ts +43 -0
- package/localhost.ts +44 -0
- package/package.json +6 -2
- package/skill/TELA_LOCALHOST_SKILL.md +106 -0
- package/skill/common.ts +52 -2
- package/skill/localhost-utils.test.ts +107 -0
- package/skill/localhost-utils.ts +175 -0
package/README.md
CHANGED
|
@@ -60,6 +60,9 @@ bun run cli.ts
|
|
|
60
60
|
|
|
61
61
|
# Verify skill loads correctly
|
|
62
62
|
bun --preload skill/preload.ts -e "console.log(Object.keys(tela))"
|
|
63
|
+
|
|
64
|
+
# Localhost (API :4000, App :3000)
|
|
65
|
+
bun localhost.ts "console.log(await tela.listProjects())"
|
|
63
66
|
```
|
|
64
67
|
|
|
65
68
|
### File layout after installation
|
package/cli.ts
CHANGED
|
@@ -5,60 +5,49 @@ import { homedir } from 'node:os'
|
|
|
5
5
|
import { join, dirname } from 'node:path'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
7
|
import open from 'open'
|
|
8
|
+
import {
|
|
9
|
+
discoverLocalhostServices,
|
|
10
|
+
parseApiGatewayKeys,
|
|
11
|
+
readAuthApiSecret,
|
|
12
|
+
signLocalhostJwt,
|
|
13
|
+
fetchWorkspaces,
|
|
14
|
+
saveLocalhostSession,
|
|
15
|
+
writeLocalhostEnv,
|
|
16
|
+
readSavedReposPath,
|
|
17
|
+
saveReposPath,
|
|
18
|
+
} from './skill/localhost-utils.ts'
|
|
8
19
|
|
|
9
20
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
10
21
|
const SKILLS_DIR = join(homedir(), '.claude', 'skills', 'tela')
|
|
11
22
|
const OPEN_ISSUE_SKILLS_DIR = join(homedir(), '.claude', 'skills', 'tela-open-issue')
|
|
23
|
+
const LOCALHOST_SKILLS_DIR = join(homedir(), '.claude', 'skills', 'tela-localhost')
|
|
12
24
|
const SESSION_DIR = join(homedir(), '.tela')
|
|
13
25
|
const BACKOFFICE_URL = 'https://backoffice.tela.com'
|
|
14
26
|
const STAGING_BACKOFFICE_URL = 'https://staging-backoffice.tela.com'
|
|
15
27
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const env = environment as 'production' | 'staging'
|
|
33
|
-
const sessionPath = join(SESSION_DIR, `session.${env}`)
|
|
34
|
-
const sessionLabel = `~/.tela/session.${env}`
|
|
35
|
-
const backofficeUrl = env === 'staging' ? STAGING_BACKOFFICE_URL : BACKOFFICE_URL
|
|
36
|
-
|
|
37
|
-
const skillsExist = existsSync(SKILLS_DIR)
|
|
38
|
-
|
|
39
|
-
if (skillsExist) {
|
|
40
|
-
const update = await p.confirm({
|
|
41
|
-
message: 'Tela skills already installed. Update to latest version?',
|
|
42
|
-
initialValue: true,
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
if (p.isCancel(update) || !update) {
|
|
46
|
-
p.cancel('Installation cancelled.')
|
|
47
|
-
process.exit(0)
|
|
28
|
+
// --- CLI Flag Parsing ---
|
|
29
|
+
function parseFlags(argv: string[]): Record<string, string | true> {
|
|
30
|
+
const flags: Record<string, string | true> = {}
|
|
31
|
+
for (let i = 0; i < argv.length; i++) {
|
|
32
|
+
const arg = argv[i]!
|
|
33
|
+
if (arg.startsWith('--')) {
|
|
34
|
+
const key = arg.slice(2)
|
|
35
|
+
const next = argv[i + 1]
|
|
36
|
+
if (next && !next.startsWith('--')) {
|
|
37
|
+
flags[key] = next
|
|
38
|
+
i++
|
|
39
|
+
} else {
|
|
40
|
+
flags[key] = true
|
|
41
|
+
}
|
|
48
42
|
}
|
|
49
43
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
message: 'Install Tela skills to ~/.claude/skills/tela/?',
|
|
53
|
-
initialValue: true,
|
|
54
|
-
})
|
|
44
|
+
return flags
|
|
45
|
+
}
|
|
55
46
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
process.exit(0)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
47
|
+
const flags = parseFlags(process.argv.slice(2))
|
|
48
|
+
const YES = 'yes' in flags || 'y' in flags
|
|
61
49
|
|
|
50
|
+
async function installSkills() {
|
|
62
51
|
const spinner = p.spinner()
|
|
63
52
|
spinner.start('Installing skill files...')
|
|
64
53
|
|
|
@@ -68,17 +57,23 @@ async function main() {
|
|
|
68
57
|
const sourceDir = join(__dirname, 'skill')
|
|
69
58
|
const openIssueSkillFile = 'TELA_OPEN_ISSUE_SKILL.md'
|
|
70
59
|
const telaSkillFile = 'TELA_SKILL.md'
|
|
71
|
-
const
|
|
60
|
+
const localhostSkillFile = 'TELA_LOCALHOST_SKILL.md'
|
|
61
|
+
const files = readdirSync(sourceDir).filter(f =>
|
|
62
|
+
f !== openIssueSkillFile && f !== telaSkillFile && f !== localhostSkillFile
|
|
63
|
+
)
|
|
72
64
|
|
|
73
65
|
for (const file of files) {
|
|
74
66
|
cpSync(join(sourceDir, file), join(SKILLS_DIR, file))
|
|
75
67
|
}
|
|
76
68
|
cpSync(join(sourceDir, telaSkillFile), join(SKILLS_DIR, 'SKILL.md'))
|
|
77
69
|
|
|
78
|
-
// Install open-issue skill to its own directory
|
|
79
70
|
mkdirSync(OPEN_ISSUE_SKILLS_DIR, { recursive: true })
|
|
80
71
|
cpSync(join(sourceDir, openIssueSkillFile), join(OPEN_ISSUE_SKILLS_DIR, 'SKILL.md'))
|
|
81
72
|
|
|
73
|
+
// Install localhost skill to its own directory
|
|
74
|
+
mkdirSync(LOCALHOST_SKILLS_DIR, { recursive: true })
|
|
75
|
+
cpSync(join(sourceDir, localhostSkillFile), join(LOCALHOST_SKILLS_DIR, 'SKILL.md'))
|
|
76
|
+
|
|
82
77
|
const agentsSourceDir = join(__dirname, 'agents')
|
|
83
78
|
const agentsDestDir = join(homedir(), '.claude', 'agents')
|
|
84
79
|
if (existsSync(agentsSourceDir)) {
|
|
@@ -107,38 +102,296 @@ async function main() {
|
|
|
107
102
|
}
|
|
108
103
|
|
|
109
104
|
depSpinner.stop(`Installed ${dependencies.length} dependencies`)
|
|
110
|
-
}
|
|
111
|
-
catch (err: unknown) {
|
|
105
|
+
} catch (err: unknown) {
|
|
112
106
|
spinner.stop('Installation failed')
|
|
113
107
|
p.log.error(err instanceof Error ? err.message : String(err))
|
|
114
108
|
process.exit(1)
|
|
115
109
|
}
|
|
110
|
+
}
|
|
116
111
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
112
|
+
async function handleLocalhostSetup() {
|
|
113
|
+
// 1. Discover Docker services
|
|
114
|
+
const discoverSpinner = p.spinner()
|
|
115
|
+
discoverSpinner.start('Discovering local services via Docker...')
|
|
116
|
+
|
|
117
|
+
const services = discoverLocalhostServices()
|
|
118
|
+
|
|
119
|
+
if (!services.authApiPort) {
|
|
120
|
+
discoverSpinner.stop('auth-api container not found')
|
|
121
|
+
p.log.error('Could not find auth-api Docker container. Make sure local services are running.')
|
|
122
|
+
process.exit(1)
|
|
123
|
+
}
|
|
124
|
+
if (!services.telaApiPort) {
|
|
125
|
+
discoverSpinner.stop('tela-api container not found')
|
|
126
|
+
p.log.error('Could not find tela-api Docker container. Make sure local services are running.')
|
|
127
|
+
process.exit(1)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
discoverSpinner.stop(`Found services — auth-api :${services.authApiPort}, tela-api :${services.telaApiPort}${services.telaAppPort ? `, tela-app :${services.telaAppPort}` : ''}`)
|
|
131
|
+
|
|
132
|
+
// 2. Ask for meistrari repos path
|
|
133
|
+
const savedPath = readSavedReposPath()
|
|
134
|
+
const reposPathFlag = typeof flags['repos-path'] === 'string' ? flags['repos-path'] : null
|
|
135
|
+
|
|
136
|
+
let reposPath: string
|
|
137
|
+
if (reposPathFlag) {
|
|
138
|
+
reposPath = reposPathFlag
|
|
139
|
+
p.log.info(`Using repos path: ${reposPath}`)
|
|
140
|
+
} else if (YES && savedPath) {
|
|
141
|
+
reposPath = savedPath
|
|
142
|
+
p.log.info(`Using saved repos path: ${reposPath}`)
|
|
143
|
+
} else {
|
|
144
|
+
const result = await p.text({
|
|
145
|
+
message: 'Where is the tela monorepo cloned?',
|
|
146
|
+
placeholder: '/path/to/tela',
|
|
147
|
+
...(savedPath ? { defaultValue: savedPath } : {}),
|
|
148
|
+
validate(value): string | undefined {
|
|
149
|
+
if (!value.trim()) return 'Path is required'
|
|
150
|
+
return undefined
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
if (p.isCancel(result)) {
|
|
155
|
+
p.cancel('Setup cancelled.')
|
|
156
|
+
process.exit(0)
|
|
157
|
+
}
|
|
158
|
+
reposPath = result
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const resolvedPath = reposPath.replace(/^~/, homedir())
|
|
162
|
+
saveReposPath(resolvedPath)
|
|
163
|
+
|
|
164
|
+
// 3. Read RSA keys and auth secret
|
|
165
|
+
const keysSpinner = p.spinner()
|
|
166
|
+
keysSpinner.start('Reading RSA keys from api-gateway...')
|
|
167
|
+
|
|
168
|
+
let keys: ReturnType<typeof parseApiGatewayKeys>
|
|
169
|
+
let authApiSecret: string
|
|
170
|
+
try {
|
|
171
|
+
keys = parseApiGatewayKeys(resolvedPath)
|
|
172
|
+
authApiSecret = readAuthApiSecret(resolvedPath)
|
|
173
|
+
keysSpinner.stop('RSA keys loaded')
|
|
174
|
+
} catch (err) {
|
|
175
|
+
keysSpinner.stop('Failed to read keys')
|
|
176
|
+
p.log.error(err instanceof Error ? err.message : String(err))
|
|
177
|
+
process.exit(1)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 4. Fetch workspaces
|
|
181
|
+
const authApiUrl = `http://localhost:${services.authApiPort}`
|
|
182
|
+
const wsSpinner = p.spinner()
|
|
183
|
+
wsSpinner.start('Fetching workspaces from auth-api...')
|
|
184
|
+
|
|
185
|
+
let workspaces: Awaited<ReturnType<typeof fetchWorkspaces>>
|
|
186
|
+
try {
|
|
187
|
+
workspaces = await fetchWorkspaces(authApiUrl, authApiSecret)
|
|
188
|
+
wsSpinner.stop(`Found ${workspaces.length} workspace(s)`)
|
|
189
|
+
} catch (err) {
|
|
190
|
+
wsSpinner.stop('Failed to fetch workspaces')
|
|
191
|
+
p.log.error(err instanceof Error ? err.message : String(err))
|
|
192
|
+
process.exit(1)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (workspaces.length === 0) {
|
|
196
|
+
p.log.warning('No workspaces found. Create one using auth-api CLI scripts first.')
|
|
197
|
+
process.exit(1)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 5. Select workspace
|
|
201
|
+
const workspaceFlag = typeof flags['workspace'] === 'string' ? flags['workspace'] : null
|
|
202
|
+
|
|
203
|
+
let workspace: { id: string; title: string; slug: string }
|
|
204
|
+
if (workspaceFlag) {
|
|
205
|
+
const match = workspaces.find(ws => ws.id === workspaceFlag || ws.slug === workspaceFlag)
|
|
206
|
+
if (!match) {
|
|
207
|
+
p.log.error(`Workspace "${workspaceFlag}" not found. Available: ${workspaces.map(ws => `${ws.slug} (${ws.id})`).join(', ')}`)
|
|
208
|
+
process.exit(1)
|
|
209
|
+
}
|
|
210
|
+
workspace = match
|
|
211
|
+
p.log.info(`Using workspace: ${workspace.title}`)
|
|
212
|
+
} else if (YES && workspaces.length === 1) {
|
|
213
|
+
workspace = workspaces[0]!
|
|
214
|
+
p.log.info(`Auto-selecting only workspace: ${workspace.title}`)
|
|
215
|
+
} else {
|
|
216
|
+
const selectedWorkspace = await p.select({
|
|
217
|
+
message: 'Which workspace do you want to connect to?',
|
|
218
|
+
options: workspaces.map(ws => ({
|
|
219
|
+
value: ws,
|
|
220
|
+
label: ws.title,
|
|
221
|
+
hint: ws.slug,
|
|
222
|
+
})),
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
if (p.isCancel(selectedWorkspace)) {
|
|
226
|
+
p.cancel('Setup cancelled.')
|
|
227
|
+
process.exit(0)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
workspace = selectedWorkspace as { id: string; title: string; slug: string }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 6. Generate JWT and save
|
|
234
|
+
const tokenSpinner = p.spinner()
|
|
235
|
+
tokenSpinner.start('Generating authentication token...')
|
|
236
|
+
|
|
237
|
+
const token = signLocalhostJwt({
|
|
238
|
+
workspace: { id: workspace.id, title: workspace.title },
|
|
239
|
+
privateKey: keys.privateKey,
|
|
240
|
+
kid: keys.kid,
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
saveLocalhostSession(token)
|
|
244
|
+
writeLocalhostEnv(services.telaApiPort, services.telaAppPort)
|
|
245
|
+
|
|
246
|
+
tokenSpinner.stop('Token generated and saved')
|
|
247
|
+
|
|
248
|
+
// 7. Install skills (reuse existing logic)
|
|
249
|
+
if (YES) {
|
|
250
|
+
await installSkills()
|
|
251
|
+
} else {
|
|
252
|
+
const skillsExist = existsSync(SKILLS_DIR)
|
|
253
|
+
|
|
254
|
+
if (skillsExist) {
|
|
255
|
+
const update = await p.confirm({
|
|
256
|
+
message: 'Tela skills already installed. Update to latest version?',
|
|
257
|
+
initialValue: true,
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
if (!p.isCancel(update) && update) {
|
|
261
|
+
await installSkills()
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
const install = await p.confirm({
|
|
265
|
+
message: 'Install Tela skills to ~/.claude/skills/tela/?',
|
|
266
|
+
initialValue: true,
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
if (!p.isCancel(install) && install) {
|
|
270
|
+
await installSkills()
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
writeFileSync(join(SESSION_DIR, 'environment'), 'localhost', { mode: 0o600 })
|
|
276
|
+
|
|
277
|
+
p.log.success(`Connected to workspace "${workspace.title}" on localhost`)
|
|
278
|
+
|
|
279
|
+
p.note(
|
|
280
|
+
`Use in Claude Code with bun --preload:
|
|
281
|
+
|
|
282
|
+
bun --preload ~/.claude/skills/tela/preload.ts -e "
|
|
283
|
+
console.log(await tela.listProjects())
|
|
284
|
+
"`,
|
|
285
|
+
'Usage',
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
p.outro('Localhost setup complete!')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function main() {
|
|
292
|
+
p.intro('Tela Skills for Claude Code')
|
|
293
|
+
|
|
294
|
+
const envFlag = typeof flags['env'] === 'string' ? flags['env'] : null
|
|
295
|
+
const validEnvs = ['production', 'staging', 'localhost'] as const
|
|
296
|
+
|
|
297
|
+
let environment: string
|
|
298
|
+
if (envFlag) {
|
|
299
|
+
if (!validEnvs.includes(envFlag as any)) {
|
|
300
|
+
p.log.error(`Invalid environment: "${envFlag}". Valid: ${validEnvs.join(', ')}`)
|
|
301
|
+
process.exit(1)
|
|
302
|
+
}
|
|
303
|
+
environment = envFlag
|
|
304
|
+
p.log.info(`Environment: ${environment}`)
|
|
305
|
+
} else {
|
|
306
|
+
const selected = await p.select({
|
|
307
|
+
message: 'Which environment do you want to connect to?',
|
|
308
|
+
options: [
|
|
309
|
+
{ value: 'production', label: 'Production' },
|
|
310
|
+
{ value: 'staging', label: 'Staging' },
|
|
311
|
+
{ value: 'localhost', label: 'Localhost (internal)' },
|
|
312
|
+
],
|
|
121
313
|
})
|
|
122
314
|
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
315
|
+
if (p.isCancel(selected)) {
|
|
316
|
+
p.cancel('Setup cancelled.')
|
|
317
|
+
process.exit(0)
|
|
318
|
+
}
|
|
319
|
+
environment = selected as string
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (environment === 'localhost') {
|
|
323
|
+
await handleLocalhostSetup()
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const env = environment as 'production' | 'staging'
|
|
328
|
+
const sessionPath = join(SESSION_DIR, `session.${env}`)
|
|
329
|
+
const sessionLabel = `~/.tela/session.${env}`
|
|
330
|
+
const backofficeUrl = env === 'staging' ? STAGING_BACKOFFICE_URL : BACKOFFICE_URL
|
|
331
|
+
|
|
332
|
+
if (YES) {
|
|
333
|
+
await installSkills()
|
|
334
|
+
} else {
|
|
335
|
+
const skillsExist = existsSync(SKILLS_DIR)
|
|
336
|
+
|
|
337
|
+
if (skillsExist) {
|
|
338
|
+
const update = await p.confirm({
|
|
339
|
+
message: 'Tela skills already installed. Update to latest version?',
|
|
340
|
+
initialValue: true,
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
if (p.isCancel(update) || !update) {
|
|
344
|
+
p.cancel('Installation cancelled.')
|
|
345
|
+
process.exit(0)
|
|
346
|
+
}
|
|
129
347
|
}
|
|
130
348
|
else {
|
|
349
|
+
const install = await p.confirm({
|
|
350
|
+
message: 'Install Tela skills to ~/.claude/skills/tela/?',
|
|
351
|
+
initialValue: true,
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
if (p.isCancel(install) || !install) {
|
|
355
|
+
p.cancel('Installation cancelled.')
|
|
356
|
+
process.exit(0)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
await installSkills()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (existsSync(sessionPath)) {
|
|
364
|
+
if (YES) {
|
|
131
365
|
p.log.info(`Keeping existing session at ${sessionLabel}`)
|
|
366
|
+
} else {
|
|
367
|
+
const reconnect = await p.confirm({
|
|
368
|
+
message: 'Session already configured. Clear and reconnect?',
|
|
369
|
+
initialValue: false,
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
if (!p.isCancel(reconnect) && reconnect) {
|
|
373
|
+
const { unlinkSync } = await import('node:fs')
|
|
374
|
+
unlinkSync(sessionPath)
|
|
375
|
+
const envPath = join(SESSION_DIR, 'environment')
|
|
376
|
+
if (existsSync(envPath)) unlinkSync(envPath)
|
|
377
|
+
p.log.info('Session cleared. Proceeding to authentication...')
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
p.log.info(`Keeping existing session at ${sessionLabel}`)
|
|
381
|
+
}
|
|
132
382
|
}
|
|
133
383
|
}
|
|
134
384
|
|
|
135
385
|
if (!existsSync(sessionPath)) {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
386
|
+
const shouldAuth = YES || await (async () => {
|
|
387
|
+
const setupKey = await p.confirm({
|
|
388
|
+
message: 'Set up Tela authentication now?',
|
|
389
|
+
initialValue: true,
|
|
390
|
+
})
|
|
391
|
+
return !p.isCancel(setupKey) && setupKey
|
|
392
|
+
})()
|
|
140
393
|
|
|
141
|
-
if (
|
|
394
|
+
if (shouldAuth) {
|
|
142
395
|
let resolveToken: (token: string) => void
|
|
143
396
|
let rejectToken: (error: Error) => void
|
|
144
397
|
const tokenPromise = new Promise<string>((resolve, reject) => {
|
|
@@ -147,6 +400,7 @@ async function main() {
|
|
|
147
400
|
})
|
|
148
401
|
|
|
149
402
|
const server = Bun.serve({
|
|
403
|
+
hostname: '127.0.0.1',
|
|
150
404
|
port: 0,
|
|
151
405
|
fetch(req) {
|
|
152
406
|
const url = new URL(req.url)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* List available Tela workspaces from the local auth-api.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* bun list-workspaces.ts
|
|
7
|
+
* bunx @meistrari/tela-skills/list-workspaces
|
|
8
|
+
*
|
|
9
|
+
* Requires:
|
|
10
|
+
* - Docker running with auth-api container
|
|
11
|
+
* - Tela monorepo path saved at ~/.tela/tela-repo-path (via CLI setup)
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
discoverLocalhostServices,
|
|
15
|
+
readSavedReposPath,
|
|
16
|
+
readAuthApiSecret,
|
|
17
|
+
fetchWorkspaces,
|
|
18
|
+
} from './skill/localhost-utils.ts'
|
|
19
|
+
|
|
20
|
+
const reposPath = readSavedReposPath()
|
|
21
|
+
if (!reposPath) {
|
|
22
|
+
console.error('No tela repo path saved. Run CLI setup first: bunx @meistrari/tela-skills → select Localhost')
|
|
23
|
+
process.exit(1)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const services = discoverLocalhostServices()
|
|
27
|
+
if (!services.authApiPort) {
|
|
28
|
+
console.error('auth-api Docker container not found. Are local services running?')
|
|
29
|
+
process.exit(1)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const secret = readAuthApiSecret(reposPath)
|
|
33
|
+
const workspaces = await fetchWorkspaces(`http://localhost:${services.authApiPort}`, secret)
|
|
34
|
+
|
|
35
|
+
if (workspaces.length === 0) {
|
|
36
|
+
console.log('No workspaces found.')
|
|
37
|
+
process.exit(0)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.log('Available workspaces:')
|
|
41
|
+
for (const ws of workspaces) {
|
|
42
|
+
console.log(` - ${ws.title} (${ws.id}) [${ws.slug}]`)
|
|
43
|
+
}
|
package/localhost.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Run tela-skills against localhost (discovers ports via Docker).
|
|
4
|
+
* Usage: bun run localhost -- "console.log(await tela.listProjects())"
|
|
5
|
+
* or: bun localhost.ts "console.log(await tela.listProjects())"
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { discoverLocalhostServices, writeLocalhostEnv } from './skill/localhost-utils.ts'
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
const TELA_PRELOAD = join(__dirname, 'skill', 'preload.ts')
|
|
15
|
+
const SESSION_PATH = join(homedir(), '.tela', 'session.local')
|
|
16
|
+
|
|
17
|
+
const args = process.argv.slice(2)
|
|
18
|
+
const code = args.join(' ').trim()
|
|
19
|
+
if (!code) {
|
|
20
|
+
console.error('Usage: bun localhost.ts "console.log(await tela.listProjects())"')
|
|
21
|
+
process.exit(1)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Check session.local exists
|
|
25
|
+
if (!existsSync(SESSION_PATH)) {
|
|
26
|
+
console.error('Error: No localhost session found at ~/.tela/session.local')
|
|
27
|
+
console.error('Run CLI setup first: bunx @meistrari/tela-skills → select Localhost')
|
|
28
|
+
process.exit(1)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Discover ports from Docker
|
|
32
|
+
const services = discoverLocalhostServices()
|
|
33
|
+
if (!services.telaApiPort) {
|
|
34
|
+
console.error('Error: tela-api Docker container not found. Are local services running?')
|
|
35
|
+
process.exit(1)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Write .env with discovered ports
|
|
39
|
+
writeLocalhostEnv(services.telaApiPort, services.telaAppPort)
|
|
40
|
+
|
|
41
|
+
const proc = Bun.spawn(['bun', '--preload', TELA_PRELOAD, '-e', code], {
|
|
42
|
+
stdio: ['inherit', 'inherit', 'inherit'],
|
|
43
|
+
})
|
|
44
|
+
process.exit(await proc.exited)
|
package/package.json
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meistrari/tela-skills",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Tela API skills for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"tela-skills": "./cli.ts"
|
|
7
|
+
"tela-skills": "./cli.ts",
|
|
8
|
+
"tela-list-workspaces": "./list-workspaces.ts"
|
|
8
9
|
},
|
|
9
10
|
"scripts": {
|
|
11
|
+
"prepare": "ln -sf ../../scripts/pre-commit .git/hooks/pre-commit",
|
|
10
12
|
"postinstall": "mise-en-place postinstall",
|
|
11
13
|
"build": "echo \"No build step required, script placeholder for npm publish action compatibility\""
|
|
12
14
|
},
|
|
13
15
|
"files": [
|
|
14
16
|
"cli.ts",
|
|
17
|
+
"localhost.ts",
|
|
18
|
+
"list-workspaces.ts",
|
|
15
19
|
"skill/**/*"
|
|
16
20
|
],
|
|
17
21
|
"keywords": [
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tela-localhost
|
|
3
|
+
description: Manage localhost workspaces for Tela. Switch workspaces, check status, and regenerate tokens for local development.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Tela Localhost Workspace Manager
|
|
7
|
+
|
|
8
|
+
Manage your local Tela development environment. Switch between workspaces, check connection status, and regenerate authentication tokens.
|
|
9
|
+
|
|
10
|
+
## Prerequisites
|
|
11
|
+
|
|
12
|
+
- Docker running with tela services (auth-api, tela-api)
|
|
13
|
+
- Localhost environment configured via `bunx @meistrari/tela-skills` (select "Localhost")
|
|
14
|
+
- Tela monorepo path saved at `~/.tela/tela-repo-path`
|
|
15
|
+
|
|
16
|
+
## Commands
|
|
17
|
+
|
|
18
|
+
### List Workspaces
|
|
19
|
+
|
|
20
|
+
Show all available workspaces in the local environment.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
bunx @meistrari/tela-skills/list-workspaces
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or if running from the repo directly:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bun list-workspaces.ts
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Switch Workspace
|
|
33
|
+
|
|
34
|
+
Switch to a different workspace by generating a new JWT token.
|
|
35
|
+
|
|
36
|
+
Replace `WORKSPACE_ID` and `WORKSPACE_TITLE` with the target workspace values from "List Workspaces".
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bun --preload ~/.claude/skills/tela/preload.ts -e "
|
|
40
|
+
const { join } = await import('node:path')
|
|
41
|
+
const { homedir } = await import('node:os')
|
|
42
|
+
const lu = await import(join(homedir(), '.claude', 'skills', 'tela', 'localhost-utils.ts'))
|
|
43
|
+
const reposPath = lu.readSavedReposPath()
|
|
44
|
+
if (!reposPath) { console.error('No tela repo path saved. Run CLI setup first.'); process.exit(1) }
|
|
45
|
+
|
|
46
|
+
const keys = lu.parseApiGatewayKeys(reposPath)
|
|
47
|
+
const token = lu.signLocalhostJwt({
|
|
48
|
+
workspace: { id: 'WORKSPACE_ID', title: 'WORKSPACE_TITLE' },
|
|
49
|
+
privateKey: keys.privateKey,
|
|
50
|
+
kid: keys.kid,
|
|
51
|
+
})
|
|
52
|
+
lu.saveLocalhostSession(token)
|
|
53
|
+
console.log('Switched to workspace: WORKSPACE_TITLE')
|
|
54
|
+
console.log('New token saved to ~/.tela/session.local')
|
|
55
|
+
"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Check Status
|
|
59
|
+
|
|
60
|
+
Verify localhost connection is working and show current workspace.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
bun --preload ~/.claude/skills/tela/preload.ts -e "
|
|
64
|
+
const { readFileSync, existsSync } = await import('node:fs')
|
|
65
|
+
const { join } = await import('node:path')
|
|
66
|
+
const { homedir } = await import('node:os')
|
|
67
|
+
|
|
68
|
+
const envPath = join(homedir(), '.tela', '.env')
|
|
69
|
+
const sessionPath = join(homedir(), '.tela', 'session.local')
|
|
70
|
+
|
|
71
|
+
if (!existsSync(sessionPath)) {
|
|
72
|
+
console.log('Status: NOT CONFIGURED')
|
|
73
|
+
console.log('Run CLI setup: bunx @meistrari/tela-skills → select Localhost')
|
|
74
|
+
process.exit(0)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Decode JWT payload
|
|
78
|
+
const token = readFileSync(sessionPath, 'utf-8').trim()
|
|
79
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString())
|
|
80
|
+
console.log('Status: CONFIGURED')
|
|
81
|
+
console.log('Workspace:', payload.workspace?.title, '(' + payload.workspace?.id + ')')
|
|
82
|
+
console.log('Token expires:', new Date(payload.exp * 1000).toISOString())
|
|
83
|
+
|
|
84
|
+
// Check env
|
|
85
|
+
if (existsSync(envPath)) {
|
|
86
|
+
const env = readFileSync(envPath, 'utf-8')
|
|
87
|
+
console.log('API URL:', env.match(/TELA_API_URL=(.*)/)?.[1] || 'not set')
|
|
88
|
+
console.log('App URL:', env.match(/TELA_APP_URL=(.*)/)?.[1] || 'not set')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Try a test call
|
|
92
|
+
try {
|
|
93
|
+
const projects = await tela.listProjects()
|
|
94
|
+
console.log('Connection: OK (' + projects.length + ' projects)')
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.log('Connection: FAILED -', e.message)
|
|
97
|
+
}
|
|
98
|
+
"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Troubleshooting
|
|
102
|
+
|
|
103
|
+
- **"auth-api not running"**: Start Docker services in the tela monorepo (`make dev`)
|
|
104
|
+
- **"No tela repo path saved"**: Run `bunx @meistrari/tela-skills` and select Localhost to configure
|
|
105
|
+
- **"Connection: FAILED"**: Check that tela-api Docker container is running (`docker ps | grep tela-api`)
|
|
106
|
+
- **Token expired**: Tokens are auto-regenerated when expired. If auto-regeneration fails, use "Switch Workspace" to regenerate manually
|
package/skill/common.ts
CHANGED
|
@@ -6,7 +6,11 @@ const SESSION_DIR = join(homedir(), '.tela')
|
|
|
6
6
|
|
|
7
7
|
export function getSessionPath(): string {
|
|
8
8
|
const apiUrl = getApiBaseUrl()
|
|
9
|
-
const env = apiUrl.includes('
|
|
9
|
+
const env = apiUrl.includes('localhost')
|
|
10
|
+
? 'local'
|
|
11
|
+
: apiUrl.includes('staging')
|
|
12
|
+
? 'staging'
|
|
13
|
+
: 'production'
|
|
10
14
|
return join(SESSION_DIR, `session.${env}`)
|
|
11
15
|
}
|
|
12
16
|
|
|
@@ -34,12 +38,58 @@ export function getCanvasUrl(canvasId: string): string {
|
|
|
34
38
|
return `${getAppBaseUrl()}/prompt/${canvasId}/craft`
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
export function isJwtExpired(token: string): boolean {
|
|
42
|
+
try {
|
|
43
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString())
|
|
44
|
+
return typeof payload.exp === 'number' && payload.exp < Math.floor(Date.now() / 1000)
|
|
45
|
+
} catch {
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getJwtWorkspace(token: string): { id: string; title: string } | null {
|
|
51
|
+
try {
|
|
52
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString())
|
|
53
|
+
return payload.workspace ?? null
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function tryRegenerateLocalhostToken(token: string): string | null {
|
|
60
|
+
try {
|
|
61
|
+
const workspace = getJwtWorkspace(token)
|
|
62
|
+
if (!workspace) return null
|
|
63
|
+
|
|
64
|
+
const { readSavedReposPath, parseApiGatewayKeys, signLocalhostJwt, saveLocalhostSession } = require('./localhost-utils') as typeof import('./localhost-utils')
|
|
65
|
+
|
|
66
|
+
const reposPath = readSavedReposPath()
|
|
67
|
+
if (!reposPath) return null
|
|
68
|
+
|
|
69
|
+
const keys = parseApiGatewayKeys(reposPath)
|
|
70
|
+
const newToken = signLocalhostJwt({ workspace, privateKey: keys.privateKey, kid: keys.kid })
|
|
71
|
+
saveLocalhostSession(newToken)
|
|
72
|
+
return newToken
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
37
78
|
export function loadApiKey(): string {
|
|
38
79
|
const sessionPath = getSessionPath()
|
|
39
80
|
if (!existsSync(sessionPath)) {
|
|
40
81
|
throw new Error(`No session found at ${sessionPath}. Run the installer to authenticate first.`)
|
|
41
82
|
}
|
|
42
|
-
|
|
83
|
+
let token = readFileSync(sessionPath, 'utf-8').trim()
|
|
84
|
+
|
|
85
|
+
if (getApiBaseUrl().includes('localhost') && isJwtExpired(token)) {
|
|
86
|
+
const refreshed = tryRegenerateLocalhostToken(token)
|
|
87
|
+
if (refreshed) {
|
|
88
|
+
token = refreshed
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return token
|
|
43
93
|
}
|
|
44
94
|
|
|
45
95
|
export function parsePayload(obj: unknown): unknown {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { generateKeyPairSync, randomUUID } from 'node:crypto'
|
|
3
|
+
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
|
|
7
|
+
// Generate a test RSA keypair
|
|
8
|
+
const { publicKey: testPublicPem, privateKey: testPrivatePem } = generateKeyPairSync('rsa', {
|
|
9
|
+
modulusLength: 2048,
|
|
10
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
11
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
12
|
+
})
|
|
13
|
+
const testKid = 'test-key-id-123'
|
|
14
|
+
|
|
15
|
+
describe('localhost-utils', () => {
|
|
16
|
+
describe('parseApiGatewayKeys', () => {
|
|
17
|
+
test('parses multiline RSA keys from .env file', async () => {
|
|
18
|
+
const { parseApiGatewayKeys } = await import('./localhost-utils.ts')
|
|
19
|
+
|
|
20
|
+
// Create a temp directory mimicking the repo structure
|
|
21
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'tela-test-'))
|
|
22
|
+
const envDir = join(tmpDir, '.repositories', 'api-gateway')
|
|
23
|
+
mkdirSync(envDir, { recursive: true })
|
|
24
|
+
|
|
25
|
+
const envContent = `CURRENT_KEY_ID='${testKid}'\nCURRENT_KEY_PRIVATE='${testPrivatePem}'\nCURRENT_KEY_PUBLIC='${testPublicPem}'`
|
|
26
|
+
writeFileSync(join(envDir, '.env'), envContent)
|
|
27
|
+
|
|
28
|
+
const keys = parseApiGatewayKeys(tmpDir)
|
|
29
|
+
expect(keys.kid).toBe(testKid)
|
|
30
|
+
expect(keys.privateKey).toContain('BEGIN PRIVATE KEY')
|
|
31
|
+
expect(keys.publicKey).toContain('BEGIN PUBLIC KEY')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('throws when .env file is missing', async () => {
|
|
35
|
+
const { parseApiGatewayKeys } = await import('./localhost-utils.ts')
|
|
36
|
+
expect(() => parseApiGatewayKeys('/nonexistent/path')).toThrow('api-gateway .env not found')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('signLocalhostJwt', () => {
|
|
41
|
+
test('produces a valid 3-part JWT', async () => {
|
|
42
|
+
const { signLocalhostJwt } = await import('./localhost-utils.ts')
|
|
43
|
+
|
|
44
|
+
const token = signLocalhostJwt({
|
|
45
|
+
workspace: { id: randomUUID(), title: 'Test Workspace' },
|
|
46
|
+
privateKey: testPrivatePem,
|
|
47
|
+
kid: testKid,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const parts = token.split('.')
|
|
51
|
+
expect(parts).toHaveLength(3)
|
|
52
|
+
|
|
53
|
+
// Decode and verify header
|
|
54
|
+
const header = JSON.parse(Buffer.from(parts[0]!, 'base64url').toString())
|
|
55
|
+
expect(header.alg).toBe('RS256')
|
|
56
|
+
expect(header.typ).toBe('JWT')
|
|
57
|
+
expect(header.kid).toBe(testKid)
|
|
58
|
+
|
|
59
|
+
// Decode and verify payload
|
|
60
|
+
const payload = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString())
|
|
61
|
+
expect(payload.iss).toBe('api.tela.com')
|
|
62
|
+
expect(payload.workspace.title).toBe('Test Workspace')
|
|
63
|
+
expect(payload.requestId).toBeDefined()
|
|
64
|
+
expect(payload.iat).toBeDefined()
|
|
65
|
+
expect(payload.exp).toBeGreaterThan(payload.iat)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('getDockerPort', () => {
|
|
70
|
+
test('returns null for non-existent container', async () => {
|
|
71
|
+
const { getDockerPort } = await import('./localhost-utils.ts')
|
|
72
|
+
const port = getDockerPort('nonexistent-container-xyz')
|
|
73
|
+
expect(port).toBeNull()
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('readAuthApiSecret', () => {
|
|
78
|
+
test('reads quoted AUTH_API_SECRET from tela-api .env', async () => {
|
|
79
|
+
const { readAuthApiSecret } = await import('./localhost-utils.ts')
|
|
80
|
+
|
|
81
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'tela-test-'))
|
|
82
|
+
const envDir = join(tmpDir, 'packages', 'api')
|
|
83
|
+
mkdirSync(envDir, { recursive: true })
|
|
84
|
+
writeFileSync(join(envDir, '.env'), "AUTH_API_SECRET='test-secret'\nOTHER=value\n")
|
|
85
|
+
|
|
86
|
+
const secret = readAuthApiSecret(tmpDir)
|
|
87
|
+
expect(secret).toBe('test-secret')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('reads unquoted AUTH_API_SECRET from tela-api .env', async () => {
|
|
91
|
+
const { readAuthApiSecret } = await import('./localhost-utils.ts')
|
|
92
|
+
|
|
93
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'tela-test-'))
|
|
94
|
+
const envDir = join(tmpDir, 'packages', 'api')
|
|
95
|
+
mkdirSync(envDir, { recursive: true })
|
|
96
|
+
writeFileSync(join(envDir, '.env'), "AUTH_API_SECRET=chefs\nOTHER=value\n")
|
|
97
|
+
|
|
98
|
+
const secret = readAuthApiSecret(tmpDir)
|
|
99
|
+
expect(secret).toBe('chefs')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('throws when .env missing', async () => {
|
|
103
|
+
const { readAuthApiSecret } = await import('./localhost-utils.ts')
|
|
104
|
+
expect(() => readAuthApiSecret('/nonexistent')).toThrow('tela-api .env not found')
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { createSign, randomUUID } from 'node:crypto'
|
|
3
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
|
|
7
|
+
const SESSION_DIR = join(homedir(), '.tela')
|
|
8
|
+
|
|
9
|
+
// --- Docker Discovery ---
|
|
10
|
+
|
|
11
|
+
export function getDockerPort(container: string): number | null {
|
|
12
|
+
try {
|
|
13
|
+
const output = execFileSync('docker', ['port', container], {
|
|
14
|
+
encoding: 'utf-8',
|
|
15
|
+
timeout: 5000,
|
|
16
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
17
|
+
})
|
|
18
|
+
const match = output.match(/-> [^:]+:(\d+)/)
|
|
19
|
+
return match ? parseInt(match[1]!, 10) : null
|
|
20
|
+
} catch {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function discoverLocalhostServices(): {
|
|
26
|
+
authApiPort: number | null
|
|
27
|
+
telaApiPort: number | null
|
|
28
|
+
telaAppPort: number | null
|
|
29
|
+
} {
|
|
30
|
+
return {
|
|
31
|
+
authApiPort: getDockerPort('auth-api'),
|
|
32
|
+
telaApiPort: getDockerPort('tela-api'),
|
|
33
|
+
telaAppPort: getDockerPort('tela-app'),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// --- RSA Key Parsing ---
|
|
38
|
+
|
|
39
|
+
function parseEnvFile(content: string): Record<string, string> {
|
|
40
|
+
return Object.fromEntries(
|
|
41
|
+
content
|
|
42
|
+
.replace(/\r\n/g, '\n')
|
|
43
|
+
.split(/\n(?=[A-Za-z_][A-Za-z0-9_]*=)/)
|
|
44
|
+
.filter((line) => !line.trimStart().startsWith('#'))
|
|
45
|
+
.map((line) => {
|
|
46
|
+
const firstEqual = line.indexOf('=')
|
|
47
|
+
if (firstEqual === -1) return [line, '']
|
|
48
|
+
const key = line.slice(0, firstEqual)
|
|
49
|
+
let value = line.slice(firstEqual + 1)
|
|
50
|
+
// Strip surrounding single or double quotes if present
|
|
51
|
+
if ((value.startsWith("'") && value.endsWith("'")) ||
|
|
52
|
+
(value.startsWith('"') && value.endsWith('"'))) {
|
|
53
|
+
value = value.slice(1, -1)
|
|
54
|
+
}
|
|
55
|
+
return [key, value]
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseApiGatewayKeys(reposPath: string): {
|
|
61
|
+
kid: string
|
|
62
|
+
privateKey: string
|
|
63
|
+
publicKey: string
|
|
64
|
+
} {
|
|
65
|
+
const envPath = join(reposPath, '.repositories', 'api-gateway', '.env')
|
|
66
|
+
if (!existsSync(envPath)) {
|
|
67
|
+
throw new Error(`api-gateway .env not found at ${envPath}`)
|
|
68
|
+
}
|
|
69
|
+
const content = readFileSync(envPath, 'utf-8')
|
|
70
|
+
const env = parseEnvFile(content)
|
|
71
|
+
|
|
72
|
+
const kid = env.CURRENT_KEY_ID
|
|
73
|
+
const privateKey = env.CURRENT_KEY_PRIVATE
|
|
74
|
+
const publicKey = env.CURRENT_KEY_PUBLIC
|
|
75
|
+
|
|
76
|
+
if (!kid || !privateKey || !publicKey) {
|
|
77
|
+
throw new Error(`Missing RSA key variables in ${envPath}. Expected: CURRENT_KEY_ID, CURRENT_KEY_PRIVATE, CURRENT_KEY_PUBLIC`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { kid, privateKey, publicKey }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- Auth API Secret ---
|
|
84
|
+
|
|
85
|
+
export function readAuthApiSecret(reposPath: string): string {
|
|
86
|
+
const envPath = join(reposPath, 'packages', 'api', '.env')
|
|
87
|
+
if (!existsSync(envPath)) {
|
|
88
|
+
throw new Error(`tela-api .env not found at ${envPath}. Expected at: ${envPath}`)
|
|
89
|
+
}
|
|
90
|
+
const content = readFileSync(envPath, 'utf-8')
|
|
91
|
+
const env = parseEnvFile(content)
|
|
92
|
+
const secret = env.AUTH_API_SECRET
|
|
93
|
+
if (!secret) {
|
|
94
|
+
throw new Error(`AUTH_API_SECRET not found in ${envPath}`)
|
|
95
|
+
}
|
|
96
|
+
return secret
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- JWT Signing ---
|
|
100
|
+
|
|
101
|
+
export function signLocalhostJwt(opts: {
|
|
102
|
+
workspace: { id: string; title: string }
|
|
103
|
+
privateKey: string
|
|
104
|
+
kid: string
|
|
105
|
+
}): string {
|
|
106
|
+
const header = { alg: 'RS256', typ: 'JWT', kid: opts.kid }
|
|
107
|
+
const b64 = (obj: object) => Buffer.from(JSON.stringify(obj)).toString('base64url')
|
|
108
|
+
|
|
109
|
+
const headerB64 = b64(header)
|
|
110
|
+
const payloadB64 = b64({
|
|
111
|
+
requestId: randomUUID(),
|
|
112
|
+
workspace: opts.workspace,
|
|
113
|
+
iat: Math.floor(Date.now() / 1000),
|
|
114
|
+
exp: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
|
115
|
+
iss: 'api.tela.com',
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const signature = createSign('RSA-SHA256')
|
|
119
|
+
.update(`${headerB64}.${payloadB64}`)
|
|
120
|
+
.sign(opts.privateKey, 'base64url')
|
|
121
|
+
|
|
122
|
+
return `${headerB64}.${payloadB64}.${signature}`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- Workspace Fetching ---
|
|
126
|
+
|
|
127
|
+
export type Workspace = {
|
|
128
|
+
id: string
|
|
129
|
+
title: string
|
|
130
|
+
slug: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function fetchWorkspaces(authApiUrl: string, authApiSecret: string): Promise<Workspace[]> {
|
|
134
|
+
const response = await fetch(`${authApiUrl}/organizations`, {
|
|
135
|
+
headers: { Authorization: `Bearer ${authApiSecret}` },
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
throw new Error(`Failed to fetch workspaces: ${response.status} ${response.statusText}`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const { data } = await response.json() as { data: Workspace[] }
|
|
143
|
+
return data
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- Session Management ---
|
|
147
|
+
|
|
148
|
+
export function saveLocalhostSession(token: string): void {
|
|
149
|
+
mkdirSync(SESSION_DIR, { recursive: true })
|
|
150
|
+
writeFileSync(join(SESSION_DIR, 'session.local'), token, { mode: 0o600 })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function writeLocalhostEnv(apiPort: number, appPort: number | null): void {
|
|
154
|
+
mkdirSync(SESSION_DIR, { recursive: true })
|
|
155
|
+
const apiUrl = `http://localhost:${apiPort}`
|
|
156
|
+
const appUrl = appPort ? `http://localhost:${appPort}` : `http://localhost:3000`
|
|
157
|
+
writeFileSync(
|
|
158
|
+
join(SESSION_DIR, '.env'),
|
|
159
|
+
`TELA_API_URL=${apiUrl}\nTELA_APP_URL=${appUrl}\n`,
|
|
160
|
+
{ mode: 0o600 },
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function readSavedReposPath(): string | null {
|
|
165
|
+
const saved = join(SESSION_DIR, 'tela-repo-path')
|
|
166
|
+
if (existsSync(saved)) {
|
|
167
|
+
return readFileSync(saved, 'utf-8').trim() || null
|
|
168
|
+
}
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function saveReposPath(reposPath: string): void {
|
|
173
|
+
mkdirSync(SESSION_DIR, { recursive: true })
|
|
174
|
+
writeFileSync(join(SESSION_DIR, 'tela-repo-path'), reposPath, { mode: 0o600 })
|
|
175
|
+
}
|