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.
Files changed (53) hide show
  1. package/README.md +173 -0
  2. package/bin/rootless.js +15 -0
  3. package/package.json +46 -0
  4. package/src/cli/commandRegistry.js +42 -0
  5. package/src/cli/commands/benchmark.js +43 -0
  6. package/src/cli/commands/clean.js +16 -0
  7. package/src/cli/commands/completion.js +59 -0
  8. package/src/cli/commands/debug.js +50 -0
  9. package/src/cli/commands/doctor.js +63 -0
  10. package/src/cli/commands/graph.js +34 -0
  11. package/src/cli/commands/init.js +53 -0
  12. package/src/cli/commands/inspect.js +48 -0
  13. package/src/cli/commands/migrate.js +63 -0
  14. package/src/cli/commands/prepare.js +18 -0
  15. package/src/cli/commands/stats.js +40 -0
  16. package/src/cli/commands/status.js +47 -0
  17. package/src/cli/commands/validate.js +34 -0
  18. package/src/cli/commands/watch.js +40 -0
  19. package/src/cli/index.js +53 -0
  20. package/src/core/cliWrapper.js +106 -0
  21. package/src/core/configGraph.js +50 -0
  22. package/src/core/configSchema.js +45 -0
  23. package/src/core/containerLoader.js +67 -0
  24. package/src/core/executionContext.js +27 -0
  25. package/src/core/fileHashCache.js +39 -0
  26. package/src/core/generatedManifest.js +35 -0
  27. package/src/core/generator.js +59 -0
  28. package/src/core/overrideStrategy.js +15 -0
  29. package/src/core/pathResolver.js +91 -0
  30. package/src/core/remoteLoader.js +64 -0
  31. package/src/core/rootSearch.js +36 -0
  32. package/src/core/versionCheck.js +42 -0
  33. package/src/experimental/capsule.js +11 -0
  34. package/src/experimental/index.js +20 -0
  35. package/src/experimental/virtualFS.js +13 -0
  36. package/src/index.js +39 -0
  37. package/src/plugins/builtins/assetPlugin.js +34 -0
  38. package/src/plugins/builtins/configPlugin.js +38 -0
  39. package/src/plugins/builtins/envPlugin.js +33 -0
  40. package/src/plugins/builtins/index.js +9 -0
  41. package/src/plugins/pluginLoader.js +35 -0
  42. package/src/plugins/pluginRegistry.js +36 -0
  43. package/src/types/configTypes.js +23 -0
  44. package/src/types/errors.js +43 -0
  45. package/src/types/pluginInterface.js +25 -0
  46. package/src/utils/debounce.js +22 -0
  47. package/src/utils/fsUtils.js +48 -0
  48. package/src/utils/hashUtils.js +24 -0
  49. package/src/utils/logger.js +34 -0
  50. package/src/utils/prompt.js +15 -0
  51. package/src/watch/dirtySet.js +30 -0
  52. package/src/watch/incrementalRegeneration.js +32 -0
  53. 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 }