rootless-config 1.0.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 +173 -0
- package/bin/rootless.js +15 -0
- package/package.json +46 -0
- package/src/cli/commandRegistry.js +42 -0
- package/src/cli/commands/benchmark.js +43 -0
- package/src/cli/commands/clean.js +16 -0
- package/src/cli/commands/completion.js +59 -0
- package/src/cli/commands/debug.js +50 -0
- package/src/cli/commands/doctor.js +63 -0
- package/src/cli/commands/graph.js +34 -0
- package/src/cli/commands/init.js +53 -0
- package/src/cli/commands/inspect.js +48 -0
- package/src/cli/commands/migrate.js +63 -0
- package/src/cli/commands/prepare.js +18 -0
- package/src/cli/commands/stats.js +40 -0
- package/src/cli/commands/status.js +47 -0
- package/src/cli/commands/validate.js +34 -0
- package/src/cli/commands/watch.js +40 -0
- package/src/cli/index.js +53 -0
- package/src/core/cliWrapper.js +106 -0
- package/src/core/configGraph.js +50 -0
- package/src/core/configSchema.js +45 -0
- package/src/core/containerLoader.js +67 -0
- package/src/core/executionContext.js +27 -0
- package/src/core/fileHashCache.js +39 -0
- package/src/core/generatedManifest.js +35 -0
- package/src/core/generator.js +59 -0
- package/src/core/overrideStrategy.js +15 -0
- package/src/core/pathResolver.js +91 -0
- package/src/core/remoteLoader.js +64 -0
- package/src/core/rootSearch.js +36 -0
- package/src/core/versionCheck.js +42 -0
- package/src/experimental/capsule.js +11 -0
- package/src/experimental/index.js +20 -0
- package/src/experimental/virtualFS.js +13 -0
- package/src/index.js +39 -0
- package/src/plugins/builtins/assetPlugin.js +34 -0
- package/src/plugins/builtins/configPlugin.js +38 -0
- package/src/plugins/builtins/envPlugin.js +33 -0
- package/src/plugins/builtins/index.js +9 -0
- package/src/plugins/pluginLoader.js +35 -0
- package/src/plugins/pluginRegistry.js +36 -0
- package/src/types/configTypes.js +23 -0
- package/src/types/errors.js +43 -0
- package/src/types/pluginInterface.js +25 -0
- package/src/utils/debounce.js +22 -0
- package/src/utils/fsUtils.js +48 -0
- package/src/utils/hashUtils.js +24 -0
- package/src/utils/logger.js +34 -0
- package/src/utils/prompt.js +15 -0
- package/src/watch/dirtySet.js +30 -0
- package/src/watch/incrementalRegeneration.js +32 -0
- package/src/watch/watcher.js +38 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/*-------- Single entry point for all file generation — proxy and copy modes --------*/
|
|
2
|
+
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileExists, atomicWrite, copyFile } from '../utils/fsUtils.js'
|
|
5
|
+
import { computeAndCompare, updateHash } from './fileHashCache.js'
|
|
6
|
+
import { addToManifest, isManaged } from './generatedManifest.js'
|
|
7
|
+
import { resolveOverrideStrategy } from './overrideStrategy.js'
|
|
8
|
+
|
|
9
|
+
const PROXY_TEMPLATE = (sourcePath) =>
|
|
10
|
+
`export { default } from "${sourcePath}"\n`
|
|
11
|
+
|
|
12
|
+
async function generateFile({ source, target, mode = 'proxy', plugin, options = {}, logger }) {
|
|
13
|
+
const targetExists = await fileExists(target)
|
|
14
|
+
|
|
15
|
+
if (targetExists && !(await isManaged(target))) {
|
|
16
|
+
const shouldOverride = await resolveOverrideStrategy(target, options)
|
|
17
|
+
if (!shouldOverride) {
|
|
18
|
+
logger?.info(`Skipped: ${path.basename(target)} (user declined override)`)
|
|
19
|
+
return { skipped: true, target }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (targetExists) {
|
|
24
|
+
const { changed } = await computeAndCompare(source)
|
|
25
|
+
if (!changed) {
|
|
26
|
+
logger?.debug(`Unchanged: ${path.basename(target)}`)
|
|
27
|
+
return { skipped: true, target, reason: 'unchanged' }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (plugin?.generate) {
|
|
32
|
+
const content = await plugin.generate(source, { mode, target, options })
|
|
33
|
+
await atomicWrite(target, content)
|
|
34
|
+
} else if (mode === 'proxy') {
|
|
35
|
+
const rel = path.relative(path.dirname(target), source).replace(/\\/g, '/')
|
|
36
|
+
const relPath = rel.startsWith('.') ? rel : `./${rel}`
|
|
37
|
+
await atomicWrite(target, PROXY_TEMPLATE(relPath))
|
|
38
|
+
} else {
|
|
39
|
+
await copyFile(source, target)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { newHash } = await computeAndCompare(source)
|
|
43
|
+
await updateHash(source, newHash)
|
|
44
|
+
await addToManifest(target)
|
|
45
|
+
|
|
46
|
+
logger?.success(`${path.basename(target)} generated`)
|
|
47
|
+
return { skipped: false, target }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function generateFiles(entries, options = {}, logger) {
|
|
51
|
+
const sorted = [...entries].sort((a, b) => a.target.localeCompare(b.target))
|
|
52
|
+
const results = []
|
|
53
|
+
for (const entry of sorted) {
|
|
54
|
+
results.push(await generateFile({ ...entry, options, logger }))
|
|
55
|
+
}
|
|
56
|
+
return results
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { generateFile, generateFiles }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*-------- Resolves override strategy for file conflict detection --------*/
|
|
2
|
+
|
|
3
|
+
import { confirm } from '../utils/prompt.js'
|
|
4
|
+
|
|
5
|
+
async function resolveOverrideStrategy(filePath, options = {}) {
|
|
6
|
+
if (options.yes === true) return true
|
|
7
|
+
if (options.no === true) return false
|
|
8
|
+
|
|
9
|
+
const isCI = process.env.CI !== undefined && process.env.CI !== ''
|
|
10
|
+
if (isCI && options.yes === undefined) return true
|
|
11
|
+
|
|
12
|
+
return confirm(`File exists: ${filePath}\nOverride?`)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export { resolveOverrideStrategy }
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/*-------- Single source of truth for all path resolution in the project --------*/
|
|
2
|
+
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { readFile } from 'node:fs/promises'
|
|
6
|
+
import { findProjectRoot } from './rootSearch.js'
|
|
7
|
+
|
|
8
|
+
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
9
|
+
let _projectRoot = null
|
|
10
|
+
let _containerPath = null
|
|
11
|
+
let _containerOverride = null
|
|
12
|
+
|
|
13
|
+
function resetPathResolver() {
|
|
14
|
+
_projectRoot = null
|
|
15
|
+
_containerPath = null
|
|
16
|
+
_containerOverride = null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function setContainerOverride(absolutePath) {
|
|
20
|
+
_containerOverride = absolutePath
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function resolveProjectRoot() {
|
|
24
|
+
if (_projectRoot) return _projectRoot
|
|
25
|
+
if (_containerOverride) {
|
|
26
|
+
const nodePath = await import('node:path')
|
|
27
|
+
_projectRoot = nodePath.default.dirname(_containerOverride)
|
|
28
|
+
return _projectRoot
|
|
29
|
+
}
|
|
30
|
+
_projectRoot = await findProjectRoot(process.cwd())
|
|
31
|
+
return _projectRoot
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function resolveContainerPath() {
|
|
35
|
+
if (_containerPath) return _containerPath
|
|
36
|
+
if (_containerOverride) {
|
|
37
|
+
_containerPath = _containerOverride
|
|
38
|
+
return _containerPath
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const projectRoot = await resolveProjectRoot()
|
|
42
|
+
const configPath = path.resolve(projectRoot, 'rootless.config.json')
|
|
43
|
+
|
|
44
|
+
const containerFromConfig = await readContainerPathFromConfig(configPath)
|
|
45
|
+
_containerPath = containerFromConfig
|
|
46
|
+
? path.resolve(projectRoot, containerFromConfig)
|
|
47
|
+
: path.resolve(projectRoot, '.root')
|
|
48
|
+
|
|
49
|
+
return _containerPath
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readContainerPathFromConfig(configPath) {
|
|
53
|
+
try {
|
|
54
|
+
const raw = await readFile(configPath, 'utf8')
|
|
55
|
+
const config = JSON.parse(raw)
|
|
56
|
+
return config.containerPath ?? null
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function resolveCachePath(subPath) {
|
|
63
|
+
const container = await resolveContainerPath()
|
|
64
|
+
const base = path.join(container, '.cache')
|
|
65
|
+
return subPath ? path.join(base, subPath) : base
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function resolveGeneratedFilePath() {
|
|
69
|
+
const container = await resolveContainerPath()
|
|
70
|
+
return path.join(container, '.generated.json')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function resolveSourceFilePath(relativePath) {
|
|
74
|
+
const container = await resolveContainerPath()
|
|
75
|
+
return path.join(container, relativePath)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolvePackageRoot() {
|
|
79
|
+
return PKG_ROOT
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export {
|
|
83
|
+
resolveProjectRoot,
|
|
84
|
+
resolveContainerPath,
|
|
85
|
+
resolveCachePath,
|
|
86
|
+
resolveGeneratedFilePath,
|
|
87
|
+
resolveSourceFilePath,
|
|
88
|
+
resolvePackageRoot,
|
|
89
|
+
setContainerOverride,
|
|
90
|
+
resetPathResolver,
|
|
91
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/*-------- Parallel remote config fetching with TTL cache --------*/
|
|
2
|
+
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileExists, readJsonFile, writeJsonFile, atomicWrite, ensureDir } from '../utils/fsUtils.js'
|
|
5
|
+
import { readFile } from 'node:fs/promises'
|
|
6
|
+
import { hashString } from '../utils/hashUtils.js'
|
|
7
|
+
import { resolveCachePath } from './pathResolver.js'
|
|
8
|
+
import { RemoteError } from '../types/errors.js'
|
|
9
|
+
|
|
10
|
+
const TTL_MS = 60 * 60 * 1000
|
|
11
|
+
const REMOTE_CACHE_DIR = 'remote'
|
|
12
|
+
const META_FILE = 'remote-meta.json'
|
|
13
|
+
|
|
14
|
+
async function loadMeta() {
|
|
15
|
+
const metaPath = await resolveCachePath(META_FILE)
|
|
16
|
+
if (!(await fileExists(metaPath))) return {}
|
|
17
|
+
return readJsonFile(metaPath)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function saveMeta(meta) {
|
|
21
|
+
const metaPath = await resolveCachePath(META_FILE)
|
|
22
|
+
await writeJsonFile(metaPath, meta)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function fetchOne(url) {
|
|
26
|
+
const response = await fetch(url)
|
|
27
|
+
if (!response.ok) throw new RemoteError(`HTTP ${response.status} fetching ${url}`, url)
|
|
28
|
+
return response.text()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function loadRemoteConfigs(urls = []) {
|
|
32
|
+
const remoteDir = await resolveCachePath(REMOTE_CACHE_DIR)
|
|
33
|
+
await ensureDir(remoteDir)
|
|
34
|
+
|
|
35
|
+
const meta = await loadMeta()
|
|
36
|
+
const now = Date.now()
|
|
37
|
+
|
|
38
|
+
const tasks = urls.map(async url => {
|
|
39
|
+
const key = hashString(url)
|
|
40
|
+
const cachedFile = path.join(remoteDir, key)
|
|
41
|
+
const entry = meta[key]
|
|
42
|
+
|
|
43
|
+
if (entry && now - entry.fetchedAt < TTL_MS && (await fileExists(cachedFile))) {
|
|
44
|
+
const content = await readFile(cachedFile, 'utf8')
|
|
45
|
+
return { url, content, fromCache: true }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const content = await fetchOne(url)
|
|
49
|
+
await atomicWrite(cachedFile, content)
|
|
50
|
+
meta[key] = { url, fetchedAt: now }
|
|
51
|
+
return { url, content, fromCache: false }
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const results = await Promise.all(tasks)
|
|
55
|
+
await saveMeta(meta)
|
|
56
|
+
return results
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function clearRemoteCache() {
|
|
60
|
+
const metaPath = await resolveCachePath(META_FILE)
|
|
61
|
+
await writeJsonFile(metaPath, {})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { loadRemoteConfigs, clearRemoteCache }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*-------- Upward directory traversal to locate the nearest .root container --------*/
|
|
2
|
+
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { access, constants } from 'node:fs/promises'
|
|
5
|
+
import { ContainerNotFoundError } from '../types/errors.js'
|
|
6
|
+
|
|
7
|
+
const GIT_MARKER = '.git'
|
|
8
|
+
const CONTAINER_NAME = '.root'
|
|
9
|
+
|
|
10
|
+
async function exists(dirPath) {
|
|
11
|
+
try {
|
|
12
|
+
await access(dirPath, constants.F_OK)
|
|
13
|
+
return true
|
|
14
|
+
} catch {
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function findProjectRoot(startDir) {
|
|
20
|
+
let current = path.resolve(startDir)
|
|
21
|
+
|
|
22
|
+
while (true) {
|
|
23
|
+
const candidate = path.join(current, CONTAINER_NAME)
|
|
24
|
+
if (await exists(candidate)) return current
|
|
25
|
+
|
|
26
|
+
const parent = path.dirname(current)
|
|
27
|
+
if (parent === current) throw new ContainerNotFoundError(startDir)
|
|
28
|
+
|
|
29
|
+
const hasGit = await exists(path.join(current, GIT_MARKER))
|
|
30
|
+
if (hasGit) throw new ContainerNotFoundError(startDir)
|
|
31
|
+
|
|
32
|
+
current = parent
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export { findProjectRoot }
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/*-------- Reads .root/rootless.version and checks library compatibility --------*/
|
|
2
|
+
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { fileExists } from '../utils/fsUtils.js'
|
|
7
|
+
import { readFile } from 'node:fs/promises'
|
|
8
|
+
import { resolveContainerPath } from './pathResolver.js'
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url)
|
|
11
|
+
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
12
|
+
|
|
13
|
+
function getLibraryVersion() {
|
|
14
|
+
try {
|
|
15
|
+
const pkg = require(path.join(PKG_ROOT, 'package.json'))
|
|
16
|
+
return pkg.version ?? '0.0.0'
|
|
17
|
+
} catch {
|
|
18
|
+
return '0.0.0'
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseMajor(version) {
|
|
23
|
+
return parseInt(version.split('.')[0], 10)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function checkVersionCompatibility() {
|
|
27
|
+
const container = await resolveContainerPath()
|
|
28
|
+
const versionFile = path.join(container, 'rootless.version')
|
|
29
|
+
const cliVersion = getLibraryVersion()
|
|
30
|
+
|
|
31
|
+
if (!(await fileExists(versionFile))) {
|
|
32
|
+
return { compatible: true, projectVersion: null, cliVersion }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const raw = await readFile(versionFile, 'utf8')
|
|
36
|
+
const projectVersion = raw.trim()
|
|
37
|
+
const compatible = parseMajor(cliVersion) === parseMajor(projectVersion)
|
|
38
|
+
|
|
39
|
+
return { compatible, projectVersion, cliVersion }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export { checkVersionCompatibility, getLibraryVersion }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*-------- Capsule stub — experimental.capsule = false --------*/
|
|
2
|
+
|
|
3
|
+
function createCapsule() {
|
|
4
|
+
return {
|
|
5
|
+
enabled: false,
|
|
6
|
+
pack: async () => { throw new Error('Capsule feature is experimental and disabled. Set experimental.capsule = true to enable.') },
|
|
7
|
+
unpack: async () => { throw new Error('Capsule feature is experimental and disabled. Set experimental.capsule = true to enable.') },
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export { createCapsule }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*-------- Experimental feature registry — reads the experimental block from config --------*/
|
|
2
|
+
|
|
3
|
+
const DEFAULTS = {
|
|
4
|
+
virtualFS: false,
|
|
5
|
+
capsule: false,
|
|
6
|
+
profiles: false,
|
|
7
|
+
remoteConfigs: false,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function createExperimentalRegistry(config = {}) {
|
|
11
|
+
const flags = { ...DEFAULTS, ...(config.experimental ?? {}) }
|
|
12
|
+
|
|
13
|
+
function isEnabled(name) {
|
|
14
|
+
return flags[name] === true
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return { isEnabled, flags }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { createExperimentalRegistry }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*-------- Virtual Filesystem stub — experimental.virtualFS = false --------*/
|
|
2
|
+
|
|
3
|
+
function createVirtualFS() {
|
|
4
|
+
return {
|
|
5
|
+
enabled: false,
|
|
6
|
+
read: async () => null,
|
|
7
|
+
write: async () => {},
|
|
8
|
+
flush: async () => {},
|
|
9
|
+
list: async () => [],
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { createVirtualFS }
|
package/src/index.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/*-------- Public programmatic API --------*/
|
|
2
|
+
|
|
3
|
+
import { preparePipeline, cleanPipeline, buildContext } from './core/cliWrapper.js'
|
|
4
|
+
import { createWatcher } from './watch/watcher.js'
|
|
5
|
+
import { createDirtySet } from './watch/dirtySet.js'
|
|
6
|
+
import { runIncrementalRegeneration } from './watch/incrementalRegeneration.js'
|
|
7
|
+
import { resolveContainerPath } from './core/pathResolver.js'
|
|
8
|
+
|
|
9
|
+
async function prepare(options = {}) {
|
|
10
|
+
return preparePipeline(options)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function clean(options = {}) {
|
|
14
|
+
return cleanPipeline(options)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function watch(options = {}) {
|
|
18
|
+
const ctx = await buildContext({ yes: true, ...options })
|
|
19
|
+
const containerPath = await resolveContainerPath()
|
|
20
|
+
|
|
21
|
+
const dirtySet = createDirtySet(async changed => {
|
|
22
|
+
await runIncrementalRegeneration([...changed], ctx)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const watcher = createWatcher([containerPath], { ignoreInitial: true })
|
|
26
|
+
watcher.on('change', filePath => dirtySet.add(filePath))
|
|
27
|
+
watcher.on('add', filePath => dirtySet.add(filePath))
|
|
28
|
+
watcher.start()
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
stop: async () => {
|
|
32
|
+
watcher.stop()
|
|
33
|
+
dirtySet.clear()
|
|
34
|
+
await ctx.dispose()
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { prepare, watch, clean }
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/*-------- Built-in asset plugin — handles static assets like favicon.ico, robots.txt --------*/
|
|
2
|
+
|
|
3
|
+
const ASSET_NAMES = new Set(['favicon.ico', 'robots.txt', 'manifest.json', 'sitemap.xml'])
|
|
4
|
+
const ASSET_EXTENSIONS = new Set(['.ico', '.png', '.jpg', '.jpeg', '.svg', '.webp', '.gif'])
|
|
5
|
+
|
|
6
|
+
const assetPlugin = {
|
|
7
|
+
name: 'asset',
|
|
8
|
+
priority: 10,
|
|
9
|
+
|
|
10
|
+
match(filePath) {
|
|
11
|
+
const base = filePath.split(/[\\/]/).pop() ?? ''
|
|
12
|
+
const ext = '.' + base.split('.').pop()
|
|
13
|
+
return ASSET_NAMES.has(base) || ASSET_EXTENSIONS.has(ext)
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
async load(filePath) {
|
|
17
|
+
const { readFile } = await import('node:fs/promises')
|
|
18
|
+
return readFile(filePath)
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
async transform(content) {
|
|
22
|
+
return content
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
async generate(source, { target }) {
|
|
26
|
+
const { copyFile } = await import('../../utils/fsUtils.js')
|
|
27
|
+
await copyFile(source, target)
|
|
28
|
+
return null
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async teardown() {},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { assetPlugin }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/*-------- Built-in config plugin — handles *.config.js / *.config.ts — full lifecycle --------*/
|
|
2
|
+
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
const CONFIG_PATTERN = /\.config\.(js|ts|mjs|cjs)$/
|
|
6
|
+
|
|
7
|
+
const configPlugin = {
|
|
8
|
+
name: 'config',
|
|
9
|
+
priority: 15,
|
|
10
|
+
|
|
11
|
+
match(filePath) {
|
|
12
|
+
return CONFIG_PATTERN.test(filePath)
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
async load(filePath) {
|
|
16
|
+
const { readFile } = await import('node:fs/promises')
|
|
17
|
+
return readFile(filePath, 'utf8')
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
async transform(content) {
|
|
21
|
+
return content
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
async generate(source, { target, mode = 'proxy' }) {
|
|
25
|
+
if (mode === 'copy') {
|
|
26
|
+
const content = await configPlugin.load(source)
|
|
27
|
+
return configPlugin.transform(content)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rel = path.relative(path.dirname(target), source).replace(/\\/g, '/')
|
|
31
|
+
const relPath = rel.startsWith('.') ? rel : `./${rel}`
|
|
32
|
+
return `export { default } from "${relPath}"\n`
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async teardown() {},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export { configPlugin }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/*-------- Built-in env plugin — handles .env files — full lifecycle --------*/
|
|
2
|
+
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import { atomicWrite } from '../../utils/fsUtils.js'
|
|
5
|
+
|
|
6
|
+
const envPlugin = {
|
|
7
|
+
name: 'env',
|
|
8
|
+
priority: 20,
|
|
9
|
+
|
|
10
|
+
match(filePath) {
|
|
11
|
+
const base = filePath.split(/[\\/]/).pop() ?? ''
|
|
12
|
+
return base === '.env' || base.startsWith('.env.')
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
async load(filePath) {
|
|
16
|
+
return readFile(filePath, 'utf8')
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
async transform(content) {
|
|
20
|
+
return content
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
async generate(source, { target }) {
|
|
24
|
+
const content = await envPlugin.load(source)
|
|
25
|
+
const transformed = await envPlugin.transform(content)
|
|
26
|
+
await atomicWrite(target, transformed)
|
|
27
|
+
return transformed
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
async teardown() {},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export { envPlugin }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/*-------- Re-exports all built-in plugins as a registry-ready array --------*/
|
|
2
|
+
|
|
3
|
+
import { envPlugin } from './envPlugin.js'
|
|
4
|
+
import { configPlugin } from './configPlugin.js'
|
|
5
|
+
import { assetPlugin } from './assetPlugin.js'
|
|
6
|
+
|
|
7
|
+
const BUILTIN_PLUGINS = [assetPlugin, configPlugin, envPlugin]
|
|
8
|
+
|
|
9
|
+
export { BUILTIN_PLUGINS }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/*-------- Discovers, validates, and loads plugins into a registry --------*/
|
|
2
|
+
|
|
3
|
+
import { assertValidPlugin } from '../types/pluginInterface.js'
|
|
4
|
+
import { PluginError } from '../types/errors.js'
|
|
5
|
+
import { BUILTIN_PLUGINS } from './builtins/index.js'
|
|
6
|
+
|
|
7
|
+
async function loadPlugins(config = {}, registry) {
|
|
8
|
+
for (const plugin of BUILTIN_PLUGINS) {
|
|
9
|
+
registry.register(plugin, 'builtin')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const externalDefs = config.plugins ?? []
|
|
13
|
+
for (const def of externalDefs) {
|
|
14
|
+
const moduleName = typeof def === 'string' ? def : def.name
|
|
15
|
+
const pluginOptions = typeof def === 'object' ? def.options ?? {} : {}
|
|
16
|
+
|
|
17
|
+
let mod
|
|
18
|
+
try {
|
|
19
|
+
mod = await import(moduleName)
|
|
20
|
+
} catch (err) {
|
|
21
|
+
throw new PluginError(`Cannot load plugin module "${moduleName}": ${err.message}`, moduleName)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const factory = mod.default ?? mod
|
|
25
|
+
if (typeof factory !== 'function') {
|
|
26
|
+
throw new PluginError(`Plugin "${moduleName}" must export a factory function`, moduleName)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const plugin = factory(pluginOptions)
|
|
30
|
+
assertValidPlugin(plugin, 'external')
|
|
31
|
+
registry.register(plugin, 'external')
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export { loadPlugins }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*-------- Central plugin registry — sorted by priority before execution --------*/
|
|
2
|
+
|
|
3
|
+
import { assertValidPlugin } from '../types/pluginInterface.js'
|
|
4
|
+
import { PluginError } from '../types/errors.js'
|
|
5
|
+
|
|
6
|
+
function createPluginRegistry() {
|
|
7
|
+
const plugins = new Map()
|
|
8
|
+
|
|
9
|
+
function register(plugin, type = 'external') {
|
|
10
|
+
assertValidPlugin(plugin, type)
|
|
11
|
+
if (plugins.has(plugin.name)) {
|
|
12
|
+
throw new PluginError(`Plugin "${plugin.name}" is already registered`, plugin.name)
|
|
13
|
+
}
|
|
14
|
+
plugins.set(plugin.name, { ...plugin, _type: type })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getAll() {
|
|
18
|
+
return [...plugins.values()].sort((a, b) => {
|
|
19
|
+
const pa = a.priority ?? 0
|
|
20
|
+
const pb = b.priority ?? 0
|
|
21
|
+
return pa - pb
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolve(filePath) {
|
|
26
|
+
return getAll().filter(p => p.match(filePath))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function unregister(name) {
|
|
30
|
+
plugins.delete(name)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { register, getAll, resolve, unregister }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export { createPluginRegistry }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/*-------- Shape constants for rootless.config.json and related manifests --------*/
|
|
2
|
+
|
|
3
|
+
const ROOT_CONFIG_SHAPE = {
|
|
4
|
+
containerPath: 'string|undefined',
|
|
5
|
+
mode: 'proxy|copy|undefined',
|
|
6
|
+
plugins: 'array|undefined',
|
|
7
|
+
remote: 'array|undefined',
|
|
8
|
+
extends: 'array|undefined',
|
|
9
|
+
experimental: 'object|undefined',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const GENERATED_MANIFEST_SHAPE = {
|
|
13
|
+
generated: 'array',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const EXPERIMENTAL_FLAGS_SHAPE = {
|
|
17
|
+
virtualFS: 'boolean',
|
|
18
|
+
capsule: 'boolean',
|
|
19
|
+
profiles: 'boolean',
|
|
20
|
+
remoteConfigs: 'boolean',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { ROOT_CONFIG_SHAPE, GENERATED_MANIFEST_SHAPE, EXPERIMENTAL_FLAGS_SHAPE }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*-------- Centralized error hierarchy for rootless-config --------*/
|
|
2
|
+
|
|
3
|
+
class RcsError extends Error {
|
|
4
|
+
constructor(message, code) {
|
|
5
|
+
super(message)
|
|
6
|
+
this.name = 'RcsError'
|
|
7
|
+
this.code = code
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class ContainerNotFoundError extends RcsError {
|
|
12
|
+
constructor(searchRoot) {
|
|
13
|
+
super(`No .root container found searching upward from: ${searchRoot}`, 'CONTAINER_NOT_FOUND')
|
|
14
|
+
this.name = 'ContainerNotFoundError'
|
|
15
|
+
this.searchRoot = searchRoot
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class ValidationError extends RcsError {
|
|
20
|
+
constructor(message, field) {
|
|
21
|
+
super(message, 'VALIDATION_ERROR')
|
|
22
|
+
this.name = 'ValidationError'
|
|
23
|
+
this.field = field
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class PluginError extends RcsError {
|
|
28
|
+
constructor(message, pluginName) {
|
|
29
|
+
super(message, 'PLUGIN_ERROR')
|
|
30
|
+
this.name = 'PluginError'
|
|
31
|
+
this.pluginName = pluginName
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class RemoteError extends RcsError {
|
|
36
|
+
constructor(message, url) {
|
|
37
|
+
super(message, 'REMOTE_ERROR')
|
|
38
|
+
this.name = 'RemoteError'
|
|
39
|
+
this.url = url
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { RcsError, ContainerNotFoundError, ValidationError, PluginError, RemoteError }
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/*-------- Plugin interface contracts and runtime shape validation --------*/
|
|
2
|
+
|
|
3
|
+
import { PluginError } from './errors.js'
|
|
4
|
+
|
|
5
|
+
const EXTERNAL_PLUGIN_FIELDS = ['name', 'match', 'generate']
|
|
6
|
+
const BUILTIN_PLUGIN_FIELDS = ['name', 'match', 'load', 'transform', 'generate', 'teardown']
|
|
7
|
+
|
|
8
|
+
function assertValidPlugin(plugin, type) {
|
|
9
|
+
const required = type === 'builtin' ? BUILTIN_PLUGIN_FIELDS : EXTERNAL_PLUGIN_FIELDS
|
|
10
|
+
|
|
11
|
+
for (const field of required) {
|
|
12
|
+
if (typeof plugin[field] !== 'function' && field !== 'name') {
|
|
13
|
+
throw new PluginError(
|
|
14
|
+
`Plugin "${plugin.name ?? '?'}" missing required method: ${field}`,
|
|
15
|
+
plugin.name ?? 'unknown'
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (typeof plugin.name !== 'string' || plugin.name.trim() === '') {
|
|
21
|
+
throw new PluginError('Plugin must have a non-empty string "name"', 'unknown')
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export { EXTERNAL_PLUGIN_FIELDS, BUILTIN_PLUGIN_FIELDS, assertValidPlugin }
|