@remix-run/cli 0.1.0 → 0.2.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.
Files changed (56) hide show
  1. package/README.md +0 -3
  2. package/bootstrap/.agents/skills/remix/SKILL.md +501 -0
  3. package/bootstrap/.agents/skills/remix/references/animate-elements.md +195 -0
  4. package/bootstrap/.agents/skills/remix/references/assets-and-browser-modules.md +122 -0
  5. package/bootstrap/.agents/skills/remix/references/auth-and-sessions.md +420 -0
  6. package/bootstrap/.agents/skills/remix/references/component-model.md +282 -0
  7. package/bootstrap/.agents/skills/remix/references/create-mixins.md +158 -0
  8. package/bootstrap/.agents/skills/remix/references/data-and-validation.md +363 -0
  9. package/bootstrap/.agents/skills/remix/references/hydration-frames-navigation.md +297 -0
  10. package/bootstrap/.agents/skills/remix/references/middleware-and-server.md +243 -0
  11. package/bootstrap/.agents/skills/remix/references/mixins-styling-events.md +213 -0
  12. package/bootstrap/.agents/skills/remix/references/routing-and-controllers.md +324 -0
  13. package/bootstrap/.agents/skills/remix/references/testing-patterns.md +156 -0
  14. package/bootstrap/AGENTS.md +4 -0
  15. package/bootstrap/app/assets/entry.ts +19 -0
  16. package/bootstrap/app/assets.ts +18 -0
  17. package/bootstrap/app/controllers/auth.tsx +2 -2
  18. package/bootstrap/app/controllers/home.tsx +3 -18
  19. package/bootstrap/app/router.ts +6 -0
  20. package/bootstrap/app/routes.ts +2 -1
  21. package/bootstrap/app/ui/document.tsx +6 -1
  22. package/bootstrap/app/ui/prompt-button.tsx +162 -0
  23. package/bootstrap/app/ui/scaffold-home-page.tsx +526 -0
  24. package/bootstrap/app/utils/render.tsx +22 -3
  25. package/bootstrap/server.ts +13 -13
  26. package/bootstrap/tsconfig.json +0 -1
  27. package/dist/lib/cli.d.ts.map +1 -1
  28. package/dist/lib/cli.js +7 -10
  29. package/dist/lib/commands/help.d.ts.map +1 -1
  30. package/dist/lib/commands/help.js +9 -33
  31. package/dist/lib/commands/test.d.ts +1 -1
  32. package/dist/lib/commands/test.d.ts.map +1 -1
  33. package/dist/lib/commands/test.js +8 -4
  34. package/dist/lib/completion.d.ts.map +1 -1
  35. package/dist/lib/completion.js +3 -101
  36. package/dist/lib/errors.d.ts +0 -6
  37. package/dist/lib/errors.d.ts.map +1 -1
  38. package/dist/lib/errors.js +0 -11
  39. package/package.json +3 -4
  40. package/src/lib/cli.ts +7 -11
  41. package/src/lib/commands/help.ts +9 -43
  42. package/src/lib/commands/test.ts +10 -4
  43. package/src/lib/completion.ts +3 -146
  44. package/src/lib/errors.ts +0 -12
  45. package/dist/lib/commands/skills.d.ts +0 -6
  46. package/dist/lib/commands/skills.d.ts.map +0 -1
  47. package/dist/lib/commands/skills.js +0 -222
  48. package/dist/lib/skills-cache.d.ts +0 -19
  49. package/dist/lib/skills-cache.d.ts.map +0 -1
  50. package/dist/lib/skills-cache.js +0 -89
  51. package/dist/lib/skills.d.ts +0 -30
  52. package/dist/lib/skills.d.ts.map +0 -1
  53. package/dist/lib/skills.js +0 -441
  54. package/src/lib/commands/skills.ts +0 -306
  55. package/src/lib/skills-cache.ts +0 -140
  56. package/src/lib/skills.ts +0 -706
