pluribus-context 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.
@@ -0,0 +1,690 @@
1
+ /**
2
+ * Import resolver for pluribus.md files.
3
+ *
4
+ * Supports directives like:
5
+ * # @import ./shared/base-context.md
6
+ *
7
+ * Local imports remain synchronous and deterministic by default. Remote imports are
8
+ * available only through resolveImportsAsync(..., { allowRemote: true }) so normal
9
+ * sync runs never perform silent network access.
10
+ */
11
+
12
+ import * as childProcess from 'child_process'
13
+ import * as crypto from 'crypto'
14
+ import * as fs from 'fs'
15
+ import * as path from 'path'
16
+
17
+ const IMPORT_DIRECTIVE_RE = /^#\s+@import\s+(.+?)\s*$/
18
+ const REMOTE_IMPORT_RE = /^(?:https?:\/\/|github:)/i
19
+ const HTTPS_IMPORT_RE = /^https:\/\//i
20
+ const HTTP_IMPORT_RE = /^http:\/\//i
21
+ const GITHUB_IMPORT_RE = /^github:/i
22
+ const DEFAULT_MAX_DEPTH = 5
23
+ const DEFAULT_TIMEOUT_MS = 5000
24
+ const DEFAULT_MAX_REMOTE_BYTES = 256 * 1024
25
+ const DEFAULT_MAX_MERGED_REMOTE_BYTES = 1024 * 1024
26
+ const DEFAULT_MAX_REDIRECTS = 3
27
+
28
+ /**
29
+ * @typedef {object} ResolvedImport
30
+ * @property {string} from Absolute local path or remote resource id of the importing file
31
+ * @property {string} to Absolute local path or remote resource id of the imported file
32
+ * @property {string} spec Raw import spec from the directive
33
+ */
34
+
35
+ /**
36
+ * @typedef {object} ResolveImportsOptions
37
+ * @property {string} [rootDir] Directory local imports must stay inside. Defaults to source file directory.
38
+ * @property {number} [maxDepth] Maximum recursive import depth. Defaults to 5.
39
+ * @property {boolean} [allowRemote] Enable remote imports. Sync resolver defaults to false.
40
+ * @property {(url: string, init?: object) => Promise<Response>} [fetchImpl] Test seam / custom fetch implementation.
41
+ * @property {number} [timeoutMs] Per-request timeout for remote imports. Defaults to 5000.
42
+ * @property {number} [maxRemoteBytes] Maximum bytes per remote document. Defaults to 256 KiB.
43
+ * @property {number} [maxMergedRemoteBytes] Maximum total remote bytes in one resolution. Defaults to 1 MiB.
44
+ * @property {number} [maxRedirects] Maximum HTTPS redirects. Defaults to 3.
45
+ * @property {string} [lockfilePath] Project remote import lockfile path.
46
+ * @property {string} [cacheDir] Directory for digest-addressed remote import cache entries.
47
+ * @property {boolean} [updateLockfile] Write fetched remote imports into lockfile/cache.
48
+ * @property {(file: string, args: string[], options: object, callback: Function) => void} [execFileImpl] Test seam for GitHub CLI token lookup.
49
+ */
50
+
51
+ /**
52
+ * Resolve and expand local # @import directives synchronously.
53
+ * Imported content is emitted before the importing file's own content so later
54
+ * local sections win with the existing parser's duplicate-section behavior.
55
+ *
56
+ * Remote imports intentionally require resolveImportsAsync with allowRemote=true.
57
+ *
58
+ * @param {string} sourcePath Path to the root pluribus.md file.
59
+ * @param {ResolveImportsOptions} [options]
60
+ * @returns {{ content: string, imports: ResolvedImport[] }}
61
+ */
62
+ export function resolveImports(sourcePath, options = {}) {
63
+ const ctx = createContext(sourcePath, options, false)
64
+ const content = resolveLocalFileSync(createLocalResource(ctx.absoluteSource), ctx)
65
+ return { content, imports: ctx.imports }
66
+ }
67
+
68
+ /**
69
+ * Resolve and expand # @import directives asynchronously.
70
+ * Local behavior matches resolveImports. Remote github:/https:// imports are only
71
+ * resolved when allowRemote is true, making network use explicit.
72
+ *
73
+ * @param {string} sourcePath Path to the root pluribus.md file.
74
+ * @param {ResolveImportsOptions} [options]
75
+ * @returns {Promise<{ content: string, imports: ResolvedImport[] }>}
76
+ */
77
+ export async function resolveImportsAsync(sourcePath, options = {}) {
78
+ const ctx = createContext(sourcePath, options, Boolean(options.allowRemote))
79
+ const content = await resolveResource(createLocalResource(ctx.absoluteSource), ctx)
80
+ return { content, imports: ctx.imports }
81
+ }
82
+
83
+ /**
84
+ * @param {string} sourcePath
85
+ * @param {ResolveImportsOptions} options
86
+ * @param {boolean} asyncMode
87
+ */
88
+ function createContext(sourcePath, options, asyncMode) {
89
+ const absoluteSource = path.resolve(sourcePath)
90
+ return {
91
+ absoluteSource,
92
+ rootDir: path.resolve(options.rootDir || path.dirname(absoluteSource)),
93
+ maxDepth: Number.isInteger(options.maxDepth) ? options.maxDepth : DEFAULT_MAX_DEPTH,
94
+ allowRemote: Boolean(options.allowRemote),
95
+ asyncMode,
96
+ fetchImpl: options.fetchImpl || globalThis.fetch,
97
+ timeoutMs: Number.isInteger(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS,
98
+ maxRemoteBytes: Number.isInteger(options.maxRemoteBytes) ? options.maxRemoteBytes : DEFAULT_MAX_REMOTE_BYTES,
99
+ maxMergedRemoteBytes: Number.isInteger(options.maxMergedRemoteBytes) ? options.maxMergedRemoteBytes : DEFAULT_MAX_MERGED_REMOTE_BYTES,
100
+ maxRedirects: Number.isInteger(options.maxRedirects) ? options.maxRedirects : DEFAULT_MAX_REDIRECTS,
101
+ lockfilePath: options.lockfilePath ? path.resolve(options.lockfilePath) : null,
102
+ cacheDir: options.cacheDir ? path.resolve(options.cacheDir) : null,
103
+ updateLockfile: Boolean(options.updateLockfile),
104
+ depth: 0,
105
+ stack: [],
106
+ imports: [],
107
+ remoteState: { bytes: 0 },
108
+ githubAuth: { resolved: false, token: null },
109
+ execFileImpl: options.execFileImpl || childProcess.execFile,
110
+ lockfile: options.lockfilePath ? readRemoteLockfile(path.resolve(options.lockfilePath)) : null,
111
+ }
112
+ }
113
+
114
+ function createLocalResource(filePath) {
115
+ const absolutePath = path.resolve(filePath)
116
+ return {
117
+ kind: 'local',
118
+ id: absolutePath,
119
+ display: formatPath(absolutePath),
120
+ dir: path.dirname(absolutePath),
121
+ }
122
+ }
123
+
124
+ /**
125
+ * @param {{ kind: string, id: string, display: string, dir?: string }} resource
126
+ * @param {ReturnType<typeof createContext>} ctx
127
+ * @returns {string}
128
+ */
129
+ function resolveLocalFileSync(resource, ctx) {
130
+ if (resource.kind !== 'local') {
131
+ throw new Error(`Remote imports require resolveImportsAsync(..., { allowRemote: true }): ${resource.display}`)
132
+ }
133
+
134
+ const rawContent = readLocalFile(resource, ctx)
135
+ return resolveContentSync(rawContent, resource, ctx)
136
+ }
137
+
138
+ /**
139
+ * @param {{ kind: string, id: string, display: string, dir?: string, github?: object }} resource
140
+ * @param {ReturnType<typeof createContext>} ctx
141
+ * @returns {Promise<string>}
142
+ */
143
+ async function resolveResource(resource, ctx) {
144
+ let rawContent
145
+ if (resource.kind === 'local') {
146
+ rawContent = readLocalFile(resource, ctx)
147
+ } else {
148
+ rawContent = await readRemoteResource(resource, ctx)
149
+ }
150
+
151
+ return resolveContentAsync(rawContent, resource, ctx)
152
+ }
153
+
154
+ function readLocalFile(resource, ctx) {
155
+ assertInsideRoot(resource.id, ctx.rootDir, `Import escapes project root: ${formatPath(resource.id)} is outside ${formatPath(ctx.rootDir)}`)
156
+
157
+ if (ctx.stack.includes(resource.id)) {
158
+ const cycle = [...ctx.stack, resource.id].map(formatImportId).join(' -> ')
159
+ throw new Error(`Import cycle detected: ${cycle}`)
160
+ }
161
+
162
+ if (!fs.existsSync(resource.id)) {
163
+ throw new Error(`Imported file not found: ${formatPath(resource.id)}`)
164
+ }
165
+
166
+ try {
167
+ return fs.readFileSync(resource.id, 'utf8')
168
+ } catch (err) {
169
+ throw new Error(`Could not read imported file ${formatPath(resource.id)}: ${err.message}`)
170
+ }
171
+ }
172
+
173
+ function resolveContentSync(rawContent, resource, ctx) {
174
+ const nextStack = [...ctx.stack, resource.id]
175
+ const importedChunks = []
176
+ const localLines = []
177
+ const cleanedContent = rawContent.replace(/^\uFEFF/, '')
178
+
179
+ for (const line of cleanedContent.split(/\r?\n/)) {
180
+ const match = line.match(IMPORT_DIRECTIVE_RE)
181
+ if (!match) {
182
+ localLines.push(line)
183
+ continue
184
+ }
185
+
186
+ const spec = normalizeImportSpec(match[1])
187
+ if (REMOTE_IMPORT_RE.test(spec)) {
188
+ throw new Error(`Remote imports are not enabled: ${redactImportSpec(spec)}. Use resolveImportsAsync(..., { allowRemote: true }) or pluribus sync --update-imports.`)
189
+ }
190
+
191
+ const nextDepth = ctx.depth + 1
192
+ assertDepth(nextDepth, ctx.maxDepth, spec, resource.display)
193
+
194
+ const target = resolveLocalImport(spec, resource)
195
+ ctx.imports.push({ from: resource.id, to: target.id, spec })
196
+ importedChunks.push(resolveLocalFileSync(target, {
197
+ ...ctx,
198
+ depth: nextDepth,
199
+ stack: nextStack,
200
+ }))
201
+ }
202
+
203
+ return joinChunks(importedChunks, localLines)
204
+ }
205
+
206
+ async function resolveContentAsync(rawContent, resource, ctx) {
207
+ if (ctx.stack.includes(resource.id)) {
208
+ const cycle = [...ctx.stack, resource.id].map(formatImportId).join(' -> ')
209
+ throw new Error(`Import cycle detected: ${cycle}`)
210
+ }
211
+
212
+ const nextStack = [...ctx.stack, resource.id]
213
+ const importedChunks = []
214
+ const localLines = []
215
+ const cleanedContent = rawContent.replace(/^\uFEFF/, '')
216
+
217
+ for (const line of cleanedContent.split(/\r?\n/)) {
218
+ const match = line.match(IMPORT_DIRECTIVE_RE)
219
+ if (!match) {
220
+ localLines.push(line)
221
+ continue
222
+ }
223
+
224
+ const spec = normalizeImportSpec(match[1])
225
+ const nextDepth = ctx.depth + 1
226
+ assertDepth(nextDepth, ctx.maxDepth, spec, resource.display)
227
+
228
+ const target = resolveImportSpec(spec, resource, ctx)
229
+ ctx.imports.push({ from: resource.id, to: target.id, spec })
230
+ importedChunks.push(await resolveResource(target, {
231
+ ...ctx,
232
+ depth: nextDepth,
233
+ stack: nextStack,
234
+ }))
235
+ }
236
+
237
+ return joinChunks(importedChunks, localLines)
238
+ }
239
+
240
+ function resolveImportSpec(spec, importer, ctx) {
241
+ if (HTTP_IMPORT_RE.test(spec)) {
242
+ throw new Error(`Remote imports require https://, not http://: ${redactImportSpec(spec)}`)
243
+ }
244
+
245
+ if (GITHUB_IMPORT_RE.test(spec)) {
246
+ return createGithubResource(spec)
247
+ }
248
+
249
+ if (HTTPS_IMPORT_RE.test(spec)) {
250
+ return createHttpsResource(spec)
251
+ }
252
+
253
+ if (importer.kind === 'github') {
254
+ return createGithubRelativeResource(spec, importer)
255
+ }
256
+
257
+ if (importer.kind === 'https') {
258
+ throw new Error(`Relative imports from HTTPS documents are not supported in the remote MVP: ${spec}`)
259
+ }
260
+
261
+ return resolveLocalImport(spec, importer)
262
+ }
263
+
264
+ function resolveLocalImport(spec, importer) {
265
+ const targetPath = path.resolve(importer.dir, spec)
266
+ return createLocalResource(targetPath)
267
+ }
268
+
269
+ function createHttpsResource(spec) {
270
+ let url
271
+ try {
272
+ url = new URL(spec)
273
+ } catch {
274
+ throw new Error(`Invalid HTTPS import URL: ${redactImportSpec(spec)}`)
275
+ }
276
+
277
+ if (url.protocol !== 'https:') {
278
+ throw new Error(`Remote imports require https://, not ${url.protocol}: ${redactImportSpec(spec)}`)
279
+ }
280
+
281
+ if (url.username || url.password) {
282
+ throw new Error('Credential-bearing HTTPS import URLs are not supported')
283
+ }
284
+
285
+ return {
286
+ kind: 'https',
287
+ id: url.toString(),
288
+ display: url.toString(),
289
+ url: url.toString(),
290
+ }
291
+ }
292
+
293
+ function createGithubResource(spec) {
294
+ const parsed = parseGithubSpec(spec)
295
+ return githubResourceFromParts(parsed.owner, parsed.repo, parsed.filePath, parsed.ref)
296
+ }
297
+
298
+ function createGithubRelativeResource(spec, importer) {
299
+ const normalized = path.posix.normalize(path.posix.join(path.posix.dirname(importer.github.filePath), spec))
300
+ if (normalized.startsWith('../') || normalized === '..') {
301
+ throw new Error(`GitHub relative import escapes repository path scope: ${spec}`)
302
+ }
303
+ return githubResourceFromParts(importer.github.owner, importer.github.repo, normalized, importer.github.ref)
304
+ }
305
+
306
+ function githubResourceFromParts(owner, repo, filePath, ref) {
307
+ const rawUrl = buildGithubRawUrl(owner, repo, ref, filePath)
308
+ const display = `github:${owner}/${repo}/${filePath}${ref ? `@${ref}` : ''}`
309
+ return {
310
+ kind: 'github',
311
+ id: display,
312
+ display,
313
+ url: rawUrl,
314
+ github: { owner, repo, filePath, ref },
315
+ }
316
+ }
317
+
318
+ function parseGithubSpec(spec) {
319
+ const body = spec.replace(/^github:/i, '').trim()
320
+ const firstSlash = body.indexOf('/')
321
+ const secondSlash = firstSlash >= 0 ? body.indexOf('/', firstSlash + 1) : -1
322
+ if (firstSlash <= 0 || secondSlash <= firstSlash + 1 || secondSlash === body.length - 1) {
323
+ throw new Error(`Invalid GitHub import spec: ${redactImportSpec(spec)}`)
324
+ }
325
+
326
+ const owner = body.slice(0, firstSlash)
327
+ const repo = body.slice(firstSlash + 1, secondSlash)
328
+ let filePath = body.slice(secondSlash + 1)
329
+ let ref = null
330
+ const atIndex = filePath.lastIndexOf('@')
331
+ if (atIndex > 0 && atIndex < filePath.length - 1) {
332
+ ref = filePath.slice(atIndex + 1)
333
+ filePath = filePath.slice(0, atIndex)
334
+ }
335
+
336
+ if (!owner || !repo || !filePath || filePath.startsWith('/')) {
337
+ throw new Error(`Invalid GitHub import spec: ${redactImportSpec(spec)}`)
338
+ }
339
+
340
+ const normalizedPath = path.posix.normalize(filePath)
341
+ if (normalizedPath.startsWith('../') || normalizedPath === '..') {
342
+ throw new Error(`GitHub import path escapes repository: ${redactImportSpec(spec)}`)
343
+ }
344
+
345
+ return { owner, repo, filePath: normalizedPath, ref }
346
+ }
347
+
348
+ function buildGithubRawUrl(owner, repo, ref, filePath) {
349
+ const safe = [owner, repo, ref || 'HEAD', ...filePath.split('/')]
350
+ .map((part) => encodeURIComponent(part))
351
+ .join('/')
352
+ return `https://raw.githubusercontent.com/${safe}`
353
+ }
354
+
355
+ async function readRemoteResource(resource, ctx) {
356
+ if (!ctx.allowRemote) {
357
+ const cachedText = readLockedRemoteText(resource, ctx)
358
+ if (cachedText !== null) return cachedText
359
+
360
+ if (ctx.lockfilePath) {
361
+ throw remoteError('REMOTE_IMPORT_UNLOCKED', `Remote import is not locked or cached: ${resource.display}. Run pluribus sync --update-imports to refresh pluribus.lock.json.`)
362
+ }
363
+
364
+ throw new Error(`Remote imports are not enabled: ${resource.display}`)
365
+ }
366
+
367
+ if (typeof ctx.fetchImpl !== 'function') {
368
+ throw new Error('Remote imports require a fetch implementation')
369
+ }
370
+
371
+ const fetched = await fetchRemoteText(resource, ctx)
372
+ writeLockedRemoteText(resource, fetched, ctx)
373
+ return fetched.text
374
+ }
375
+
376
+ async function fetchRemoteText(resource, ctx) {
377
+ let url = resource.url
378
+ const githubToken = resource.kind === 'github' ? await getGithubAuthToken(ctx) : null
379
+ for (let redirectCount = 0; redirectCount <= ctx.maxRedirects; redirectCount++) {
380
+ const controller = new AbortController()
381
+ const timeout = setTimeout(() => controller.abort(), ctx.timeoutMs)
382
+ let response
383
+ try {
384
+ response = await ctx.fetchImpl(url, {
385
+ redirect: 'manual',
386
+ signal: controller.signal,
387
+ headers: remoteFetchHeaders(resource, url, githubToken),
388
+ })
389
+ } catch (err) {
390
+ if (err?.name === 'AbortError') {
391
+ throw remoteError('REMOTE_IMPORT_TIMEOUT', `Remote import timed out after ${ctx.timeoutMs}ms: ${resource.display}`)
392
+ }
393
+ throw new Error(`Could not fetch remote import ${resource.display}: ${redactSecrets(err.message || String(err), ctx)}`)
394
+ } finally {
395
+ clearTimeout(timeout)
396
+ }
397
+
398
+ if (isRedirect(response.status)) {
399
+ const location = response.headers.get('location')
400
+ if (!location) {
401
+ throw remoteError('REMOTE_IMPORT_UNSAFE_REDIRECT', `Remote import redirect missing Location: ${resource.display}`)
402
+ }
403
+ const nextUrl = new URL(location, url)
404
+ if (nextUrl.protocol !== 'https:') {
405
+ throw remoteError('REMOTE_IMPORT_UNSAFE_REDIRECT', `Remote import redirected outside https://: ${resource.display}`)
406
+ }
407
+ if (nextUrl.username || nextUrl.password) {
408
+ throw remoteError('REMOTE_IMPORT_UNSAFE_REDIRECT', `Remote import redirected to a credential-bearing URL: ${resource.display}`)
409
+ }
410
+ url = nextUrl.toString()
411
+ continue
412
+ }
413
+
414
+ if (!response.ok) {
415
+ throw new Error(`Remote import failed (${response.status}) for ${resource.display}`)
416
+ }
417
+
418
+ const lengthHeader = response.headers.get('content-length')
419
+ if (lengthHeader && Number(lengthHeader) > ctx.maxRemoteBytes) {
420
+ throw remoteError('REMOTE_IMPORT_TOO_LARGE', `Remote import exceeds ${ctx.maxRemoteBytes} bytes: ${resource.display}`)
421
+ }
422
+
423
+ const contentType = response.headers.get('content-type') || ''
424
+ if (!isSupportedTextContentType(contentType)) {
425
+ throw remoteError('REMOTE_IMPORT_UNSUPPORTED_CONTENT', `Remote import is not UTF-8 Markdown/text: ${resource.display}`)
426
+ }
427
+
428
+ const bytes = new Uint8Array(await response.arrayBuffer())
429
+ if (bytes.byteLength > ctx.maxRemoteBytes) {
430
+ throw remoteError('REMOTE_IMPORT_TOO_LARGE', `Remote import exceeds ${ctx.maxRemoteBytes} bytes: ${resource.display}`)
431
+ }
432
+
433
+ ctx.remoteState.bytes += bytes.byteLength
434
+ if (ctx.remoteState.bytes > ctx.maxMergedRemoteBytes) {
435
+ throw remoteError('REMOTE_IMPORT_TOO_LARGE', `Merged remote imports exceed ${ctx.maxMergedRemoteBytes} bytes`)
436
+ }
437
+
438
+ try {
439
+ return {
440
+ text: new TextDecoder('utf-8', { fatal: true }).decode(bytes),
441
+ bytes,
442
+ byteLength: bytes.byteLength,
443
+ contentType,
444
+ }
445
+ } catch {
446
+ throw remoteError('REMOTE_IMPORT_UNSUPPORTED_CONTENT', `Remote import is not valid UTF-8: ${resource.display}`)
447
+ }
448
+ }
449
+
450
+ throw remoteError('REMOTE_IMPORT_UNSAFE_REDIRECT', `Remote import exceeded ${ctx.maxRedirects} redirects: ${resource.display}`)
451
+ }
452
+
453
+ function remoteFetchHeaders(resource, url, githubToken) {
454
+ const headers = {
455
+ Accept: 'text/markdown,text/plain;q=0.9,*/*;q=0.1',
456
+ 'User-Agent': 'pluribus-remote-imports',
457
+ }
458
+
459
+ if (resource.kind === 'github' && githubToken && isGithubRawUrl(url)) {
460
+ headers.Authorization = `Bearer ${githubToken}`
461
+ }
462
+
463
+ return headers
464
+ }
465
+
466
+ async function getGithubAuthToken(ctx) {
467
+ if (ctx.githubAuth.resolved) return ctx.githubAuth.token
468
+
469
+ ctx.githubAuth.resolved = true
470
+ ctx.githubAuth.token = getEnvGithubToken() || await getGithubCliToken(ctx)
471
+ return ctx.githubAuth.token
472
+ }
473
+
474
+ function getEnvGithubToken() {
475
+ return firstNonEmpty(process.env.GH_TOKEN, process.env.GITHUB_TOKEN)
476
+ }
477
+
478
+ async function getGithubCliToken(ctx) {
479
+ if (typeof ctx.execFileImpl !== 'function') return null
480
+
481
+ return new Promise((resolve) => {
482
+ try {
483
+ ctx.execFileImpl('gh', ['auth', 'token'], {
484
+ encoding: 'utf8',
485
+ timeout: 5000,
486
+ windowsHide: true,
487
+ }, (err, stdout) => {
488
+ if (err) {
489
+ resolve(null)
490
+ return
491
+ }
492
+ resolve(firstNonEmpty(stdout))
493
+ })
494
+ } catch {
495
+ resolve(null)
496
+ }
497
+ })
498
+ }
499
+
500
+ function firstNonEmpty(...values) {
501
+ for (const value of values) {
502
+ if (typeof value !== 'string') continue
503
+ const trimmed = value.trim()
504
+ if (trimmed) return trimmed
505
+ }
506
+ return null
507
+ }
508
+
509
+ function isGithubRawUrl(candidate) {
510
+ try {
511
+ const url = new URL(candidate)
512
+ return url.protocol === 'https:' && url.hostname === 'raw.githubusercontent.com'
513
+ } catch {
514
+ return false
515
+ }
516
+ }
517
+
518
+ function readRemoteLockfile(lockfilePath) {
519
+ if (!fs.existsSync(lockfilePath)) {
520
+ return { version: 1, remoteImports: {} }
521
+ }
522
+
523
+ try {
524
+ const parsed = JSON.parse(fs.readFileSync(lockfilePath, 'utf8'))
525
+ if (parsed?.version !== 1 || typeof parsed.remoteImports !== 'object' || parsed.remoteImports === null) {
526
+ throw new Error('expected version 1 with remoteImports object')
527
+ }
528
+ return parsed
529
+ } catch (err) {
530
+ throw new Error(`Could not read remote import lockfile ${formatPath(lockfilePath)}: ${err.message}`)
531
+ }
532
+ }
533
+
534
+ function readLockedRemoteText(resource, ctx) {
535
+ if (!ctx.lockfilePath || !ctx.cacheDir || !ctx.lockfile) return null
536
+
537
+ const entry = ctx.lockfile.remoteImports[resource.id]
538
+ if (!entry) return null
539
+
540
+ const digestHex = parseSha256Digest(entry.digest)
541
+ if (!digestHex) {
542
+ throw remoteError('REMOTE_IMPORT_DIGEST_MISMATCH', `Remote import lock entry has invalid digest for ${resource.display}`)
543
+ }
544
+
545
+ const cachePath = path.join(ctx.cacheDir, `${digestHex}.md`)
546
+ if (!fs.existsSync(cachePath)) return null
547
+
548
+ const bytes = fs.readFileSync(cachePath)
549
+ const actualDigest = sha256Digest(bytes)
550
+ if (actualDigest !== entry.digest) {
551
+ throw remoteError('REMOTE_IMPORT_DIGEST_MISMATCH', `Remote import cache digest mismatch for ${resource.display}`)
552
+ }
553
+
554
+ try {
555
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
556
+ } catch {
557
+ throw remoteError('REMOTE_IMPORT_UNSUPPORTED_CONTENT', `Cached remote import is not valid UTF-8: ${resource.display}`)
558
+ }
559
+ }
560
+
561
+ function writeLockedRemoteText(resource, fetched, ctx) {
562
+ if (!ctx.updateLockfile || !ctx.lockfilePath || !ctx.cacheDir || !ctx.lockfile) return
563
+
564
+ const digest = sha256Digest(fetched.bytes)
565
+ fs.mkdirSync(ctx.cacheDir, { recursive: true })
566
+ fs.writeFileSync(path.join(ctx.cacheDir, `${parseSha256Digest(digest)}.md`), fetched.bytes)
567
+
568
+ ctx.lockfile.remoteImports[resource.id] = {
569
+ spec: resource.display,
570
+ url: resource.url,
571
+ digest,
572
+ bytes: fetched.byteLength,
573
+ contentType: fetched.contentType || null,
574
+ fetchedAt: new Date().toISOString(),
575
+ }
576
+ writeRemoteLockfile(ctx.lockfilePath, ctx.lockfile)
577
+ }
578
+
579
+ function writeRemoteLockfile(lockfilePath, lockfile) {
580
+ const sortedEntries = Object.fromEntries(
581
+ Object.entries(lockfile.remoteImports).sort(([a], [b]) => a.localeCompare(b))
582
+ )
583
+ const normalized = {
584
+ version: 1,
585
+ remoteImports: sortedEntries,
586
+ }
587
+
588
+ fs.mkdirSync(path.dirname(lockfilePath), { recursive: true })
589
+ fs.writeFileSync(lockfilePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
590
+ }
591
+
592
+ function sha256Digest(bytes) {
593
+ return `sha256-${crypto.createHash('sha256').update(bytes).digest('hex')}`
594
+ }
595
+
596
+ function parseSha256Digest(digest) {
597
+ if (typeof digest !== 'string') return null
598
+ const match = digest.match(/^sha256-([a-f0-9]{64})$/)
599
+ return match ? match[1] : null
600
+ }
601
+
602
+ function isRedirect(status) {
603
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308
604
+ }
605
+
606
+ function isSupportedTextContentType(contentType) {
607
+ if (!contentType) return true
608
+ const lower = contentType.toLowerCase()
609
+ return lower.includes('text/') || lower.includes('markdown') || lower.includes('application/octet-stream')
610
+ }
611
+
612
+ function remoteError(code, message) {
613
+ const err = new Error(message)
614
+ err.code = code
615
+ return err
616
+ }
617
+
618
+ function assertDepth(nextDepth, maxDepth, spec, importerDisplay) {
619
+ if (nextDepth > maxDepth) {
620
+ throw new Error(`Maximum import depth exceeded (${maxDepth}) while importing ${redactImportSpec(spec)} from ${importerDisplay}`)
621
+ }
622
+ }
623
+
624
+ function joinChunks(importedChunks, localLines) {
625
+ return [...importedChunks, localLines.join('\n')]
626
+ .filter((chunk) => chunk.length > 0)
627
+ .join('\n\n')
628
+ }
629
+
630
+ /**
631
+ * @param {string} spec
632
+ * @returns {string}
633
+ */
634
+ function normalizeImportSpec(spec) {
635
+ const trimmed = spec.trim()
636
+ if (
637
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
638
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
639
+ ) {
640
+ return trimmed.slice(1, -1).trim()
641
+ }
642
+ return trimmed
643
+ }
644
+
645
+ /**
646
+ * @param {string} candidate
647
+ * @param {string} rootDir
648
+ * @param {string} message
649
+ */
650
+ function assertInsideRoot(candidate, rootDir, message) {
651
+ const relative = path.relative(rootDir, candidate)
652
+ const isInside = relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
653
+ if (!isInside) {
654
+ throw new Error(message)
655
+ }
656
+ }
657
+
658
+ function redactImportSpec(spec) {
659
+ if (!/^https?:\/\//i.test(spec)) return spec
660
+ try {
661
+ const url = new URL(spec)
662
+ if (url.username) url.username = 'REDACTED'
663
+ if (url.password) url.password = 'REDACTED'
664
+ return url.toString()
665
+ } catch {
666
+ return spec.replace(/:\/\/([^:@/]+):([^@/]+)@/, '://REDACTED:REDACTED@')
667
+ }
668
+ }
669
+
670
+ function redactSecrets(text, ctx) {
671
+ let redacted = String(text)
672
+ const candidates = [ctx.githubAuth?.token, process.env.GH_TOKEN, process.env.GITHUB_TOKEN]
673
+ for (const secret of candidates) {
674
+ if (typeof secret !== 'string' || secret.length === 0) continue
675
+ redacted = redacted.split(secret).join('REDACTED')
676
+ }
677
+ return redacted
678
+ }
679
+
680
+ function formatImportId(id) {
681
+ return path.isAbsolute(id) ? formatPath(id) : id
682
+ }
683
+
684
+ /**
685
+ * @param {string} filePath
686
+ * @returns {string}
687
+ */
688
+ function formatPath(filePath) {
689
+ return path.normalize(filePath)
690
+ }