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,63 @@
|
|
|
1
|
+
/*-------- rootless migrate — moves existing root configs into .root container --------*/
|
|
2
|
+
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { readdir } from 'node:fs/promises'
|
|
5
|
+
import { createLogger } from '../../utils/logger.js'
|
|
6
|
+
import { fileExists, ensureDir, atomicWrite } from '../../utils/fsUtils.js'
|
|
7
|
+
import { readFile } from 'node:fs/promises'
|
|
8
|
+
import { confirm } from '../../utils/prompt.js'
|
|
9
|
+
import { resolveProjectRoot } from '../../core/pathResolver.js'
|
|
10
|
+
|
|
11
|
+
const CONFIG_PATTERN = /\.(config|rc)\.(js|ts|mjs|cjs|json)$|\.eslintrc$|\.babelrc$/
|
|
12
|
+
const ENV_PATTERN = /^\.env/
|
|
13
|
+
|
|
14
|
+
async function findMigratableFiles(projectRoot) {
|
|
15
|
+
const entries = await readdir(projectRoot, { withFileTypes: true })
|
|
16
|
+
return entries
|
|
17
|
+
.filter(e => e.isFile() && (CONFIG_PATTERN.test(e.name) || ENV_PATTERN.test(e.name)))
|
|
18
|
+
.map(e => e.name)
|
|
19
|
+
.sort()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
name: 'migrate',
|
|
24
|
+
description: 'Move existing config files from project root into .root container',
|
|
25
|
+
|
|
26
|
+
async handler(args) {
|
|
27
|
+
const logger = createLogger({ verbose: args.verbose ?? false })
|
|
28
|
+
const projectRoot = await resolveProjectRoot()
|
|
29
|
+
const containerPath = path.join(projectRoot, '.root')
|
|
30
|
+
|
|
31
|
+
const candidates = await findMigratableFiles(projectRoot)
|
|
32
|
+
if (candidates.length === 0) {
|
|
33
|
+
logger.info('No config files found to migrate in project root')
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
logger.info(`Found ${candidates.length} migratable files:\n ${candidates.join('\n ')}`)
|
|
38
|
+
|
|
39
|
+
const proceed = args.yes || await confirm('Move these files to .root/?')
|
|
40
|
+
if (!proceed) {
|
|
41
|
+
logger.info('Migration cancelled')
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const name of candidates) {
|
|
46
|
+
const src = path.join(projectRoot, name)
|
|
47
|
+
const isEnv = ENV_PATTERN.test(name)
|
|
48
|
+
const destDir = path.join(containerPath, isEnv ? 'env' : 'configs')
|
|
49
|
+
|
|
50
|
+
await ensureDir(destDir)
|
|
51
|
+
const content = await readFile(src, 'utf8')
|
|
52
|
+
await atomicWrite(path.join(destDir, name), content)
|
|
53
|
+
|
|
54
|
+
const rel = path.relative(path.dirname(src), path.join(containerPath, isEnv ? 'env' : 'configs', name)).replace(/\\/g, '/')
|
|
55
|
+
const relPath = rel.startsWith('.') ? rel : `./${rel}`
|
|
56
|
+
await atomicWrite(src, `export { default } from "${relPath}"\n`)
|
|
57
|
+
|
|
58
|
+
logger.success(`Migrated: ${name}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
logger.info('Run: rootless prepare to regenerate')
|
|
62
|
+
},
|
|
63
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/*-------- rootless prepare — scans .root, generates proxy/copy files --------*/
|
|
2
|
+
|
|
3
|
+
import { preparePipeline } from '../../core/cliWrapper.js'
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
name: 'prepare',
|
|
7
|
+
description: 'Scan .root container and generate proxy/copy files in project root',
|
|
8
|
+
|
|
9
|
+
async handler(args) {
|
|
10
|
+
const options = {
|
|
11
|
+
yes: args.yes ?? false,
|
|
12
|
+
no: args.no ?? false,
|
|
13
|
+
verbose: args.verbose ?? false,
|
|
14
|
+
silent: args.silent ?? false,
|
|
15
|
+
}
|
|
16
|
+
await preparePipeline(options)
|
|
17
|
+
},
|
|
18
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/*-------- rootless stats — aggregate statistics for the project --------*/
|
|
2
|
+
|
|
3
|
+
import { readManifest } from '../../core/generatedManifest.js'
|
|
4
|
+
import { loadHashCache } from '../../core/fileHashCache.js'
|
|
5
|
+
import { hashFile } from '../../utils/hashUtils.js'
|
|
6
|
+
import { fileExists } from '../../utils/fsUtils.js'
|
|
7
|
+
import { resolveContainerPath, resolveProjectRoot } from '../../core/pathResolver.js'
|
|
8
|
+
import { discoverFiles } from '../../core/containerLoader.js'
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
name: 'stats',
|
|
12
|
+
description: 'Display aggregate project statistics',
|
|
13
|
+
|
|
14
|
+
async handler() {
|
|
15
|
+
const containerPath = await resolveContainerPath()
|
|
16
|
+
const managed = await readManifest()
|
|
17
|
+
const hashCache = await loadHashCache()
|
|
18
|
+
const files = await discoverFiles(containerPath)
|
|
19
|
+
|
|
20
|
+
const totalConfigs = (files.configs ?? []).length
|
|
21
|
+
const totalEnv = (files.env ?? []).length
|
|
22
|
+
const totalAssets = (files.assets ?? []).length
|
|
23
|
+
|
|
24
|
+
let stale = 0
|
|
25
|
+
for (const [filePath, cached] of Object.entries(hashCache.files ?? {})) {
|
|
26
|
+
if (!(await fileExists(filePath))) continue
|
|
27
|
+
const current = await hashFile(filePath).catch(() => null)
|
|
28
|
+
if (current && current !== cached) stale++
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
process.stdout.write('\nRootless Stats\n\n')
|
|
32
|
+
process.stdout.write(`Container: ${containerPath}\n`)
|
|
33
|
+
process.stdout.write(`Configs detected: ${totalConfigs}\n`)
|
|
34
|
+
process.stdout.write(`Env files: ${totalEnv}\n`)
|
|
35
|
+
process.stdout.write(`Assets detected: ${totalAssets}\n`)
|
|
36
|
+
process.stdout.write(`Generated files: ${managed.length}\n`)
|
|
37
|
+
process.stdout.write(`Stale files: ${stale}\n`)
|
|
38
|
+
process.stdout.write('\n')
|
|
39
|
+
},
|
|
40
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/*-------- rootless status — shows managed files, stale files, loaded containers --------*/
|
|
2
|
+
|
|
3
|
+
import { createLogger } from '../../utils/logger.js'
|
|
4
|
+
import { readManifest } from '../../core/generatedManifest.js'
|
|
5
|
+
import { loadHashCache } from '../../core/fileHashCache.js'
|
|
6
|
+
import { hashFile } from '../../utils/hashUtils.js'
|
|
7
|
+
import { fileExists } from '../../utils/fsUtils.js'
|
|
8
|
+
import { resolveContainerPath } from '../../core/pathResolver.js'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
name: 'status',
|
|
13
|
+
description: 'Show status of generated files and container',
|
|
14
|
+
|
|
15
|
+
async handler(args) {
|
|
16
|
+
const logger = createLogger({ verbose: args.verbose ?? false })
|
|
17
|
+
const containerPath = await resolveContainerPath()
|
|
18
|
+
|
|
19
|
+
process.stdout.write('\nRootless Config Status\n\n')
|
|
20
|
+
process.stdout.write(`Container: ${containerPath}\n`)
|
|
21
|
+
|
|
22
|
+
const managed = await readManifest()
|
|
23
|
+
const hashCache = await loadHashCache()
|
|
24
|
+
|
|
25
|
+
process.stdout.write(`\nGenerated files: ${managed.length}\n`)
|
|
26
|
+
|
|
27
|
+
for (const filePath of managed) {
|
|
28
|
+
const exists = await fileExists(filePath)
|
|
29
|
+
if (!exists) {
|
|
30
|
+
logger.fail(`${path.basename(filePath)} (missing)`)
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const currentHash = await hashFile(filePath).catch(() => null)
|
|
35
|
+
const cachedHash = hashCache.files[filePath]
|
|
36
|
+
const stale = currentHash && cachedHash && currentHash !== cachedHash
|
|
37
|
+
|
|
38
|
+
if (stale) {
|
|
39
|
+
process.stdout.write(` \x1b[33m~ ${path.basename(filePath)} (stale)\x1b[0m\n`)
|
|
40
|
+
} else {
|
|
41
|
+
process.stdout.write(` \x1b[32m✔ ${path.basename(filePath)}\x1b[0m\n`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
process.stdout.write('\n')
|
|
46
|
+
},
|
|
47
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/*-------- rootless validate — validates rootless.config.json structure --------*/
|
|
2
|
+
|
|
3
|
+
import { createLogger } from '../../utils/logger.js'
|
|
4
|
+
import { resolveContainerPath } from '../../core/pathResolver.js'
|
|
5
|
+
import { fileExists, readJsonFile } from '../../utils/fsUtils.js'
|
|
6
|
+
import { validateConfig } from '../../core/configSchema.js'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
name: 'validate',
|
|
11
|
+
description: 'Validate rootless.config.json against the expected schema',
|
|
12
|
+
|
|
13
|
+
async handler(args) {
|
|
14
|
+
const logger = createLogger({ verbose: args.verbose ?? false })
|
|
15
|
+
const containerPath = await resolveContainerPath()
|
|
16
|
+
const configPath = path.join(containerPath, 'rootless.config.json')
|
|
17
|
+
|
|
18
|
+
if (!(await fileExists(configPath))) {
|
|
19
|
+
logger.warn('No rootless.config.json found — nothing to validate')
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const raw = await readJsonFile(configPath)
|
|
24
|
+
const { valid, errors } = validateConfig(raw)
|
|
25
|
+
|
|
26
|
+
if (valid) {
|
|
27
|
+
logger.success('rootless.config.json is valid')
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
errors.forEach(e => logger.fail(`${e.field ? `[${e.field}] ` : ''}${e.message}`))
|
|
32
|
+
process.exitCode = 1
|
|
33
|
+
},
|
|
34
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/*-------- rootless watch — watches .root and incrementally regenerates on change --------*/
|
|
2
|
+
|
|
3
|
+
import { 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
|
+
export default {
|
|
10
|
+
name: 'watch',
|
|
11
|
+
description: 'Watch .root container and regenerate files on change',
|
|
12
|
+
|
|
13
|
+
async handler(args) {
|
|
14
|
+
const options = {
|
|
15
|
+
yes: true,
|
|
16
|
+
verbose: args.verbose ?? false,
|
|
17
|
+
silent: args.silent ?? false,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ctx = await buildContext(options)
|
|
21
|
+
const containerPath = await resolveContainerPath()
|
|
22
|
+
|
|
23
|
+
ctx.logger.info(`Watching ${containerPath}`)
|
|
24
|
+
|
|
25
|
+
const dirtySet = createDirtySet(async changed => {
|
|
26
|
+
await runIncrementalRegeneration([...changed], ctx)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const watcher = createWatcher([containerPath], { ignoreInitial: true })
|
|
30
|
+
watcher.on('change', filePath => dirtySet.add(filePath))
|
|
31
|
+
watcher.on('add', filePath => dirtySet.add(filePath))
|
|
32
|
+
watcher.start()
|
|
33
|
+
|
|
34
|
+
process.on('SIGINT', async () => {
|
|
35
|
+
watcher.stop()
|
|
36
|
+
await ctx.dispose()
|
|
37
|
+
process.exit(0)
|
|
38
|
+
})
|
|
39
|
+
},
|
|
40
|
+
}
|
package/src/cli/index.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*-------- CLI application entry — bootstraps commander and routes commands --------*/
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander'
|
|
4
|
+
import { createCommandRegistry } from './commandRegistry.js'
|
|
5
|
+
import { checkVersionCompatibility } from '../core/versionCheck.js'
|
|
6
|
+
import { createLogger } from '../utils/logger.js'
|
|
7
|
+
|
|
8
|
+
async function run(argv) {
|
|
9
|
+
const logger = createLogger()
|
|
10
|
+
const program = new Command()
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name('rootless')
|
|
14
|
+
.description('Store project configs in .root, generate them where tools expect them')
|
|
15
|
+
.version('1.0.0')
|
|
16
|
+
|
|
17
|
+
const registry = createCommandRegistry()
|
|
18
|
+
await registry.discover()
|
|
19
|
+
|
|
20
|
+
for (const cmd of registry.list()) {
|
|
21
|
+
const sub = program.command(cmd.name).description(cmd.description)
|
|
22
|
+
|
|
23
|
+
if (['prepare', 'watch', 'migrate'].includes(cmd.name)) {
|
|
24
|
+
sub.option('--yes', 'Auto-approve all file override prompts')
|
|
25
|
+
sub.option('--no-yes', 'Auto-decline all file override prompts')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
sub.option('--verbose', 'Enable verbose output')
|
|
29
|
+
sub.option('--silent', 'Suppress all output')
|
|
30
|
+
|
|
31
|
+
sub.action(async (options) => {
|
|
32
|
+
try {
|
|
33
|
+
await cmd.handler(options)
|
|
34
|
+
} catch (err) {
|
|
35
|
+
logger.fail(err.message)
|
|
36
|
+
if (options.verbose) process.stderr.write(err.stack + '\n')
|
|
37
|
+
process.exitCode = 1
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const { compatible, projectVersion, cliVersion } = await checkVersionCompatibility().catch(() => ({ compatible: true }))
|
|
44
|
+
if (!compatible) {
|
|
45
|
+
logger.warn(`Container version ${projectVersion} may be incompatible with CLI ${cliVersion}. Run: rootless migrate`)
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await program.parseAsync(argv)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { run }
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/*-------- Shared pipeline functions reused by CLI commands and the public API --------*/
|
|
2
|
+
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { readdir } from 'node:fs/promises'
|
|
5
|
+
import { createContainerLoader } from './containerLoader.js'
|
|
6
|
+
import { createExecutionContext } from './executionContext.js'
|
|
7
|
+
import { createPluginRegistry } from '../plugins/pluginRegistry.js'
|
|
8
|
+
import { loadPlugins } from '../plugins/pluginLoader.js'
|
|
9
|
+
import { validateConfig } from './configSchema.js'
|
|
10
|
+
import { getGraph } from './configGraph.js'
|
|
11
|
+
import { generateFiles } from './generator.js'
|
|
12
|
+
import { readManifest, removeFromManifest } from './generatedManifest.js'
|
|
13
|
+
import { removeHash } from './fileHashCache.js'
|
|
14
|
+
import { resolveProjectRoot, resolveContainerPath, setContainerOverride } from './pathResolver.js'
|
|
15
|
+
import { fileExists, removeFile } from '../utils/fsUtils.js'
|
|
16
|
+
import { ValidationError } from '../types/errors.js'
|
|
17
|
+
|
|
18
|
+
async function loadConfig(containerPath) {
|
|
19
|
+
const manifestPath = path.join(containerPath, 'rootless.config.json')
|
|
20
|
+
if (!(await fileExists(manifestPath))) return {}
|
|
21
|
+
const { readJsonFile } = await import('../utils/fsUtils.js')
|
|
22
|
+
return readJsonFile(manifestPath)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function buildContext(options = {}) {
|
|
26
|
+
if (options.cwd) {
|
|
27
|
+
const { findProjectRoot } = await import('./rootSearch.js')
|
|
28
|
+
const root = await findProjectRoot(options.cwd)
|
|
29
|
+
const containerPath = path.join(root, '.root')
|
|
30
|
+
setContainerOverride(options.containerPath ?? containerPath)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const pluginRegistry = createPluginRegistry()
|
|
34
|
+
const containerLoader = createContainerLoader({ pluginRegistry })
|
|
35
|
+
const container = await containerLoader.load()
|
|
36
|
+
|
|
37
|
+
const config = container.manifest ?? {}
|
|
38
|
+
const { valid, errors } = validateConfig(config)
|
|
39
|
+
if (!valid) throw errors[0]
|
|
40
|
+
|
|
41
|
+
await loadPlugins(config, pluginRegistry)
|
|
42
|
+
|
|
43
|
+
return createExecutionContext({ container, pluginRegistry, options })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function preparePipeline(options = {}) {
|
|
47
|
+
const ctx = await buildContext(options)
|
|
48
|
+
|
|
49
|
+
const containerPath = await resolveContainerPath()
|
|
50
|
+
const config = ctx.container.manifest ?? {}
|
|
51
|
+
const graph = await getGraph(config)
|
|
52
|
+
|
|
53
|
+
const entries = await buildGenerationEntries(containerPath, graph, ctx)
|
|
54
|
+
const projectRoot = await resolveProjectRoot()
|
|
55
|
+
const mapped = entries.map(e => ({
|
|
56
|
+
...e,
|
|
57
|
+
target: path.join(projectRoot, path.basename(e.source)),
|
|
58
|
+
}))
|
|
59
|
+
|
|
60
|
+
const results = await generateFiles(mapped, options, ctx.logger)
|
|
61
|
+
await ctx.dispose()
|
|
62
|
+
return results
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function buildGenerationEntries(containerPath, graph, ctx) {
|
|
66
|
+
const entries = []
|
|
67
|
+
|
|
68
|
+
for (const node of Object.values(graph.nodes)) {
|
|
69
|
+
const plugin = ctx.pluginRegistry.resolve(node.absolutePath)[0] ?? null
|
|
70
|
+
entries.push({ source: node.absolutePath, mode: graph.mode ?? 'proxy', plugin })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const dirs = ['configs', 'env', 'assets']
|
|
74
|
+
for (const dir of dirs) {
|
|
75
|
+
const dirPath = path.join(containerPath, dir)
|
|
76
|
+
if (!(await fileExists(dirPath))) continue
|
|
77
|
+
const files = await readdir(dirPath, { withFileTypes: true })
|
|
78
|
+
for (const f of files) {
|
|
79
|
+
if (!f.isFile()) continue
|
|
80
|
+
const absPath = path.join(dirPath, f.name)
|
|
81
|
+
const alreadyIncluded = entries.some(e => e.source === absPath)
|
|
82
|
+
if (alreadyIncluded) continue
|
|
83
|
+
const plugin = ctx.pluginRegistry.resolve(absPath)[0] ?? null
|
|
84
|
+
entries.push({ source: absPath, mode: 'proxy', plugin })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return entries
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function cleanPipeline(options = {}) {
|
|
92
|
+
const ctx = await buildContext(options)
|
|
93
|
+
const managed = await readManifest()
|
|
94
|
+
|
|
95
|
+
for (const filePath of managed) {
|
|
96
|
+
await removeFile(filePath)
|
|
97
|
+
await removeHash(filePath)
|
|
98
|
+
await removeFromManifest(filePath)
|
|
99
|
+
ctx.logger.success(`Removed: ${path.basename(filePath)}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await ctx.dispose()
|
|
103
|
+
return { removed: managed }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { preparePipeline, cleanPipeline, buildContext, loadConfig }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/*-------- Config dependency graph with hash-based cache invalidation --------*/
|
|
2
|
+
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileExists, readJsonFile, writeJsonFile } from '../utils/fsUtils.js'
|
|
5
|
+
import { hashObject } from '../utils/hashUtils.js'
|
|
6
|
+
import { resolveCachePath, resolveContainerPath } from './pathResolver.js'
|
|
7
|
+
|
|
8
|
+
const GRAPH_CACHE_FILE = 'graph.json'
|
|
9
|
+
|
|
10
|
+
async function buildGraph(config) {
|
|
11
|
+
const container = await resolveContainerPath()
|
|
12
|
+
const nodes = {}
|
|
13
|
+
|
|
14
|
+
const entries = Object.entries(config).filter(
|
|
15
|
+
([k]) => !['containerPath', 'mode', 'experimental', 'plugins', 'extends'].includes(k)
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
for (const [name, relPath] of entries) {
|
|
19
|
+
if (typeof relPath !== 'string') continue
|
|
20
|
+
nodes[name] = {
|
|
21
|
+
name,
|
|
22
|
+
relPath,
|
|
23
|
+
absolutePath: path.join(container, relPath),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { nodes, configHash: hashObject(config) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function loadCachedGraph() {
|
|
31
|
+
const cachePath = await resolveCachePath(GRAPH_CACHE_FILE)
|
|
32
|
+
if (!(await fileExists(cachePath))) return null
|
|
33
|
+
return readJsonFile(cachePath)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function saveGraph(graph) {
|
|
37
|
+
const cachePath = await resolveCachePath(GRAPH_CACHE_FILE)
|
|
38
|
+
await writeJsonFile(cachePath, graph)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function getGraph(config) {
|
|
42
|
+
const cached = await loadCachedGraph()
|
|
43
|
+
const fresh = await buildGraph(config)
|
|
44
|
+
|
|
45
|
+
if (cached && cached.configHash === fresh.configHash) return cached
|
|
46
|
+
await saveGraph(fresh)
|
|
47
|
+
return fresh
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { buildGraph, loadCachedGraph, saveGraph, getGraph }
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*-------- Validates rootless.config.json structure --------*/
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from '../types/errors.js'
|
|
4
|
+
import { ROOT_CONFIG_SHAPE } from '../types/configTypes.js'
|
|
5
|
+
|
|
6
|
+
const ALLOWED_KEYS = Object.keys(ROOT_CONFIG_SHAPE)
|
|
7
|
+
|
|
8
|
+
function validateConfig(rawConfig) {
|
|
9
|
+
const errors = []
|
|
10
|
+
|
|
11
|
+
if (rawConfig === null || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
|
|
12
|
+
errors.push(new ValidationError('rootless.config.json must be a JSON object', 'root'))
|
|
13
|
+
return { valid: false, errors }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (rawConfig.containerPath !== undefined && typeof rawConfig.containerPath !== 'string') {
|
|
17
|
+
errors.push(new ValidationError('"containerPath" must be a string', 'containerPath'))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (rawConfig.mode !== undefined && !['proxy', 'copy'].includes(rawConfig.mode)) {
|
|
21
|
+
errors.push(new ValidationError('"mode" must be "proxy" or "copy"', 'mode'))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (rawConfig.plugins !== undefined && !Array.isArray(rawConfig.plugins)) {
|
|
25
|
+
errors.push(new ValidationError('"plugins" must be an array', 'plugins'))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (rawConfig.remote !== undefined && !Array.isArray(rawConfig.remote)) {
|
|
29
|
+
errors.push(new ValidationError('"remote" must be an array', 'remote'))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (rawConfig.extends !== undefined && !Array.isArray(rawConfig.extends)) {
|
|
33
|
+
errors.push(new ValidationError('"extends" must be an array', 'extends'))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (rawConfig.experimental !== undefined) {
|
|
37
|
+
if (typeof rawConfig.experimental !== 'object' || Array.isArray(rawConfig.experimental)) {
|
|
38
|
+
errors.push(new ValidationError('"experimental" must be an object', 'experimental'))
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { valid: errors.length === 0, errors }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { validateConfig, ALLOWED_KEYS }
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/*-------- DI factory that wires together a container loading pipeline --------*/
|
|
2
|
+
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { readdir } from 'node:fs/promises'
|
|
5
|
+
import { fileExists, readJsonFile } from '../utils/fsUtils.js'
|
|
6
|
+
import { resolveContainerPath } from './pathResolver.js'
|
|
7
|
+
import { ContainerNotFoundError } from '../types/errors.js'
|
|
8
|
+
|
|
9
|
+
const MANIFEST_FILE = 'rootless.config.json'
|
|
10
|
+
const VERSION_FILE = 'rootless.version'
|
|
11
|
+
|
|
12
|
+
const KNOWN_DIRS = { env: 'env', configs: 'configs', assets: 'assets' }
|
|
13
|
+
|
|
14
|
+
function createContainerLoader({ cache, resolver, graph, pluginRegistry } = {}) {
|
|
15
|
+
async function load() {
|
|
16
|
+
const containerPath = await resolveContainerPath()
|
|
17
|
+
|
|
18
|
+
if (!(await fileExists(containerPath))) {
|
|
19
|
+
throw new ContainerNotFoundError(containerPath)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const manifestPath = path.join(containerPath, MANIFEST_FILE)
|
|
23
|
+
const versionPath = path.join(containerPath, VERSION_FILE)
|
|
24
|
+
|
|
25
|
+
const [manifest, version, autoFiles] = await Promise.all([
|
|
26
|
+
fileExists(manifestPath).then(e => e ? readJsonFile(manifestPath) : null),
|
|
27
|
+
fileExists(versionPath).then(e => e ? readFile(versionPath) : null),
|
|
28
|
+
discoverFiles(containerPath),
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
containerPath,
|
|
33
|
+
manifest,
|
|
34
|
+
version: version ? String(version).trim() : null,
|
|
35
|
+
files: autoFiles,
|
|
36
|
+
cache,
|
|
37
|
+
resolver,
|
|
38
|
+
graph,
|
|
39
|
+
pluginRegistry,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { load }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function discoverFiles(containerPath) {
|
|
47
|
+
const results = {}
|
|
48
|
+
|
|
49
|
+
for (const [key, dir] of Object.entries(KNOWN_DIRS)) {
|
|
50
|
+
const dirPath = path.join(containerPath, dir)
|
|
51
|
+
if (!(await fileExists(dirPath))) continue
|
|
52
|
+
|
|
53
|
+
const entries = await readdir(dirPath, { withFileTypes: true })
|
|
54
|
+
results[key] = entries
|
|
55
|
+
.filter(e => e.isFile())
|
|
56
|
+
.map(e => path.join(dirPath, e.name))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return results
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function readFile(filePath) {
|
|
63
|
+
const { readFile: fsRead } = await import('node:fs/promises')
|
|
64
|
+
return fsRead(filePath, 'utf8')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { createContainerLoader, discoverFiles }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/*-------- Isolated execution context per CLI command invocation --------*/
|
|
2
|
+
|
|
3
|
+
import { createLogger } from '../utils/logger.js'
|
|
4
|
+
|
|
5
|
+
function createExecutionContext({ container, cache, pluginRegistry, options = {} } = {}) {
|
|
6
|
+
const verbose = options.verbose ?? false
|
|
7
|
+
const silent = options.silent ?? false
|
|
8
|
+
const logger = createLogger({ verbose, silent })
|
|
9
|
+
|
|
10
|
+
let disposed = false
|
|
11
|
+
|
|
12
|
+
async function dispose() {
|
|
13
|
+
if (disposed) return
|
|
14
|
+
disposed = true
|
|
15
|
+
|
|
16
|
+
const plugins = pluginRegistry?.getAll() ?? []
|
|
17
|
+
for (const plugin of plugins) {
|
|
18
|
+
if (typeof plugin.teardown === 'function') {
|
|
19
|
+
await plugin.teardown({ logger })
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { container, cache, pluginRegistry, logger, options, dispose }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export { createExecutionContext }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/*-------- Per-file SHA-256 hash cache stored in .root/.cache/files.json --------*/
|
|
2
|
+
|
|
3
|
+
import { fileExists, readJsonFile, writeJsonFile } from '../utils/fsUtils.js'
|
|
4
|
+
import { hashFile } from '../utils/hashUtils.js'
|
|
5
|
+
import { resolveCachePath } from './pathResolver.js'
|
|
6
|
+
|
|
7
|
+
const CACHE_FILE = 'files.json'
|
|
8
|
+
|
|
9
|
+
async function loadHashCache() {
|
|
10
|
+
const cachePath = await resolveCachePath(CACHE_FILE)
|
|
11
|
+
if (!(await fileExists(cachePath))) return { files: {} }
|
|
12
|
+
return readJsonFile(cachePath)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function saveHashCache(cache) {
|
|
16
|
+
const cachePath = await resolveCachePath(CACHE_FILE)
|
|
17
|
+
await writeJsonFile(cachePath, cache)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function computeAndCompare(filePath) {
|
|
21
|
+
const cache = await loadHashCache()
|
|
22
|
+
const newHash = await hashFile(filePath)
|
|
23
|
+
const prev = cache.files[filePath]
|
|
24
|
+
return { changed: prev !== newHash, newHash }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function updateHash(filePath, hash) {
|
|
28
|
+
const cache = await loadHashCache()
|
|
29
|
+
cache.files[filePath] = hash
|
|
30
|
+
await saveHashCache(cache)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function removeHash(filePath) {
|
|
34
|
+
const cache = await loadHashCache()
|
|
35
|
+
delete cache.files[filePath]
|
|
36
|
+
await saveHashCache(cache)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { loadHashCache, saveHashCache, computeAndCompare, updateHash, removeHash }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/*-------- Tracks generated files in .root/.generated.json --------*/
|
|
2
|
+
|
|
3
|
+
import { fileExists, readJsonFile, writeJsonFile } from '../utils/fsUtils.js'
|
|
4
|
+
import { resolveGeneratedFilePath } from './pathResolver.js'
|
|
5
|
+
|
|
6
|
+
async function readManifest() {
|
|
7
|
+
const manifestPath = await resolveGeneratedFilePath()
|
|
8
|
+
if (!(await fileExists(manifestPath))) return []
|
|
9
|
+
const data = await readJsonFile(manifestPath)
|
|
10
|
+
return Array.isArray(data.generated) ? data.generated : []
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function writeManifest(files) {
|
|
14
|
+
const manifestPath = await resolveGeneratedFilePath()
|
|
15
|
+
await writeJsonFile(manifestPath, { generated: files })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function addToManifest(filePath) {
|
|
19
|
+
const current = await readManifest()
|
|
20
|
+
if (!current.includes(filePath)) {
|
|
21
|
+
await writeManifest([...current, filePath])
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function removeFromManifest(filePath) {
|
|
26
|
+
const current = await readManifest()
|
|
27
|
+
await writeManifest(current.filter(f => f !== filePath))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function isManaged(filePath) {
|
|
31
|
+
const current = await readManifest()
|
|
32
|
+
return current.includes(filePath)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export { readManifest, writeManifest, addToManifest, removeFromManifest, isManaged }
|