package/src/lib/skills.ts DELETED
@@ -1,706 +0,0 @@
1
- import { parseTar } from '@remix-run/tar-parser'
2
- import * as crypto from 'node:crypto'
3
- import * as fs from 'node:fs/promises'
4
- import * as path from 'node:path'
5
- import * as process from 'node:process'
6
- import { gunzipSync } from 'node:zlib'
7
-
8
- import { resolveContainedPath } from './contained-path.ts'
9
- import { fetchUnavailable, projectRootNotFound, remoteSkillDataMissing } from './errors.ts'
10
- import { runProgressStep, type StepProgressReporter } from './reporter.ts'
11
- import {
12
- createSkillsCacheManifest,
13
- readSkillsCache,
14
- type SkillsCacheManifest,
15
- type SkillsCacheSkillEntry,
16
- writeSkillsCache,
17
- } from './skills-cache.ts'
18
-
19
- const REMIX_GITHUB_TREE_URL =
20
- 'https://api.github.com/repos/remix-run/remix/git/trees/main?recursive=1'
21
- const REMIX_GITHUB_ARCHIVE_URL =
22
- 'https://codeload.github.com/remix-run/remix/tar.gz/refs/heads/main'
23
- const REMIX_SKILLS_PATH = 'skills/'
24
-
25
- export interface SkillChange {
26
- action: 'add' | 'replace'
27
- name: string
28
- }
29
-
30
- export interface SkillStatusEntry {
31
- name: string
32
- state: 'installed' | 'missing' | 'outdated'
33
- }
34
-
35
- interface SkillsResult {
36
- entries: SkillStatusEntry[]
37
- projectRoot: string
38
- skillsDir: string
39
- }
40
-
41
- export interface SkillsOverview extends SkillsResult {}
42
-
43
- export interface SkillsInstallResult extends SkillsResult {
44
- appliedChanges: SkillChange[]
45
- }
46
-
47
- export interface SkillsOptions {
48
- progress?: SkillsProgressReporter
49
- skillsDir?: string
50
- }
51
-
52
- export type SkillsInstallPhase =
53
- | 'resolve-project-root'
54
- | 'fetch-remix-skills-metadata'
55
- | 'read-local-skills-cache'
56
- | 'compare-local-skills'
57
- | 'download-remix-skills-archive'
58
- | 'write-updated-skills'
59
-
60
- export type SkillsProgressReporter = StepProgressReporter<SkillsInstallPhase>
61
-
62
- interface GitHubTreeEntry {
63
- path: string
64
- sha: string
65
- type: 'blob' | 'tree'
66
- }
67
-
68
- interface GitHubTreeResponse {
69
- tree?: unknown
70
- truncated?: unknown
71
- }
72
-
73
- interface LocalSkillSnapshot {
74
- exists: boolean
75
- fileHashes: Map<string, string>
76
- isDirectory: boolean
77
- }
78
-
79
- interface RemoteSkill {
80
- files: RemoteSkillFile[]
81
- name: string
82
- }
83
-
84
- interface RemoteSkillContent {
85
- files: RemoteSkillContentFile[]
86
- name: string
87
- }
88
-
89
- interface RemoteSkillContentFile {
90
- content: string
91
- path: string
92
- }
93
-
94
- interface RemoteSkillFile {
95
- path: string
96
- sha: string
97
- }
98
-
99
- interface SkillsPlan extends SkillsResult {
100
- pendingChanges: SkillChange[]
101
- remoteSkills: RemoteSkill[]
102
- }
103
-
104
- type FetchImpl = typeof fetch
105
-
106
- export async function getSkillsOverview(
107
- cwd: string = process.cwd(),
108
- fetchImpl: FetchImpl = globalThis.fetch,
109
- options: SkillsOptions = {},
110
- ): Promise<SkillsOverview> {
111
- let plan = await loadSkillsPlan(cwd, fetchImpl, options)
112
- return {
113
- entries: plan.entries,
114
- projectRoot: plan.projectRoot,
115
- skillsDir: plan.skillsDir,
116
- }
117
- }
118
-
119
- export async function installRemixSkills(
120
- cwd: string = process.cwd(),
121
- fetchImpl: FetchImpl = globalThis.fetch,
122
- options: SkillsOptions = {},
123
- ): Promise<SkillsInstallResult> {
124
- let plan = await loadSkillsPlan(cwd, fetchImpl, options)
125
-
126
- if (plan.pendingChanges.length === 0) {
127
- options.progress?.skip('write-updated-skills', 'No changes.')
128
- return {
129
- appliedChanges: [],
130
- entries: plan.entries,
131
- projectRoot: plan.projectRoot,
132
- skillsDir: plan.skillsDir,
133
- }
134
- }
135
-
136
- let targetSkillNames = new Set(plan.pendingChanges.map((change) => change.name))
137
- let downloadedSkills = await runProgressStep(
138
- options.progress,
139
- 'download-remix-skills-archive',
140
- () => downloadRemoteSkillsArchive(fetchImpl, plan.remoteSkills, targetSkillNames),
141
- )
142
-
143
- await runProgressStep(options.progress, 'write-updated-skills', async () => {
144
- await fs.mkdir(plan.skillsDir, { recursive: true })
145
-
146
- for (let change of plan.pendingChanges) {
147
- let remoteSkill = downloadedSkills.get(change.name)
148
- if (remoteSkill == null) {
149
- throw remoteSkillDataMissing(change.name)
150
- }
151
-
152
- let skillDir = resolveRemoteSkillDir(plan.skillsDir, remoteSkill.name)
153
- await fs.rm(skillDir, { recursive: true, force: true })
154
- await writeRemoteSkill(skillDir, remoteSkill)
155
- }
156
-
157
- let manifest = await buildSkillsCacheManifest(plan.skillsDir, plan.remoteSkills)
158
- await writeSkillsCache(plan.skillsDir, manifest)
159
- })
160
-
161
- return {
162
- appliedChanges: plan.pendingChanges,
163
- entries: plan.entries,
164
- projectRoot: plan.projectRoot,
165
- skillsDir: plan.skillsDir,
166
- }
167
- }
168
-
169
- async function loadSkillsPlan(
170
- cwd: string,
171
- fetchImpl: FetchImpl,
172
- options: SkillsOptions = {},
173
- ): Promise<SkillsPlan> {
174
- if (typeof fetchImpl !== 'function') {
175
- throw fetchUnavailable()
176
- }
177
-
178
- let projectRoot = await runProgressStep(options.progress, 'resolve-project-root', () =>
179
- findProjectRoot(cwd),
180
- )
181
- let skillsDir = resolveSkillsDir(projectRoot, options.skillsDir)
182
- let remoteSkills = await runProgressStep(options.progress, 'fetch-remix-skills-metadata', () =>
183
- fetchRemoteSkills(fetchImpl),
184
- )
185
- let cacheManifest = await runProgressStep(options.progress, 'read-local-skills-cache', () =>
186
- readSkillsCache(skillsDir),
187
- )
188
- let entries = await runProgressStep(options.progress, 'compare-local-skills', async () =>
189
- Promise.all(
190
- remoteSkills.map(async (remoteSkill) => {
191
- let localSkill = await readLocalSkill(resolveRemoteSkillDir(skillsDir, remoteSkill.name))
192
- return {
193
- name: remoteSkill.name,
194
- state: getSkillState(remoteSkill, localSkill, cacheManifest),
195
- } satisfies SkillStatusEntry
196
- }),
197
- ),
198
- )
199
-
200
- entries.sort((left, right) => left.name.localeCompare(right.name))
201
-
202
- return {
203
- entries,
204
- pendingChanges: entries
205
- .filter((entry) => entry.state !== 'installed')
206
- .map((entry) => ({
207
- action: entry.state === 'missing' ? 'add' : 'replace',
208
- name: entry.name,
209
- })),
210
- projectRoot,
211
- remoteSkills,
212
- skillsDir,
213
- }
214
- }
215
-
216
- async function fetchRemoteSkills(fetchImpl: FetchImpl): Promise<RemoteSkill[]> {
217
- let treeResponse = await fetchGitHubJson<GitHubTreeResponse>(
218
- fetchImpl,
219
- REMIX_GITHUB_TREE_URL,
220
- 'Could not fetch Remix skills metadata from GitHub.',
221
- )
222
-
223
- if (treeResponse.truncated === true) {
224
- throw new Error('GitHub returned a truncated Remix skills listing.')
225
- }
226
-
227
- if (!Array.isArray(treeResponse.tree)) {
228
- throw new Error('Received an invalid Remix skills listing from GitHub.')
229
- }
230
-
231
- let groupedFiles = new Map<string, RemoteSkillFile[]>()
232
-
233
- for (let entry of treeResponse.tree) {
234
- if (!isGitHubTreeEntry(entry)) {
235
- continue
236
- }
237
-
238
- if (entry.type !== 'blob' || !entry.path.startsWith(REMIX_SKILLS_PATH)) {
239
- continue
240
- }
241
-
242
- let pathParts = entry.path.split('/')
243
- if (pathParts.length < 3) {
244
- continue
245
- }
246
-
247
- let skillName = pathParts[1]
248
- let filePath = pathParts.slice(2).join('/')
249
- if (skillName.length === 0) {
250
- continue
251
- }
252
- validateRemoteSkillPath(skillName, filePath, entry.path)
253
-
254
- let existing = groupedFiles.get(skillName) ?? []
255
- existing.push({
256
- path: filePath,
257
- sha: entry.sha,
258
- })
259
- groupedFiles.set(skillName, existing)
260
- }
261
-
262
- let remoteSkills = [...groupedFiles.entries()]
263
- .filter(([, files]) => files.some((file) => file.path === 'SKILL.md'))
264
- .sort(([left], [right]) => left.localeCompare(right))
265
- .map(([skillName, files]) => ({
266
- files: files.slice().sort((left, right) => left.path.localeCompare(right.path)),
267
- name: skillName,
268
- }))
269
-
270
- if (remoteSkills.length === 0) {
271
- throw new Error('Could not find any Remix skills on GitHub.')
272
- }
273
-
274
- return remoteSkills
275
- }
276
-
277
- async function downloadRemoteSkillsArchive(
278
- fetchImpl: FetchImpl,
279
- remoteSkills: RemoteSkill[],
280
- targetSkillNames: ReadonlySet<string>,
281
- ): Promise<Map<string, RemoteSkillContent>> {
282
- let compressedArchive = await fetchGitHubBytes(
283
- fetchImpl,
284
- REMIX_GITHUB_ARCHIVE_URL,
285
- 'Could not download the Remix skills archive from GitHub.',
286
- )
287
- let archive = gunzipSync(compressedArchive)
288
- let downloadedFiles = new Map<string, Map<string, string>>()
289
-
290
- await parseTar(archive, async (entry) => {
291
- if (entry.header.type !== 'file') {
292
- return
293
- }
294
-
295
- let archivePath = getArchiveSkillPath(entry.name)
296
- if (archivePath == null) {
297
- return
298
- }
299
-
300
- let [skillName, ...fileParts] = archivePath.split('/')
301
- if (skillName == null || skillName.length === 0 || fileParts.length === 0) {
302
- return
303
- }
304
-
305
- if (!targetSkillNames.has(skillName)) {
306
- return
307
- }
308
-
309
- let filePath = fileParts.join('/')
310
- validateRemoteSkillPath(skillName, filePath, archivePath)
311
-
312
- let files = downloadedFiles.get(skillName)
313
- if (files == null) {
314
- files = new Map<string, string>()
315
- downloadedFiles.set(skillName, files)
316
- }
317
-
318
- files.set(filePath, new TextDecoder().decode(await entry.bytes()))
319
- })
320
-
321
- let downloadedSkills = new Map<string, RemoteSkillContent>()
322
- for (let remoteSkill of remoteSkills) {
323
- if (!targetSkillNames.has(remoteSkill.name)) {
324
- continue
325
- }
326
-
327
- let files = downloadedFiles.get(remoteSkill.name)
328
- if (files == null || files.size !== remoteSkill.files.length) {
329
- throw new Error(
330
- `GitHub returned incomplete archive data for Remix skill: ${remoteSkill.name}.`,
331
- )
332
- }
333
-
334
- let validatedFiles = remoteSkill.files.map((file) => {
335
- let content = files.get(file.path)
336
- if (content == null) {
337
- throw new Error(
338
- `GitHub returned incomplete archive data for Remix skill file: ${remoteSkill.name}/${file.path}.`,
339
- )
340
- }
341
-
342
- let bytes = Buffer.from(content, 'utf8')
343
- if (computeGitBlobSha(bytes) !== file.sha) {
344
- throw new Error(
345
- `GitHub returned Remix skill content that did not match the metadata listing for ${remoteSkill.name}/${file.path}. Please try again.`,
346
- )
347
- }
348
-
349
- return {
350
- content,
351
- path: file.path,
352
- }
353
- })
354
-
355
- downloadedSkills.set(remoteSkill.name, {
356
- files: validatedFiles,
357
- name: remoteSkill.name,
358
- })
359
- }
360
-
361
- return downloadedSkills
362
- }
363
-
364
- async function fetchGitHubJson<T>(
365
- fetchImpl: FetchImpl,
366
- url: string,
367
- failureMessage: string,
368
- ): Promise<T> {
369
- let response = await fetchImpl(url, { headers: createGitHubHeaders() })
370
- if (!response.ok) {
371
- throw createGitHubRequestError(response, failureMessage)
372
- }
373
-
374
- return (await response.json()) as T
375
- }
376
-
377
- async function fetchGitHubBytes(
378
- fetchImpl: FetchImpl,
379
- url: string,
380
- failureMessage: string,
381
- ): Promise<Uint8Array> {
382
- let response = await fetchImpl(url, { headers: createGitHubHeaders() })
383
- if (!response.ok) {
384
- throw createGitHubRequestError(response, failureMessage)
385
- }
386
-
387
- return new Uint8Array(await response.arrayBuffer())
388
- }
389
-
390
- function createGitHubHeaders(): HeadersInit {
391
- let token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
392
-
393
- return {
394
- Accept: 'application/vnd.github+json',
395
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
396
- 'User-Agent': '@remix-run/cli',
397
- }
398
- }
399
-
400
- function createGitHubRequestError(response: Response, failureMessage: string): Error {
401
- if (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') {
402
- let resetText = getGitHubRateLimitResetText(response)
403
- return new Error(
404
- `${failureMessage} GitHub API rate limit exceeded.${resetText} Set GITHUB_TOKEN or GH_TOKEN to use a higher authenticated GitHub rate limit.`,
405
- )
406
- }
407
-
408
- return new Error(`${failureMessage} ${response.status} ${response.statusText}`.trim())
409
- }
410
-
411
- function getGitHubRateLimitResetText(response: Response): string {
412
- let reset = Number(response.headers.get('x-ratelimit-reset'))
413
- if (!Number.isFinite(reset)) {
414
- return ''
415
- }
416
-
417
- return ` The rate limit resets at ${new Date(reset * 1000).toISOString()}.`
418
- }
419
-
420
- function isGitHubTreeEntry(value: unknown): value is GitHubTreeEntry {
421
- return (
422
- typeof value === 'object' &&
423
- value != null &&
424
- typeof Reflect.get(value, 'path') === 'string' &&
425
- typeof Reflect.get(value, 'sha') === 'string' &&
426
- (Reflect.get(value, 'type') === 'blob' || Reflect.get(value, 'type') === 'tree')
427
- )
428
- }
429
-
430
- async function findProjectRoot(startDir: string): Promise<string> {
431
- let currentDir = path.resolve(startDir)
432
-
433
- while (true) {
434
- if (
435
- (await pathExists(path.join(currentDir, 'package.json'))) ||
436
- (await pathExists(path.join(currentDir, '.agents'))) ||
437
- (await pathExists(path.join(currentDir, '.git')))
438
- ) {
439
- return currentDir
440
- }
441
-
442
- let parentDir = path.dirname(currentDir)
443
- if (parentDir === currentDir) {
444
- break
445
- }
446
-
447
- currentDir = parentDir
448
- }
449
-
450
- throw projectRootNotFound(startDir)
451
- }
452
-
453
- function resolveSkillsDir(projectRoot: string, skillsDir: string | undefined): string {
454
- if (skillsDir == null || skillsDir.length === 0) {
455
- return path.join(projectRoot, '.agents', 'skills')
456
- }
457
-
458
- if (path.isAbsolute(skillsDir)) {
459
- return skillsDir
460
- }
461
-
462
- return path.resolve(projectRoot, skillsDir)
463
- }
464
-
465
- async function pathExists(filePath: string): Promise<boolean> {
466
- try {
467
- await fs.access(filePath)
468
- return true
469
- } catch (error) {
470
- let nodeError = error as NodeJS.ErrnoException
471
- if (nodeError.code === 'ENOENT') {
472
- return false
473
- }
474
-
475
- throw error
476
- }
477
- }
478
-
479
- async function readLocalSkill(skillDir: string): Promise<LocalSkillSnapshot> {
480
- try {
481
- let stats = await fs.stat(skillDir)
482
- if (!stats.isDirectory()) {
483
- return {
484
- exists: true,
485
- fileHashes: new Map(),
486
- isDirectory: false,
487
- }
488
- }
489
-
490
- return {
491
- exists: true,
492
- fileHashes: await readLocalFiles(skillDir),
493
- isDirectory: true,
494
- }
495
- } catch (error) {
496
- let nodeError = error as NodeJS.ErrnoException
497
- if (nodeError.code === 'ENOENT') {
498
- return {
499
- exists: false,
500
- fileHashes: new Map(),
501
- isDirectory: false,
502
- }
503
- }
504
-
505
- throw error
506
- }
507
- }
508
-
509
- async function readLocalFiles(
510
- rootDir: string,
511
- relativeDir: string = '',
512
- ): Promise<Map<string, string>> {
513
- let files = new Map<string, string>()
514
- let dirPath = relativeDir.length === 0 ? rootDir : path.join(rootDir, relativeDir)
515
- let entries = await fs.readdir(dirPath, { withFileTypes: true })
516
- entries.sort((left, right) => left.name.localeCompare(right.name))
517
-
518
- for (let entry of entries) {
519
- let relativePath = relativeDir.length === 0 ? entry.name : `${relativeDir}/${entry.name}`
520
- let entryPath = path.join(rootDir, relativePath)
521
-
522
- if (entry.isDirectory()) {
523
- let nestedFiles = await readLocalFiles(rootDir, relativePath)
524
- for (let [nestedPath, hash] of nestedFiles) {
525
- files.set(nestedPath, hash)
526
- }
527
- continue
528
- }
529
-
530
- if (entry.isFile()) {
531
- files.set(relativePath, computeLocalContentHash(await fs.readFile(entryPath)))
532
- continue
533
- }
534
-
535
- files.set(relativePath, '')
536
- }
537
-
538
- return files
539
- }
540
-
541
- function getSkillState(
542
- remoteSkill: RemoteSkill,
543
- localSkill: LocalSkillSnapshot,
544
- cacheManifest: SkillsCacheManifest | null,
545
- ): SkillStatusEntry['state'] {
546
- if (!localSkill.exists) {
547
- return 'missing'
548
- }
549
-
550
- if (!localSkill.isDirectory) {
551
- return 'outdated'
552
- }
553
-
554
- let cachedSkill = cacheManifest?.skills[remoteSkill.name]
555
- if (cachedSkill == null) {
556
- return 'outdated'
557
- }
558
-
559
- let cachedFiles = new Map(Object.entries(cachedSkill.files))
560
- if (
561
- localSkill.fileHashes.size !== remoteSkill.files.length ||
562
- cachedFiles.size !== remoteSkill.files.length
563
- ) {
564
- return 'outdated'
565
- }
566
-
567
- for (let file of remoteSkill.files) {
568
- let localHash = localSkill.fileHashes.get(file.path)
569
- let cachedFile = cachedFiles.get(file.path)
570
-
571
- if (
572
- localHash == null ||
573
- cachedFile == null ||
574
- cachedFile.localHash !== localHash ||
575
- cachedFile.remoteSha !== file.sha
576
- ) {
577
- return 'outdated'
578
- }
579
- }
580
-
581
- return 'installed'
582
- }
583
-
584
- async function buildSkillsCacheManifest(
585
- skillsDir: string,
586
- remoteSkills: RemoteSkill[],
587
- ): Promise<SkillsCacheManifest> {
588
- let cachedSkills: Record<string, SkillsCacheSkillEntry> = {}
589
-
590
- for (let remoteSkill of remoteSkills) {
591
- let localSkill = await readLocalSkill(resolveRemoteSkillDir(skillsDir, remoteSkill.name))
592
- if (!localSkill.exists || !localSkill.isDirectory) {
593
- throw remoteSkillDataMissing(remoteSkill.name)
594
- }
595
-
596
- let cachedFiles: SkillsCacheSkillEntry['files'] = {}
597
- for (let file of remoteSkill.files) {
598
- let localHash = localSkill.fileHashes.get(file.path)
599
- if (localHash == null) {
600
- throw new Error(
601
- `Installed Remix skill is missing file data for ${remoteSkill.name}/${file.path}.`,
602
- )
603
- }
604
-
605
- cachedFiles[file.path] = {
606
- localHash,
607
- remoteSha: file.sha,
608
- }
609
- }
610
-
611
- cachedSkills[remoteSkill.name] = {
612
- files: cachedFiles,
613
- }
614
- }
615
-
616
- return createSkillsCacheManifest(skillsDir, cachedSkills)
617
- }
618
-
619
- async function writeRemoteSkill(skillDir: string, remoteSkill: RemoteSkillContent): Promise<void> {
620
- await fs.mkdir(skillDir, { recursive: true })
621
-
622
- for (let file of remoteSkill.files) {
623
- let filePath = resolveRemoteSkillFilePath(skillDir, file.path)
624
- await fs.mkdir(path.dirname(filePath), { recursive: true })
625
- await fs.writeFile(filePath, file.content, 'utf8')
626
- }
627
- }
628
-
629
- function resolveRemoteSkillDir(skillsDir: string, skillName: string): string {
630
- validateRemoteSkillName(skillName, skillName)
631
- return resolveRemoteSkillPath(skillsDir, skillName)
632
- }
633
-
634
- function resolveRemoteSkillFilePath(skillDir: string, filePath: string): string {
635
- validateRemoteSkillFilePath(filePath, filePath)
636
- return resolveRemoteSkillPath(skillDir, filePath)
637
- }
638
-
639
- function resolveRemoteSkillPath(rootDir: string, relativePath: string): string {
640
- try {
641
- return resolveContainedPath(rootDir, relativePath)
642
- } catch (error) {
643
- if (error instanceof Error && error.message.includes('escapes the allowed root')) {
644
- throw new Error(`GitHub returned an invalid Remix skill path: ${relativePath}`)
645
- }
646
-
647
- throw error
648
- }
649
- }
650
-
651
- function validateRemoteSkillPath(skillName: string, filePath: string, remotePath: string): void {
652
- validateRemoteSkillName(skillName, remotePath)
653
- validateRemoteSkillFilePath(filePath, remotePath)
654
- }
655
-
656
- function validateRemoteSkillName(skillName: string, remotePath: string): void {
657
- if (isSafeRemoteSkillPath(skillName, { allowNested: false })) {
658
- return
659
- }
660
-
661
- throw new Error(`GitHub returned an invalid Remix skill path: ${remotePath}`)
662
- }
663
-
664
- function validateRemoteSkillFilePath(filePath: string, remotePath: string): void {
665
- if (isSafeRemoteSkillPath(filePath, { allowNested: true })) {
666
- return
667
- }
668
-
669
- throw new Error(`GitHub returned an invalid Remix skill path: ${remotePath}`)
670
- }
671
-
672
- function isSafeRemoteSkillPath(remotePath: string, options: { allowNested: boolean }): boolean {
673
- if (
674
- remotePath.length === 0 ||
675
- remotePath.includes('\\') ||
676
- path.posix.isAbsolute(remotePath) ||
677
- path.win32.isAbsolute(remotePath)
678
- ) {
679
- return false
680
- }
681
-
682
- let pathParts = remotePath.split('/')
683
- if (!options.allowNested && pathParts.length !== 1) {
684
- return false
685
- }
686
-
687
- return pathParts.every((pathPart) => pathPart.length > 0 && pathPart !== '.' && pathPart !== '..')
688
- }
689
-
690
- function getArchiveSkillPath(entryName: string): string | null {
691
- let marker = `/${REMIX_SKILLS_PATH}`
692
- let markerIndex = entryName.indexOf(marker)
693
- if (markerIndex === -1) {
694
- return null
695
- }
696
-
697
- return entryName.slice(markerIndex + marker.length)
698
- }
699
-
700
- function computeLocalContentHash(bytes: Uint8Array): string {
701
- return crypto.createHash('sha256').update(bytes).digest('hex')
702
- }
703
-
704
- function computeGitBlobSha(bytes: Uint8Array): string {
705
- return crypto.createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex')
706
- }