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.
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/bin/pluribus.js +108 -0
- package/docs/composable-contexts.md +124 -0
- package/docs/openclaw-integration.md +145 -0
- package/docs/release-checklist.md +91 -0
- package/docs/remote-composable-context-imports.md +233 -0
- package/examples/claude-cowork/.cursorrules +82 -0
- package/examples/claude-cowork/pluribus.md +137 -0
- package/examples/composable-contexts/pluribus.md +29 -0
- package/examples/composable-contexts/shared/security-constraints.md +6 -0
- package/examples/composable-contexts/shared/team-context.md +10 -0
- package/examples/openclaw/AGENTS.md +134 -0
- package/examples/openclaw/CLAUDE.md +132 -0
- package/examples/openclaw/pluribus.md +99 -0
- package/package.json +52 -0
- package/spec/context-format.md +356 -0
- package/spec/skills-format.md +325 -0
- package/src/commands/init.js +153 -0
- package/src/commands/sync.js +213 -0
- package/src/commands/validate.js +146 -0
- package/src/commands/watch.js +111 -0
- package/src/index.js +11 -0
- package/src/skills/built-in.js +345 -0
- package/src/utils/args.js +35 -0
- package/src/utils/imports.js +690 -0
- package/src/utils/parser.js +74 -0
- package/src/utils/renderer.js +123 -0
- package/src/utils/version.js +1 -0
|
@@ -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
|
+
}
|