@aigne/afs-cli 1.11.0-beta.11 → 1.11.0-beta.12
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/dist/cli.cjs +3 -2
- package/dist/cli.mjs +3 -2
- package/dist/cli.mjs.map +1 -1
- package/dist/config/afs-loader.cjs +64 -315
- package/dist/config/afs-loader.d.cts.map +1 -1
- package/dist/config/afs-loader.d.mts +2 -1
- package/dist/config/afs-loader.d.mts.map +1 -1
- package/dist/config/afs-loader.mjs +59 -310
- package/dist/config/afs-loader.mjs.map +1 -1
- package/dist/config/credential-helpers.cjs +291 -0
- package/dist/config/credential-helpers.d.mts +2 -0
- package/dist/config/credential-helpers.mjs +288 -0
- package/dist/config/credential-helpers.mjs.map +1 -0
- package/dist/config/loader.cjs +3 -1
- package/dist/config/loader.mjs +3 -2
- package/dist/config/loader.mjs.map +1 -1
- package/dist/config/program-install.cjs +276 -0
- package/dist/config/program-install.d.mts +1 -0
- package/dist/config/program-install.mjs +273 -0
- package/dist/config/program-install.mjs.map +1 -0
- package/dist/core/commands/connect.cjs +53 -0
- package/dist/core/commands/connect.d.mts +2 -0
- package/dist/core/commands/connect.mjs +55 -0
- package/dist/core/commands/connect.mjs.map +1 -0
- package/dist/core/commands/daemon.cjs +207 -0
- package/dist/core/commands/daemon.d.mts +2 -0
- package/dist/core/commands/daemon.mjs +208 -0
- package/dist/core/commands/daemon.mjs.map +1 -0
- package/dist/core/commands/explain.cjs +3 -1
- package/dist/core/commands/explain.mjs +3 -1
- package/dist/core/commands/explain.mjs.map +1 -1
- package/dist/core/commands/explore.cjs +47 -12
- package/dist/core/commands/explore.mjs +47 -12
- package/dist/core/commands/explore.mjs.map +1 -1
- package/dist/core/commands/gen-agent-md.cjs +126 -0
- package/dist/core/commands/gen-agent-md.d.mts +2 -0
- package/dist/core/commands/gen-agent-md.mjs +125 -0
- package/dist/core/commands/gen-agent-md.mjs.map +1 -0
- package/dist/core/commands/index.cjs +13 -1
- package/dist/core/commands/index.d.cts.map +1 -1
- package/dist/core/commands/index.d.mts +6 -0
- package/dist/core/commands/index.d.mts.map +1 -1
- package/dist/core/commands/index.mjs +13 -1
- package/dist/core/commands/index.mjs.map +1 -1
- package/dist/core/commands/install.cjs +91 -0
- package/dist/core/commands/install.d.mts +2 -0
- package/dist/core/commands/install.mjs +92 -0
- package/dist/core/commands/install.mjs.map +1 -0
- package/dist/core/commands/ls.cjs +14 -2
- package/dist/core/commands/ls.d.cts +2 -0
- package/dist/core/commands/ls.d.cts.map +1 -1
- package/dist/core/commands/ls.d.mts +2 -0
- package/dist/core/commands/ls.d.mts.map +1 -1
- package/dist/core/commands/ls.mjs +14 -2
- package/dist/core/commands/ls.mjs.map +1 -1
- package/dist/core/commands/mcp-bridge.cjs +201 -0
- package/dist/core/commands/mcp-bridge.d.mts +2 -0
- package/dist/core/commands/mcp-bridge.mjs +201 -0
- package/dist/core/commands/mcp-bridge.mjs.map +1 -0
- package/dist/core/commands/read.cjs +20 -7
- package/dist/core/commands/read.d.cts +2 -0
- package/dist/core/commands/read.d.cts.map +1 -1
- package/dist/core/commands/read.d.mts +2 -0
- package/dist/core/commands/read.d.mts.map +1 -1
- package/dist/core/commands/read.mjs +20 -7
- package/dist/core/commands/read.mjs.map +1 -1
- package/dist/core/commands/search.cjs +5 -1
- package/dist/core/commands/search.mjs +5 -1
- package/dist/core/commands/search.mjs.map +1 -1
- package/dist/core/commands/stat.mjs.map +1 -1
- package/dist/core/commands/types.d.cts +2 -0
- package/dist/core/commands/types.d.cts.map +1 -1
- package/dist/core/commands/types.d.mts +2 -0
- package/dist/core/commands/types.d.mts.map +1 -1
- package/dist/core/commands/types.mjs.map +1 -1
- package/dist/core/commands/vault.cjs +289 -0
- package/dist/core/commands/vault.d.mts +2 -0
- package/dist/core/commands/vault.mjs +289 -0
- package/dist/core/commands/vault.mjs.map +1 -0
- package/dist/core/commands/write.cjs +19 -6
- package/dist/core/commands/write.d.cts +2 -1
- package/dist/core/commands/write.d.cts.map +1 -1
- package/dist/core/commands/write.d.mts +2 -1
- package/dist/core/commands/write.d.mts.map +1 -1
- package/dist/core/commands/write.mjs +19 -6
- package/dist/core/commands/write.mjs.map +1 -1
- package/dist/core/executor/index.cjs +95 -19
- package/dist/core/executor/index.d.cts +4 -0
- package/dist/core/executor/index.d.cts.map +1 -1
- package/dist/core/executor/index.d.mts +4 -0
- package/dist/core/executor/index.d.mts.map +1 -1
- package/dist/core/executor/index.mjs +95 -19
- package/dist/core/executor/index.mjs.map +1 -1
- package/dist/core/formatters/index.d.mts +1 -0
- package/dist/core/formatters/install.cjs +21 -0
- package/dist/core/formatters/install.d.mts +1 -0
- package/dist/core/formatters/install.mjs +19 -0
- package/dist/core/formatters/install.mjs.map +1 -0
- package/dist/core/formatters/vault.cjs +36 -0
- package/dist/core/formatters/vault.mjs +32 -0
- package/dist/core/formatters/vault.mjs.map +1 -0
- package/dist/credential/index.d.mts +2 -1
- package/dist/credential/mcp-auth-context.cjs +21 -5
- package/dist/credential/mcp-auth-context.mjs +21 -5
- package/dist/credential/mcp-auth-context.mjs.map +1 -1
- package/dist/credential/resolver.cjs +7 -2
- package/dist/credential/resolver.mjs +7 -2
- package/dist/credential/resolver.mjs.map +1 -1
- package/dist/credential/vault-store.d.mts +1 -0
- package/dist/daemon/config-manager.cjs +279 -0
- package/dist/daemon/config-manager.mjs +279 -0
- package/dist/daemon/config-manager.mjs.map +1 -0
- package/dist/daemon/manager.cjs +164 -0
- package/dist/daemon/manager.mjs +157 -0
- package/dist/daemon/manager.mjs.map +1 -0
- package/dist/daemon/server.cjs +220 -0
- package/dist/daemon/server.mjs +220 -0
- package/dist/daemon/server.mjs.map +1 -0
- package/dist/mcp/http-transport.cjs +14 -1
- package/dist/mcp/http-transport.mjs +14 -1
- package/dist/mcp/http-transport.mjs.map +1 -1
- package/dist/mcp/server.cjs +4 -2
- package/dist/mcp/server.mjs +4 -2
- package/dist/mcp/server.mjs.map +1 -1
- package/dist/mcp/tools.cjs +62 -12
- package/dist/mcp/tools.mjs +62 -12
- package/dist/mcp/tools.mjs.map +1 -1
- package/dist/program/daemon-integration.cjs +46 -0
- package/dist/program/daemon-integration.mjs +45 -0
- package/dist/program/daemon-integration.mjs.map +1 -0
- package/dist/program/program-manager.cjs +162 -0
- package/dist/program/program-manager.mjs +162 -0
- package/dist/program/program-manager.mjs.map +1 -0
- package/dist/program/trigger-scanner.cjs +148 -0
- package/dist/program/trigger-scanner.mjs +148 -0
- package/dist/program/trigger-scanner.mjs.map +1 -0
- package/dist/providers/vault/dist/_virtual/_@oxc-project_runtime@0.108.0/helpers/decorate.cjs +11 -0
- package/dist/providers/vault/dist/_virtual/_@oxc-project_runtime@0.108.0/helpers/decorate.mjs +11 -0
- package/dist/providers/vault/dist/_virtual/_@oxc-project_runtime@0.108.0/helpers/decorate.mjs.map +1 -0
- package/dist/providers/vault/dist/encrypted-file.cjs +158 -0
- package/dist/providers/vault/dist/encrypted-file.mjs +153 -0
- package/dist/providers/vault/dist/encrypted-file.mjs.map +1 -0
- package/dist/providers/vault/dist/index.cjs +405 -0
- package/dist/providers/vault/dist/index.mjs +400 -0
- package/dist/providers/vault/dist/index.mjs.map +1 -0
- package/dist/providers/vault/dist/key-resolver.cjs +181 -0
- package/dist/providers/vault/dist/key-resolver.mjs +180 -0
- package/dist/providers/vault/dist/key-resolver.mjs.map +1 -0
- package/dist/repl.cjs +105 -14
- package/dist/repl.d.cts.map +1 -1
- package/dist/repl.d.mts.map +1 -1
- package/dist/repl.mjs +105 -14
- package/dist/repl.mjs.map +1 -1
- package/package.json +29 -22
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"loader.mjs","names":[],"sources":["../../src/config/loader.ts"],"sourcesContent":["import { access, readFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport { parse } from \"smol-toml\";\nimport { resolveEnvVarsInObject } from \"./env.js\";\nimport { type AFSConfig, ConfigSchema, type MountConfig, type ServeConfig } from \"./schema.js\";\n\nexport const CONFIG_DIR_NAME = \".afs-config\";\nexport const CONFIG_FILE_NAME = \"config.toml\";\n\nexport interface ConfigLoaderOptions {\n /** Custom path to user-level config directory (for testing) */\n userConfigDir?: string;\n}\n\nexport interface LoadWithSourcesResult {\n config: AFSConfig;\n /** Map from \"namespace:path\" → config directory that defines this mount */\n mountSources: Map<string, string>;\n}\n\n/**\n * Environment variable to override user config directory.\n * Useful for testing to isolate from real user config.\n */\nexport const AFS_USER_CONFIG_DIR_ENV = \"AFS_USER_CONFIG_DIR\";\n\n/**\n * Loads and merges AFS configuration from multiple layers\n *\n * Layer priority (lowest to highest):\n * 1. User-level: ~/.afs-config/config.toml\n * 2. All intermediate directories from project root to cwd\n *\n * Example: if cwd is /project/packages/cli, configs are merged from:\n * ~/.afs-config/config.toml (user)\n * /project/.afs-config/config.toml (project root, has .git)\n * /project/packages/.afs-config/config.toml (intermediate)\n * /project/packages/cli/.afs-config/config.toml (cwd)\n */\nexport class ConfigLoader {\n private userConfigDir: string;\n\n constructor(options: ConfigLoaderOptions = {}) {\n // Priority: options > environment variable > default (~/.afs-config)\n this.userConfigDir =\n options.userConfigDir ??\n process.env[AFS_USER_CONFIG_DIR_ENV] ??\n join(homedir(), CONFIG_DIR_NAME);\n }\n\n /**\n * Load and merge configuration from all layers\n *\n * @param cwd - Current working directory (defaults to process.cwd())\n * @returns Merged configuration\n * @throws Error on invalid config, TOML parse error, or duplicate mount paths\n */\n async load(cwd: string = process.cwd()): Promise<AFSConfig> {\n const result = await this.loadWithSources(cwd);\n return result.config;\n }\n\n /**\n * Load and merge configuration, also returning the config directory for each mount.\n *\n * mountSources maps \"namespace:path\" → config directory path (dirname of config file).\n * Used by credential store to scope credentials per config location.\n */\n async loadWithSources(cwd: string = process.cwd()): Promise<LoadWithSourcesResult> {\n const configPaths = await this.getConfigPaths(cwd);\n const entries: { config: AFSConfig; configDir: string }[] = [];\n\n for (const configPath of configPaths) {\n const config = await this.loadSingleConfig(configPath);\n entries.push({ config, configDir: dirname(configPath) });\n }\n\n return this.mergeConfigsWithSources(entries);\n }\n\n /**\n * Get paths to all existing config files\n *\n * Collects configs from:\n * 1. User-level: ~/.afs-config/config.toml\n * 2. Project root (or topmost .afs-config dir) to cwd: all .afs-config/config.toml files\n */\n async getConfigPaths(cwd: string = process.cwd()): Promise<string[]> {\n const paths: string[] = [];\n\n // 1. User-level config\n const userConfigPath = join(this.userConfigDir, CONFIG_FILE_NAME);\n if (await this.fileExists(userConfigPath)) {\n paths.push(userConfigPath);\n }\n\n // 2. Find project root (look for .git going up)\n const projectRoot = await this.findProjectRoot(cwd);\n\n // 3. Determine start directory\n // If project root found, use it; otherwise find topmost .afs-config directory\n const startDir = projectRoot ?? (await this.findTopmostAfsDir(cwd)) ?? cwd;\n\n // 4. Collect all config files from start to cwd\n // Exclude user config directory to avoid loading it twice\n const intermediatePaths = await this.collectConfigsFromTo(startDir, cwd, this.userConfigDir);\n paths.push(...intermediatePaths);\n\n return paths;\n }\n\n /**\n * Find the topmost directory containing .afs-config from startDir going up\n */\n private async findTopmostAfsDir(startDir: string): Promise<string | null> {\n let currentDir = startDir;\n let topmostAfsDir: string | null = null;\n\n while (true) {\n if (await this.fileExists(join(currentDir, CONFIG_DIR_NAME))) {\n topmostAfsDir = currentDir;\n }\n\n const parentDir = dirname(currentDir);\n if (parentDir === currentDir) {\n // Reached filesystem root\n break;\n }\n currentDir = parentDir;\n }\n\n return topmostAfsDir;\n }\n\n /**\n * Collect all config files from startDir to endDir (inclusive)\n * Returns paths in order from startDir to endDir (parent to child)\n *\n * @param excludeConfigDir - Optional config directory to exclude (to avoid duplicates)\n */\n private async collectConfigsFromTo(\n startDir: string,\n endDir: string,\n excludeConfigDir?: string,\n ): Promise<string[]> {\n const paths: string[] = [];\n\n // Build list of directories from startDir to endDir\n const dirs: string[] = [];\n let current = endDir;\n\n while (true) {\n dirs.unshift(current); // prepend to maintain parent-to-child order\n\n if (current === startDir) {\n break;\n }\n\n const parent = dirname(current);\n if (parent === current) {\n // Reached filesystem root without finding startDir\n // This shouldn't happen if startDir is an ancestor of endDir\n break;\n }\n current = parent;\n }\n\n // Check each directory for config file\n for (const dir of dirs) {\n const configDir = join(dir, CONFIG_DIR_NAME);\n // Skip if this is the excluded config directory (e.g., user config already loaded)\n if (excludeConfigDir && configDir === excludeConfigDir) {\n continue;\n }\n const configPath = join(configDir, CONFIG_FILE_NAME);\n if (await this.fileExists(configPath)) {\n paths.push(configPath);\n }\n }\n\n return paths;\n }\n\n /**\n * Load a single config file\n */\n private async loadSingleConfig(configPath: string): Promise<AFSConfig> {\n const content = await readFile(configPath, \"utf-8\");\n\n let parsed: unknown;\n try {\n parsed = parse(content);\n } catch (error) {\n throw new Error(\n `Failed to parse TOML config at ${configPath}: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n\n // Resolve environment variables with friendly error messages\n let resolved: unknown;\n try {\n resolved = resolveEnvVarsInObject(parsed);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n // Extract variable name from error message like \"Environment variable GITHUB_TOKEN is not defined\"\n const match = message.match(/Environment variable (\\w+) is not defined/);\n if (match) {\n const varName = match[1];\n throw new Error(\n `Missing environment variable ${varName} in ${configPath}.\\n` +\n ` Set it in your shell: export ${varName}=your_value\\n` +\n ` Or add to .env file: ${varName}=your_value`,\n );\n }\n throw new Error(`Failed to resolve environment variables in ${configPath}: ${message}`);\n }\n\n // Validate against schema\n const result = ConfigSchema.safeParse(resolved);\n if (!result.success) {\n const errors = result.error.issues.map((e) => `${e.path.join(\".\")}: ${e.message}`).join(\"; \");\n throw new Error(`Invalid config at ${configPath}: ${errors}`);\n }\n\n return result.data;\n }\n\n /**\n * Create a composite key for namespace+path duplicate detection\n * Uses empty string for undefined namespace (default namespace)\n */\n private makeNamespacePathKey(namespace: string | undefined, path: string): string {\n return `${namespace ?? \"\"}:${path}`;\n }\n\n /**\n * Merge configs with source tracking.\n * Returns merged config plus a map of mount key → config directory.\n */\n private mergeConfigsWithSources(\n entries: { config: AFSConfig; configDir: string }[],\n ): LoadWithSourcesResult {\n const mountIndexByKey = new Map<string, number>();\n const allMounts: MountConfig[] = [];\n const mountSources = new Map<string, string>();\n let mergedServe: ServeConfig | undefined;\n\n for (const { config, configDir } of entries) {\n for (const mount of config.mounts) {\n const key = this.makeNamespacePathKey(mount.namespace, mount.path);\n const existingIndex = mountIndexByKey.get(key);\n if (existingIndex !== undefined) {\n allMounts[existingIndex] = mount;\n } else {\n mountIndexByKey.set(key, allMounts.length);\n allMounts.push(mount);\n }\n // Track source (child overrides parent)\n mountSources.set(key, configDir);\n }\n\n if (config.serve) {\n mergedServe = mergedServe ? { ...mergedServe, ...config.serve } : config.serve;\n }\n }\n\n return {\n config: { mounts: allMounts, serve: mergedServe },\n mountSources,\n };\n }\n\n /**\n * Find project root by looking for .git\n * Note: Only .git is used as project root marker, not .afs-config,\n * because .afs-config can exist at multiple levels for hierarchical config\n */\n async findProjectRoot(startDir: string): Promise<string | null> {\n let currentDir = startDir;\n\n while (true) {\n // Check for .git directory\n if (await this.fileExists(join(currentDir, \".git\"))) {\n return currentDir;\n }\n\n const parentDir = dirname(currentDir);\n if (parentDir === currentDir) {\n // Reached filesystem root\n return null;\n }\n currentDir = parentDir;\n }\n }\n\n /**\n * Check if a file or directory exists\n */\n private async fileExists(path: string): Promise<boolean> {\n try {\n await access(path);\n return true;\n } catch {\n return false;\n }\n }\n}\n\n// Default singleton instance\nexport const configLoader = new ConfigLoader();\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"loader.mjs","names":[],"sources":["../../src/config/loader.ts"],"sourcesContent":["import { access, readFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport { parse } from \"smol-toml\";\nimport { resolveEnvVarsInObject } from \"./env.js\";\nimport { type AFSConfig, ConfigSchema, type MountConfig, type ServeConfig } from \"./schema.js\";\n\nexport const CONFIG_DIR_NAME = \".afs-config\";\nexport const CONFIG_FILE_NAME = \"config.toml\";\n\nexport interface ConfigLoaderOptions {\n /** Custom path to user-level config directory (for testing) */\n userConfigDir?: string;\n}\n\nexport interface LoadWithSourcesResult {\n config: AFSConfig;\n /** Map from \"namespace:path\" → config directory that defines this mount */\n mountSources: Map<string, string>;\n /** Config directories in order from outermost to innermost */\n configDirs: string[];\n}\n\n/**\n * Environment variable to override user config directory.\n * Useful for testing to isolate from real user config.\n */\nexport const AFS_USER_CONFIG_DIR_ENV = \"AFS_USER_CONFIG_DIR\";\n\n/**\n * Loads and merges AFS configuration from multiple layers\n *\n * Layer priority (lowest to highest):\n * 1. User-level: ~/.afs-config/config.toml\n * 2. All intermediate directories from project root to cwd\n *\n * Example: if cwd is /project/packages/cli, configs are merged from:\n * ~/.afs-config/config.toml (user)\n * /project/.afs-config/config.toml (project root, has .git)\n * /project/packages/.afs-config/config.toml (intermediate)\n * /project/packages/cli/.afs-config/config.toml (cwd)\n */\nexport class ConfigLoader {\n private userConfigDir: string;\n\n constructor(options: ConfigLoaderOptions = {}) {\n // Priority: options > environment variable > default (~/.afs-config)\n this.userConfigDir =\n options.userConfigDir ??\n process.env[AFS_USER_CONFIG_DIR_ENV] ??\n join(homedir(), CONFIG_DIR_NAME);\n }\n\n /**\n * Load and merge configuration from all layers\n *\n * @param cwd - Current working directory (defaults to process.cwd())\n * @returns Merged configuration\n * @throws Error on invalid config, TOML parse error, or duplicate mount paths\n */\n async load(cwd: string = process.cwd()): Promise<AFSConfig> {\n const result = await this.loadWithSources(cwd);\n return result.config;\n }\n\n /**\n * Load and merge configuration, also returning the config directory for each mount.\n *\n * mountSources maps \"namespace:path\" → config directory path (dirname of config file).\n * Used by credential store to scope credentials per config location.\n */\n async loadWithSources(cwd: string = process.cwd()): Promise<LoadWithSourcesResult> {\n const configPaths = await this.getConfigPaths(cwd);\n const entries: { config: AFSConfig; configDir: string }[] = [];\n\n for (const configPath of configPaths) {\n const config = await this.loadSingleConfig(configPath);\n entries.push({ config, configDir: dirname(configPath) });\n }\n\n return this.mergeConfigsWithSources(entries);\n }\n\n /**\n * Get paths to all existing config files\n *\n * Collects configs from:\n * 1. User-level: ~/.afs-config/config.toml\n * 2. Project root (or topmost .afs-config dir) to cwd: all .afs-config/config.toml files\n */\n async getConfigPaths(cwd: string = process.cwd()): Promise<string[]> {\n const paths: string[] = [];\n\n // 1. User-level config\n const userConfigPath = join(this.userConfigDir, CONFIG_FILE_NAME);\n if (await this.fileExists(userConfigPath)) {\n paths.push(userConfigPath);\n }\n\n // 2. Find project root (look for .git going up)\n const projectRoot = await this.findProjectRoot(cwd);\n\n // 3. Determine start directory\n // If project root found, use it; otherwise find topmost .afs-config directory\n const startDir = projectRoot ?? (await this.findTopmostAfsDir(cwd)) ?? cwd;\n\n // 4. Collect all config files from start to cwd\n // Exclude user config directory to avoid loading it twice\n const intermediatePaths = await this.collectConfigsFromTo(startDir, cwd, this.userConfigDir);\n paths.push(...intermediatePaths);\n\n return paths;\n }\n\n /**\n * Find the topmost directory containing .afs-config from startDir going up\n */\n private async findTopmostAfsDir(startDir: string): Promise<string | null> {\n let currentDir = startDir;\n let topmostAfsDir: string | null = null;\n\n while (true) {\n if (await this.fileExists(join(currentDir, CONFIG_DIR_NAME))) {\n topmostAfsDir = currentDir;\n }\n\n const parentDir = dirname(currentDir);\n if (parentDir === currentDir) {\n // Reached filesystem root\n break;\n }\n currentDir = parentDir;\n }\n\n return topmostAfsDir;\n }\n\n /**\n * Collect all config files from startDir to endDir (inclusive)\n * Returns paths in order from startDir to endDir (parent to child)\n *\n * @param excludeConfigDir - Optional config directory to exclude (to avoid duplicates)\n */\n private async collectConfigsFromTo(\n startDir: string,\n endDir: string,\n excludeConfigDir?: string,\n ): Promise<string[]> {\n const paths: string[] = [];\n\n // Build list of directories from startDir to endDir\n const dirs: string[] = [];\n let current = endDir;\n\n while (true) {\n dirs.unshift(current); // prepend to maintain parent-to-child order\n\n if (current === startDir) {\n break;\n }\n\n const parent = dirname(current);\n if (parent === current) {\n // Reached filesystem root without finding startDir\n // This shouldn't happen if startDir is an ancestor of endDir\n break;\n }\n current = parent;\n }\n\n // Check each directory for config file\n for (const dir of dirs) {\n const configDir = join(dir, CONFIG_DIR_NAME);\n // Skip if this is the excluded config directory (e.g., user config already loaded)\n if (excludeConfigDir && configDir === excludeConfigDir) {\n continue;\n }\n const configPath = join(configDir, CONFIG_FILE_NAME);\n if (await this.fileExists(configPath)) {\n paths.push(configPath);\n }\n }\n\n return paths;\n }\n\n /**\n * Load a single config file\n */\n private async loadSingleConfig(configPath: string): Promise<AFSConfig> {\n const content = await readFile(configPath, \"utf-8\");\n\n let parsed: unknown;\n try {\n parsed = parse(content);\n } catch (error) {\n throw new Error(\n `Failed to parse TOML config at ${configPath}: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n\n // Resolve environment variables with friendly error messages\n let resolved: unknown;\n try {\n resolved = resolveEnvVarsInObject(parsed);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n // Extract variable name from error message like \"Environment variable GITHUB_TOKEN is not defined\"\n const match = message.match(/Environment variable (\\w+) is not defined/);\n if (match) {\n const varName = match[1];\n throw new Error(\n `Missing environment variable ${varName} in ${configPath}.\\n` +\n ` Set it in your shell: export ${varName}=your_value\\n` +\n ` Or add to .env file: ${varName}=your_value`,\n );\n }\n throw new Error(`Failed to resolve environment variables in ${configPath}: ${message}`);\n }\n\n // Validate against schema\n const result = ConfigSchema.safeParse(resolved);\n if (!result.success) {\n const errors = result.error.issues.map((e) => `${e.path.join(\".\")}: ${e.message}`).join(\"; \");\n throw new Error(`Invalid config at ${configPath}: ${errors}`);\n }\n\n return result.data;\n }\n\n /**\n * Create a composite key for namespace+path duplicate detection\n * Uses empty string for undefined namespace (default namespace)\n */\n private makeNamespacePathKey(namespace: string | undefined, path: string): string {\n return `${namespace ?? \"\"}:${path}`;\n }\n\n /**\n * Merge configs with source tracking.\n * Returns merged config plus a map of mount key → config directory.\n */\n private mergeConfigsWithSources(\n entries: { config: AFSConfig; configDir: string }[],\n ): LoadWithSourcesResult {\n const mountIndexByKey = new Map<string, number>();\n const allMounts: MountConfig[] = [];\n const mountSources = new Map<string, string>();\n let mergedServe: ServeConfig | undefined;\n\n for (const { config, configDir } of entries) {\n for (const mount of config.mounts) {\n const key = this.makeNamespacePathKey(mount.namespace, mount.path);\n const existingIndex = mountIndexByKey.get(key);\n if (existingIndex !== undefined) {\n allMounts[existingIndex] = mount;\n } else {\n mountIndexByKey.set(key, allMounts.length);\n allMounts.push(mount);\n }\n // Track source (child overrides parent)\n mountSources.set(key, configDir);\n }\n\n if (config.serve) {\n mergedServe = mergedServe ? { ...mergedServe, ...config.serve } : config.serve;\n }\n }\n\n return {\n config: { mounts: allMounts, serve: mergedServe },\n mountSources,\n configDirs: entries.map((e) => e.configDir),\n };\n }\n\n /**\n * Find project root by looking for .git\n * Note: Only .git is used as project root marker, not .afs-config,\n * because .afs-config can exist at multiple levels for hierarchical config\n */\n async findProjectRoot(startDir: string): Promise<string | null> {\n let currentDir = startDir;\n\n while (true) {\n // Check for .git directory\n if (await this.fileExists(join(currentDir, \".git\"))) {\n return currentDir;\n }\n\n const parentDir = dirname(currentDir);\n if (parentDir === currentDir) {\n // Reached filesystem root\n return null;\n }\n currentDir = parentDir;\n }\n }\n\n /**\n * Check if a file or directory exists\n */\n private async fileExists(path: string): Promise<boolean> {\n try {\n await access(path);\n return true;\n } catch {\n return false;\n }\n }\n}\n\n// Default singleton instance\nexport const configLoader = new ConfigLoader();\n"],"mappings":"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"}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
const require_rolldown_runtime = require('../_virtual/rolldown_runtime.cjs');
|
|
2
|
+
const require_loader = require('./loader.cjs');
|
|
3
|
+
const require_mount_commands = require('./mount-commands.cjs');
|
|
4
|
+
let node_child_process = require("node:child_process");
|
|
5
|
+
let node_fs_promises = require("node:fs/promises");
|
|
6
|
+
let node_os = require("node:os");
|
|
7
|
+
let node_path = require("node:path");
|
|
8
|
+
let _aigne_afs = require("@aigne/afs");
|
|
9
|
+
|
|
10
|
+
//#region src/config/program-install.ts
|
|
11
|
+
/**
|
|
12
|
+
* Program Installation
|
|
13
|
+
*
|
|
14
|
+
* Business logic for installing, listing, and removing AFS programs.
|
|
15
|
+
* Programs are installed to ~/.afs-config/programs/<id>/ and mounted
|
|
16
|
+
* at /programs/<id> in the CWD config (same as `mount add`).
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Get the user config directory.
|
|
20
|
+
*/
|
|
21
|
+
function getUserConfigDir(override) {
|
|
22
|
+
return override ?? process.env[require_loader.AFS_USER_CONFIG_DIR_ENV] ?? (0, node_path.join)((0, node_os.homedir)(), require_loader.CONFIG_DIR_NAME);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Detect the source type from a source string.
|
|
26
|
+
*/
|
|
27
|
+
function detectSourceType(source) {
|
|
28
|
+
if (source.endsWith(".zip")) return "zip";
|
|
29
|
+
if (source.startsWith("https://github.com/") || source.startsWith("http://github.com/") || source.startsWith("github.com/")) return "github";
|
|
30
|
+
return "local";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Parse a GitHub URL into owner, repo, ref, and optional subdirectory.
|
|
34
|
+
*/
|
|
35
|
+
function parseGitHubURL(url) {
|
|
36
|
+
let normalized = url;
|
|
37
|
+
if (normalized.startsWith("github.com/")) normalized = `https://${normalized}`;
|
|
38
|
+
const match = normalized.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/([^/]+)\/(.+))?$/);
|
|
39
|
+
if (!match) return { cloneUrl: normalized.replace(/\/+$/, "") };
|
|
40
|
+
const [, owner, repo, _ref, subdir] = match;
|
|
41
|
+
return {
|
|
42
|
+
cloneUrl: `https://github.com/${owner}/${repo}.git`,
|
|
43
|
+
subdir: subdir || void 0
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolve a source to a local directory path containing program.yaml.
|
|
48
|
+
* For github/zip sources, downloads/extracts to a temp directory.
|
|
49
|
+
*
|
|
50
|
+
* Returns [resolvedPath, tempDir?] — tempDir is set if caller should clean up.
|
|
51
|
+
*/
|
|
52
|
+
async function resolveSource(source, type, cwd) {
|
|
53
|
+
switch (type) {
|
|
54
|
+
case "local": {
|
|
55
|
+
const dirPath = (0, node_path.isAbsolute)(source) ? source : (0, node_path.resolve)(cwd, source);
|
|
56
|
+
try {
|
|
57
|
+
await (0, node_fs_promises.readdir)(dirPath);
|
|
58
|
+
} catch {
|
|
59
|
+
throw new Error(`Source directory does not exist: ${dirPath}`);
|
|
60
|
+
}
|
|
61
|
+
return [dirPath, void 0];
|
|
62
|
+
}
|
|
63
|
+
case "github": {
|
|
64
|
+
const { cloneUrl, subdir } = parseGitHubURL(source);
|
|
65
|
+
const tempDir = (0, node_path.join)((0, node_os.tmpdir)(), `afs-install-${Date.now()}`);
|
|
66
|
+
await (0, node_fs_promises.mkdir)(tempDir, { recursive: true });
|
|
67
|
+
try {
|
|
68
|
+
(0, node_child_process.execSync)(`git clone --depth 1 ${cloneUrl} ${(0, node_path.join)(tempDir, "repo")}`, {
|
|
69
|
+
stdio: "pipe",
|
|
70
|
+
timeout: 6e4
|
|
71
|
+
});
|
|
72
|
+
} catch (err) {
|
|
73
|
+
await (0, node_fs_promises.rm)(tempDir, {
|
|
74
|
+
recursive: true,
|
|
75
|
+
force: true
|
|
76
|
+
});
|
|
77
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
78
|
+
throw new Error(`Failed to clone GitHub repository: ${msg}`);
|
|
79
|
+
}
|
|
80
|
+
return [subdir ? (0, node_path.join)(tempDir, "repo", subdir) : (0, node_path.join)(tempDir, "repo"), tempDir];
|
|
81
|
+
}
|
|
82
|
+
case "zip": {
|
|
83
|
+
const zipPath = (0, node_path.isAbsolute)(source) ? source : (0, node_path.resolve)(cwd, source);
|
|
84
|
+
const tempDir = (0, node_path.join)((0, node_os.tmpdir)(), `afs-install-${Date.now()}`);
|
|
85
|
+
const extractDir = (0, node_path.join)(tempDir, "extracted");
|
|
86
|
+
await (0, node_fs_promises.mkdir)(extractDir, { recursive: true });
|
|
87
|
+
try {
|
|
88
|
+
const lines = (0, node_child_process.execSync)(`unzip -l "${zipPath}"`, {
|
|
89
|
+
encoding: "utf-8",
|
|
90
|
+
stdio: [
|
|
91
|
+
"pipe",
|
|
92
|
+
"pipe",
|
|
93
|
+
"pipe"
|
|
94
|
+
]
|
|
95
|
+
}).split("\n");
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
const trimmed = line.trim();
|
|
98
|
+
if (!trimmed || trimmed.startsWith("Archive:") || trimmed.startsWith("Length") || trimmed.startsWith("---")) continue;
|
|
99
|
+
const parts = trimmed.split(/\s+/);
|
|
100
|
+
if (parts.length >= 4) {
|
|
101
|
+
const name = parts.slice(3).join(" ");
|
|
102
|
+
if (name.includes("..") || name.startsWith("/")) {
|
|
103
|
+
await (0, node_fs_promises.rm)(tempDir, {
|
|
104
|
+
recursive: true,
|
|
105
|
+
force: true
|
|
106
|
+
});
|
|
107
|
+
throw new Error(`Zip file contains unsafe path: ${name}. Refusing to extract.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (err instanceof Error && err.message.includes("unsafe path")) throw err;
|
|
113
|
+
await (0, node_fs_promises.rm)(tempDir, {
|
|
114
|
+
recursive: true,
|
|
115
|
+
force: true
|
|
116
|
+
});
|
|
117
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
118
|
+
throw new Error(`Failed to read zip file: ${msg}`);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
(0, node_child_process.execSync)(`unzip -o "${zipPath}" -d "${extractDir}"`, {
|
|
122
|
+
stdio: "pipe",
|
|
123
|
+
timeout: 3e4
|
|
124
|
+
});
|
|
125
|
+
} catch (err) {
|
|
126
|
+
await (0, node_fs_promises.rm)(tempDir, {
|
|
127
|
+
recursive: true,
|
|
128
|
+
force: true
|
|
129
|
+
});
|
|
130
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
131
|
+
throw new Error(`Failed to extract zip file: ${msg}`);
|
|
132
|
+
}
|
|
133
|
+
const entries = await (0, node_fs_promises.readdir)(extractDir);
|
|
134
|
+
if (entries.includes("program.yaml")) return [extractDir, tempDir];
|
|
135
|
+
if (entries.length === 1) {
|
|
136
|
+
const subPath = (0, node_path.join)(extractDir, entries[0]);
|
|
137
|
+
try {
|
|
138
|
+
if ((await (0, node_fs_promises.readdir)(subPath)).includes("program.yaml")) return [subPath, tempDir];
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
await (0, node_fs_promises.rm)(tempDir, {
|
|
142
|
+
recursive: true,
|
|
143
|
+
force: true
|
|
144
|
+
});
|
|
145
|
+
throw new Error("No program.yaml found in zip archive");
|
|
146
|
+
}
|
|
147
|
+
default: throw new Error(`Unsupported source type: ${type}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Validate a directory contains a valid program.yaml.
|
|
152
|
+
* Returns the parsed manifest.
|
|
153
|
+
*/
|
|
154
|
+
async function validateProgramDir(dirPath) {
|
|
155
|
+
const yamlPath = (0, node_path.join)(dirPath, "program.yaml");
|
|
156
|
+
let content;
|
|
157
|
+
try {
|
|
158
|
+
content = await (0, node_fs_promises.readFile)(yamlPath, "utf-8");
|
|
159
|
+
} catch {
|
|
160
|
+
throw new Error(`No program.yaml found in ${dirPath}`);
|
|
161
|
+
}
|
|
162
|
+
return (0, _aigne_afs.parseProgramManifest)(content);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Install a program from a source (local dir, GitHub URL, or zip file).
|
|
166
|
+
*/
|
|
167
|
+
async function installProgram(source, options) {
|
|
168
|
+
const userConfigDir = getUserConfigDir(options?.userConfigDir);
|
|
169
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
170
|
+
const [resolvedPath, tempDir] = await resolveSource(source, detectSourceType(source), cwd);
|
|
171
|
+
try {
|
|
172
|
+
const manifest = await validateProgramDir(resolvedPath);
|
|
173
|
+
const programsDir = (0, node_path.join)(userConfigDir, "programs");
|
|
174
|
+
const installPath = (0, node_path.join)(programsDir, manifest.id);
|
|
175
|
+
const mountPath = `/programs/${manifest.id}`;
|
|
176
|
+
await (0, node_fs_promises.mkdir)(programsDir, { recursive: true });
|
|
177
|
+
const tempInstallPath = `${installPath}.installing`;
|
|
178
|
+
await (0, node_fs_promises.rm)(tempInstallPath, {
|
|
179
|
+
recursive: true,
|
|
180
|
+
force: true
|
|
181
|
+
});
|
|
182
|
+
await (0, node_fs_promises.cp)(resolvedPath, tempInstallPath, { recursive: true });
|
|
183
|
+
await (0, node_fs_promises.rm)(installPath, {
|
|
184
|
+
recursive: true,
|
|
185
|
+
force: true
|
|
186
|
+
});
|
|
187
|
+
await (0, node_fs_promises.rename)(tempInstallPath, installPath);
|
|
188
|
+
await (0, node_fs_promises.mkdir)((0, node_path.join)(userConfigDir, "data", manifest.id), { recursive: true });
|
|
189
|
+
await require_mount_commands.persistMount(cwd, {
|
|
190
|
+
path: mountPath,
|
|
191
|
+
uri: `fs://${installPath}`
|
|
192
|
+
});
|
|
193
|
+
const { notifyDaemonReload } = await Promise.resolve().then(() => require("../program/daemon-integration.cjs"));
|
|
194
|
+
const { getDaemonStatus } = await Promise.resolve().then(() => require("../daemon/manager.cjs"));
|
|
195
|
+
await notifyDaemonReload({ getDaemonStatus });
|
|
196
|
+
return {
|
|
197
|
+
success: true,
|
|
198
|
+
programId: manifest.id,
|
|
199
|
+
programName: manifest.name,
|
|
200
|
+
installPath,
|
|
201
|
+
mountPath
|
|
202
|
+
};
|
|
203
|
+
} finally {
|
|
204
|
+
if (tempDir) await (0, node_fs_promises.rm)(tempDir, {
|
|
205
|
+
recursive: true,
|
|
206
|
+
force: true
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* List installed programs by scanning ~/.afs-config/programs/.
|
|
212
|
+
*/
|
|
213
|
+
async function listInstalledPrograms(options) {
|
|
214
|
+
const programsDir = (0, node_path.join)(getUserConfigDir(options?.userConfigDir), "programs");
|
|
215
|
+
let entries;
|
|
216
|
+
try {
|
|
217
|
+
entries = await (0, node_fs_promises.readdir)(programsDir);
|
|
218
|
+
} catch {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
const programs = [];
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
const dirPath = (0, node_path.join)(programsDir, entry);
|
|
224
|
+
try {
|
|
225
|
+
const manifest = await validateProgramDir(dirPath);
|
|
226
|
+
programs.push({
|
|
227
|
+
id: manifest.id,
|
|
228
|
+
name: manifest.name,
|
|
229
|
+
entrypoint: manifest.entrypoint,
|
|
230
|
+
installPath: dirPath,
|
|
231
|
+
mountPath: `/programs/${manifest.id}`
|
|
232
|
+
});
|
|
233
|
+
} catch {}
|
|
234
|
+
}
|
|
235
|
+
return programs;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Remove an installed program.
|
|
239
|
+
*/
|
|
240
|
+
async function removeProgram(programId, options) {
|
|
241
|
+
const userConfigDir = getUserConfigDir(options?.userConfigDir);
|
|
242
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
243
|
+
const installPath = (0, node_path.join)(userConfigDir, "programs", programId);
|
|
244
|
+
try {
|
|
245
|
+
await (0, node_fs_promises.readdir)(installPath);
|
|
246
|
+
} catch {
|
|
247
|
+
throw new Error(`Program "${programId}" is not installed`);
|
|
248
|
+
}
|
|
249
|
+
await (0, node_fs_promises.rm)(installPath, {
|
|
250
|
+
recursive: true,
|
|
251
|
+
force: true
|
|
252
|
+
});
|
|
253
|
+
await require_mount_commands.unpersistMount(cwd, `/programs/${programId}`);
|
|
254
|
+
let purgedData = false;
|
|
255
|
+
if (options?.purge) {
|
|
256
|
+
await (0, node_fs_promises.rm)((0, node_path.join)(userConfigDir, "data", programId), {
|
|
257
|
+
recursive: true,
|
|
258
|
+
force: true
|
|
259
|
+
});
|
|
260
|
+
purgedData = true;
|
|
261
|
+
}
|
|
262
|
+
const { notifyDaemonReload } = await Promise.resolve().then(() => require("../program/daemon-integration.cjs"));
|
|
263
|
+
const { getDaemonStatus } = await Promise.resolve().then(() => require("../daemon/manager.cjs"));
|
|
264
|
+
await notifyDaemonReload({ getDaemonStatus });
|
|
265
|
+
return {
|
|
266
|
+
success: true,
|
|
267
|
+
programId,
|
|
268
|
+
purgedData
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
//#endregion
|
|
273
|
+
exports.getUserConfigDir = getUserConfigDir;
|
|
274
|
+
exports.installProgram = installProgram;
|
|
275
|
+
exports.listInstalledPrograms = listInstalledPrograms;
|
|
276
|
+
exports.removeProgram = removeProgram;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "@aigne/afs";
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { AFS_USER_CONFIG_DIR_ENV, CONFIG_DIR_NAME } from "./loader.mjs";
|
|
2
|
+
import { persistMount, unpersistMount } from "./mount-commands.mjs";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { cp, mkdir, readFile, readdir, rename, rm } from "node:fs/promises";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
import { parseProgramManifest } from "@aigne/afs";
|
|
8
|
+
|
|
9
|
+
//#region src/config/program-install.ts
|
|
10
|
+
/**
|
|
11
|
+
* Program Installation
|
|
12
|
+
*
|
|
13
|
+
* Business logic for installing, listing, and removing AFS programs.
|
|
14
|
+
* Programs are installed to ~/.afs-config/programs/<id>/ and mounted
|
|
15
|
+
* at /programs/<id> in the CWD config (same as `mount add`).
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Get the user config directory.
|
|
19
|
+
*/
|
|
20
|
+
function getUserConfigDir(override) {
|
|
21
|
+
return override ?? process.env[AFS_USER_CONFIG_DIR_ENV] ?? join(homedir(), CONFIG_DIR_NAME);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Detect the source type from a source string.
|
|
25
|
+
*/
|
|
26
|
+
function detectSourceType(source) {
|
|
27
|
+
if (source.endsWith(".zip")) return "zip";
|
|
28
|
+
if (source.startsWith("https://github.com/") || source.startsWith("http://github.com/") || source.startsWith("github.com/")) return "github";
|
|
29
|
+
return "local";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Parse a GitHub URL into owner, repo, ref, and optional subdirectory.
|
|
33
|
+
*/
|
|
34
|
+
function parseGitHubURL(url) {
|
|
35
|
+
let normalized = url;
|
|
36
|
+
if (normalized.startsWith("github.com/")) normalized = `https://${normalized}`;
|
|
37
|
+
const match = normalized.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/([^/]+)\/(.+))?$/);
|
|
38
|
+
if (!match) return { cloneUrl: normalized.replace(/\/+$/, "") };
|
|
39
|
+
const [, owner, repo, _ref, subdir] = match;
|
|
40
|
+
return {
|
|
41
|
+
cloneUrl: `https://github.com/${owner}/${repo}.git`,
|
|
42
|
+
subdir: subdir || void 0
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve a source to a local directory path containing program.yaml.
|
|
47
|
+
* For github/zip sources, downloads/extracts to a temp directory.
|
|
48
|
+
*
|
|
49
|
+
* Returns [resolvedPath, tempDir?] — tempDir is set if caller should clean up.
|
|
50
|
+
*/
|
|
51
|
+
async function resolveSource(source, type, cwd) {
|
|
52
|
+
switch (type) {
|
|
53
|
+
case "local": {
|
|
54
|
+
const dirPath = isAbsolute(source) ? source : resolve(cwd, source);
|
|
55
|
+
try {
|
|
56
|
+
await readdir(dirPath);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error(`Source directory does not exist: ${dirPath}`);
|
|
59
|
+
}
|
|
60
|
+
return [dirPath, void 0];
|
|
61
|
+
}
|
|
62
|
+
case "github": {
|
|
63
|
+
const { cloneUrl, subdir } = parseGitHubURL(source);
|
|
64
|
+
const tempDir = join(tmpdir(), `afs-install-${Date.now()}`);
|
|
65
|
+
await mkdir(tempDir, { recursive: true });
|
|
66
|
+
try {
|
|
67
|
+
execSync(`git clone --depth 1 ${cloneUrl} ${join(tempDir, "repo")}`, {
|
|
68
|
+
stdio: "pipe",
|
|
69
|
+
timeout: 6e4
|
|
70
|
+
});
|
|
71
|
+
} catch (err) {
|
|
72
|
+
await rm(tempDir, {
|
|
73
|
+
recursive: true,
|
|
74
|
+
force: true
|
|
75
|
+
});
|
|
76
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
77
|
+
throw new Error(`Failed to clone GitHub repository: ${msg}`);
|
|
78
|
+
}
|
|
79
|
+
return [subdir ? join(tempDir, "repo", subdir) : join(tempDir, "repo"), tempDir];
|
|
80
|
+
}
|
|
81
|
+
case "zip": {
|
|
82
|
+
const zipPath = isAbsolute(source) ? source : resolve(cwd, source);
|
|
83
|
+
const tempDir = join(tmpdir(), `afs-install-${Date.now()}`);
|
|
84
|
+
const extractDir = join(tempDir, "extracted");
|
|
85
|
+
await mkdir(extractDir, { recursive: true });
|
|
86
|
+
try {
|
|
87
|
+
const lines = execSync(`unzip -l "${zipPath}"`, {
|
|
88
|
+
encoding: "utf-8",
|
|
89
|
+
stdio: [
|
|
90
|
+
"pipe",
|
|
91
|
+
"pipe",
|
|
92
|
+
"pipe"
|
|
93
|
+
]
|
|
94
|
+
}).split("\n");
|
|
95
|
+
for (const line of lines) {
|
|
96
|
+
const trimmed = line.trim();
|
|
97
|
+
if (!trimmed || trimmed.startsWith("Archive:") || trimmed.startsWith("Length") || trimmed.startsWith("---")) continue;
|
|
98
|
+
const parts = trimmed.split(/\s+/);
|
|
99
|
+
if (parts.length >= 4) {
|
|
100
|
+
const name = parts.slice(3).join(" ");
|
|
101
|
+
if (name.includes("..") || name.startsWith("/")) {
|
|
102
|
+
await rm(tempDir, {
|
|
103
|
+
recursive: true,
|
|
104
|
+
force: true
|
|
105
|
+
});
|
|
106
|
+
throw new Error(`Zip file contains unsafe path: ${name}. Refusing to extract.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (err instanceof Error && err.message.includes("unsafe path")) throw err;
|
|
112
|
+
await rm(tempDir, {
|
|
113
|
+
recursive: true,
|
|
114
|
+
force: true
|
|
115
|
+
});
|
|
116
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
117
|
+
throw new Error(`Failed to read zip file: ${msg}`);
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
execSync(`unzip -o "${zipPath}" -d "${extractDir}"`, {
|
|
121
|
+
stdio: "pipe",
|
|
122
|
+
timeout: 3e4
|
|
123
|
+
});
|
|
124
|
+
} catch (err) {
|
|
125
|
+
await rm(tempDir, {
|
|
126
|
+
recursive: true,
|
|
127
|
+
force: true
|
|
128
|
+
});
|
|
129
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
130
|
+
throw new Error(`Failed to extract zip file: ${msg}`);
|
|
131
|
+
}
|
|
132
|
+
const entries = await readdir(extractDir);
|
|
133
|
+
if (entries.includes("program.yaml")) return [extractDir, tempDir];
|
|
134
|
+
if (entries.length === 1) {
|
|
135
|
+
const subPath = join(extractDir, entries[0]);
|
|
136
|
+
try {
|
|
137
|
+
if ((await readdir(subPath)).includes("program.yaml")) return [subPath, tempDir];
|
|
138
|
+
} catch {}
|
|
139
|
+
}
|
|
140
|
+
await rm(tempDir, {
|
|
141
|
+
recursive: true,
|
|
142
|
+
force: true
|
|
143
|
+
});
|
|
144
|
+
throw new Error("No program.yaml found in zip archive");
|
|
145
|
+
}
|
|
146
|
+
default: throw new Error(`Unsupported source type: ${type}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Validate a directory contains a valid program.yaml.
|
|
151
|
+
* Returns the parsed manifest.
|
|
152
|
+
*/
|
|
153
|
+
async function validateProgramDir(dirPath) {
|
|
154
|
+
const yamlPath = join(dirPath, "program.yaml");
|
|
155
|
+
let content;
|
|
156
|
+
try {
|
|
157
|
+
content = await readFile(yamlPath, "utf-8");
|
|
158
|
+
} catch {
|
|
159
|
+
throw new Error(`No program.yaml found in ${dirPath}`);
|
|
160
|
+
}
|
|
161
|
+
return parseProgramManifest(content);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Install a program from a source (local dir, GitHub URL, or zip file).
|
|
165
|
+
*/
|
|
166
|
+
async function installProgram(source, options) {
|
|
167
|
+
const userConfigDir = getUserConfigDir(options?.userConfigDir);
|
|
168
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
169
|
+
const [resolvedPath, tempDir] = await resolveSource(source, detectSourceType(source), cwd);
|
|
170
|
+
try {
|
|
171
|
+
const manifest = await validateProgramDir(resolvedPath);
|
|
172
|
+
const programsDir = join(userConfigDir, "programs");
|
|
173
|
+
const installPath = join(programsDir, manifest.id);
|
|
174
|
+
const mountPath = `/programs/${manifest.id}`;
|
|
175
|
+
await mkdir(programsDir, { recursive: true });
|
|
176
|
+
const tempInstallPath = `${installPath}.installing`;
|
|
177
|
+
await rm(tempInstallPath, {
|
|
178
|
+
recursive: true,
|
|
179
|
+
force: true
|
|
180
|
+
});
|
|
181
|
+
await cp(resolvedPath, tempInstallPath, { recursive: true });
|
|
182
|
+
await rm(installPath, {
|
|
183
|
+
recursive: true,
|
|
184
|
+
force: true
|
|
185
|
+
});
|
|
186
|
+
await rename(tempInstallPath, installPath);
|
|
187
|
+
await mkdir(join(userConfigDir, "data", manifest.id), { recursive: true });
|
|
188
|
+
await persistMount(cwd, {
|
|
189
|
+
path: mountPath,
|
|
190
|
+
uri: `fs://${installPath}`
|
|
191
|
+
});
|
|
192
|
+
const { notifyDaemonReload } = await import("../program/daemon-integration.mjs");
|
|
193
|
+
const { getDaemonStatus } = await import("../daemon/manager.mjs");
|
|
194
|
+
await notifyDaemonReload({ getDaemonStatus });
|
|
195
|
+
return {
|
|
196
|
+
success: true,
|
|
197
|
+
programId: manifest.id,
|
|
198
|
+
programName: manifest.name,
|
|
199
|
+
installPath,
|
|
200
|
+
mountPath
|
|
201
|
+
};
|
|
202
|
+
} finally {
|
|
203
|
+
if (tempDir) await rm(tempDir, {
|
|
204
|
+
recursive: true,
|
|
205
|
+
force: true
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* List installed programs by scanning ~/.afs-config/programs/.
|
|
211
|
+
*/
|
|
212
|
+
async function listInstalledPrograms(options) {
|
|
213
|
+
const programsDir = join(getUserConfigDir(options?.userConfigDir), "programs");
|
|
214
|
+
let entries;
|
|
215
|
+
try {
|
|
216
|
+
entries = await readdir(programsDir);
|
|
217
|
+
} catch {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
const programs = [];
|
|
221
|
+
for (const entry of entries) {
|
|
222
|
+
const dirPath = join(programsDir, entry);
|
|
223
|
+
try {
|
|
224
|
+
const manifest = await validateProgramDir(dirPath);
|
|
225
|
+
programs.push({
|
|
226
|
+
id: manifest.id,
|
|
227
|
+
name: manifest.name,
|
|
228
|
+
entrypoint: manifest.entrypoint,
|
|
229
|
+
installPath: dirPath,
|
|
230
|
+
mountPath: `/programs/${manifest.id}`
|
|
231
|
+
});
|
|
232
|
+
} catch {}
|
|
233
|
+
}
|
|
234
|
+
return programs;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Remove an installed program.
|
|
238
|
+
*/
|
|
239
|
+
async function removeProgram(programId, options) {
|
|
240
|
+
const userConfigDir = getUserConfigDir(options?.userConfigDir);
|
|
241
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
242
|
+
const installPath = join(userConfigDir, "programs", programId);
|
|
243
|
+
try {
|
|
244
|
+
await readdir(installPath);
|
|
245
|
+
} catch {
|
|
246
|
+
throw new Error(`Program "${programId}" is not installed`);
|
|
247
|
+
}
|
|
248
|
+
await rm(installPath, {
|
|
249
|
+
recursive: true,
|
|
250
|
+
force: true
|
|
251
|
+
});
|
|
252
|
+
await unpersistMount(cwd, `/programs/${programId}`);
|
|
253
|
+
let purgedData = false;
|
|
254
|
+
if (options?.purge) {
|
|
255
|
+
await rm(join(userConfigDir, "data", programId), {
|
|
256
|
+
recursive: true,
|
|
257
|
+
force: true
|
|
258
|
+
});
|
|
259
|
+
purgedData = true;
|
|
260
|
+
}
|
|
261
|
+
const { notifyDaemonReload } = await import("../program/daemon-integration.mjs");
|
|
262
|
+
const { getDaemonStatus } = await import("../daemon/manager.mjs");
|
|
263
|
+
await notifyDaemonReload({ getDaemonStatus });
|
|
264
|
+
return {
|
|
265
|
+
success: true,
|
|
266
|
+
programId,
|
|
267
|
+
purgedData
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
//#endregion
|
|
272
|
+
export { getUserConfigDir, installProgram, listInstalledPrograms, removeProgram };
|
|
273
|
+
//# sourceMappingURL=program-install.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"program-install.mjs","names":[],"sources":["../../src/config/program-install.ts"],"sourcesContent":["/**\n * Program Installation\n *\n * Business logic for installing, listing, and removing AFS programs.\n * Programs are installed to ~/.afs-config/programs/<id>/ and mounted\n * at /programs/<id> in the CWD config (same as `mount add`).\n */\n\nimport { execSync } from \"node:child_process\";\nimport { cp, mkdir, readdir, readFile, rename, rm } from \"node:fs/promises\";\nimport { homedir, tmpdir } from \"node:os\";\nimport { isAbsolute, join, resolve } from \"node:path\";\nimport { type ProgramManifest, parseProgramManifest } from \"@aigne/afs\";\nimport { AFS_USER_CONFIG_DIR_ENV, CONFIG_DIR_NAME } from \"./loader.js\";\nimport { persistMount, unpersistMount } from \"./mount-commands.js\";\n\n// ─── Types ────────────────────────────────────────────────────────────────\n\nexport type SourceType = \"local\" | \"github\" | \"zip\";\n\nexport interface InstallResult {\n success: boolean;\n programId: string;\n programName: string;\n installPath: string;\n mountPath: string;\n}\n\nexport interface InstalledProgram {\n id: string;\n name: string;\n entrypoint: string;\n installPath: string;\n mountPath: string;\n}\n\nexport interface RemoveResult {\n success: boolean;\n programId: string;\n purgedData: boolean;\n}\n\nexport interface InstallOptions {\n /** Override user config dir (for testing) */\n userConfigDir?: string;\n /** CWD for persistMount (for testing) */\n cwd?: string;\n}\n\nexport interface RemoveOptions extends InstallOptions {\n /** Also remove program data directory */\n purge?: boolean;\n}\n\n// ─── Helpers ──────────────────────────────────────────────────────────────\n\n/**\n * Get the user config directory.\n */\nexport function getUserConfigDir(override?: string): string {\n return override ?? process.env[AFS_USER_CONFIG_DIR_ENV] ?? join(homedir(), CONFIG_DIR_NAME);\n}\n\n/**\n * Detect the source type from a source string.\n */\nexport function detectSourceType(source: string): SourceType {\n // ZIP file\n if (source.endsWith(\".zip\")) {\n return \"zip\";\n }\n\n // GitHub URL patterns\n if (\n source.startsWith(\"https://github.com/\") ||\n source.startsWith(\"http://github.com/\") ||\n source.startsWith(\"github.com/\")\n ) {\n return \"github\";\n }\n\n // Default: local directory\n return \"local\";\n}\n\n/**\n * Parse a GitHub URL into owner, repo, ref, and optional subdirectory.\n */\nexport function parseGitHubURL(url: string): {\n cloneUrl: string;\n subdir?: string;\n} {\n // Normalize: ensure https://\n let normalized = url;\n if (normalized.startsWith(\"github.com/\")) {\n normalized = `https://${normalized}`;\n }\n\n // Parse: https://github.com/owner/repo[/tree/ref/subdir]\n const match = normalized.match(\n /^https?:\\/\\/github\\.com\\/([^/]+)\\/([^/]+?)(?:\\.git)?(?:\\/tree\\/([^/]+)\\/(.+))?$/,\n );\n if (!match) {\n // Simple case: just owner/repo\n return { cloneUrl: normalized.replace(/\\/+$/, \"\") };\n }\n\n const [, owner, repo, _ref, subdir] = match;\n const cloneUrl = `https://github.com/${owner}/${repo}.git`;\n\n return {\n cloneUrl,\n subdir: subdir || undefined,\n };\n}\n\n/**\n * Resolve a source to a local directory path containing program.yaml.\n * For github/zip sources, downloads/extracts to a temp directory.\n *\n * Returns [resolvedPath, tempDir?] — tempDir is set if caller should clean up.\n */\nexport async function resolveSource(\n source: string,\n type: SourceType,\n cwd: string,\n): Promise<[string, string | undefined]> {\n switch (type) {\n case \"local\": {\n const dirPath = isAbsolute(source) ? source : resolve(cwd, source);\n // Verify it's a directory by trying to read it\n try {\n await readdir(dirPath);\n } catch {\n throw new Error(`Source directory does not exist: ${dirPath}`);\n }\n return [dirPath, undefined];\n }\n\n case \"github\": {\n const { cloneUrl, subdir } = parseGitHubURL(source);\n const tempDir = join(tmpdir(), `afs-install-${Date.now()}`);\n await mkdir(tempDir, { recursive: true });\n\n try {\n execSync(`git clone --depth 1 ${cloneUrl} ${join(tempDir, \"repo\")}`, {\n stdio: \"pipe\",\n timeout: 60000,\n });\n } catch (err) {\n await rm(tempDir, { recursive: true, force: true });\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`Failed to clone GitHub repository: ${msg}`);\n }\n\n const resolvedPath = subdir ? join(tempDir, \"repo\", subdir) : join(tempDir, \"repo\");\n return [resolvedPath, tempDir];\n }\n\n case \"zip\": {\n const zipPath = isAbsolute(source) ? source : resolve(cwd, source);\n const tempDir = join(tmpdir(), `afs-install-${Date.now()}`);\n const extractDir = join(tempDir, \"extracted\");\n await mkdir(extractDir, { recursive: true });\n\n // Security: check zip contents for path traversal before extracting\n try {\n const listing = execSync(`unzip -l \"${zipPath}\"`, {\n encoding: \"utf-8\",\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n });\n // Each line after header has format: \" length date time name\"\n const lines = listing.split(\"\\n\");\n for (const line of lines) {\n const trimmed = line.trim();\n // Skip header/footer lines\n if (\n !trimmed ||\n trimmed.startsWith(\"Archive:\") ||\n trimmed.startsWith(\"Length\") ||\n trimmed.startsWith(\"---\")\n )\n continue;\n // Extract filename (last column)\n const parts = trimmed.split(/\\s+/);\n if (parts.length >= 4) {\n const name = parts.slice(3).join(\" \");\n if (name.includes(\"..\") || name.startsWith(\"/\")) {\n await rm(tempDir, { recursive: true, force: true });\n throw new Error(`Zip file contains unsafe path: ${name}. Refusing to extract.`);\n }\n }\n }\n } catch (err) {\n if (err instanceof Error && err.message.includes(\"unsafe path\")) {\n throw err;\n }\n await rm(tempDir, { recursive: true, force: true });\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`Failed to read zip file: ${msg}`);\n }\n\n // Extract\n try {\n execSync(`unzip -o \"${zipPath}\" -d \"${extractDir}\"`, {\n stdio: \"pipe\",\n timeout: 30000,\n });\n } catch (err) {\n await rm(tempDir, { recursive: true, force: true });\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`Failed to extract zip file: ${msg}`);\n }\n\n // Find program.yaml: might be at root or in a single subdirectory\n const entries = await readdir(extractDir);\n // Check if program.yaml is at extract root\n if (entries.includes(\"program.yaml\")) {\n return [extractDir, tempDir];\n }\n // Check single subdirectory\n if (entries.length === 1) {\n const subPath = join(extractDir, entries[0]!);\n try {\n const subEntries = await readdir(subPath);\n if (subEntries.includes(\"program.yaml\")) {\n return [subPath, tempDir];\n }\n } catch {\n // not a directory\n }\n }\n\n await rm(tempDir, { recursive: true, force: true });\n throw new Error(\"No program.yaml found in zip archive\");\n }\n\n default:\n throw new Error(`Unsupported source type: ${type}`);\n }\n}\n\n/**\n * Validate a directory contains a valid program.yaml.\n * Returns the parsed manifest.\n */\nexport async function validateProgramDir(dirPath: string): Promise<ProgramManifest> {\n const yamlPath = join(dirPath, \"program.yaml\");\n let content: string;\n try {\n content = await readFile(yamlPath, \"utf-8\");\n } catch {\n throw new Error(`No program.yaml found in ${dirPath}`);\n }\n\n return parseProgramManifest(content);\n}\n\n// ─── Main Functions ───────────────────────────────────────────────────────\n\n/**\n * Install a program from a source (local dir, GitHub URL, or zip file).\n */\nexport async function installProgram(\n source: string,\n options?: InstallOptions,\n): Promise<InstallResult> {\n const userConfigDir = getUserConfigDir(options?.userConfigDir);\n const cwd = options?.cwd ?? process.cwd();\n const type = detectSourceType(source);\n\n // 1. Resolve source to local directory\n const [resolvedPath, tempDir] = await resolveSource(source, type, cwd);\n\n try {\n // 2. Validate program.yaml\n const manifest = await validateProgramDir(resolvedPath);\n\n // 3. Determine install target\n const programsDir = join(userConfigDir, \"programs\");\n const installPath = join(programsDir, manifest.id);\n const mountPath = `/programs/${manifest.id}`;\n\n // 4. Atomic install: copy to temp location, swap\n await mkdir(programsDir, { recursive: true });\n const tempInstallPath = `${installPath}.installing`;\n\n // Clean up any previous failed install\n await rm(tempInstallPath, { recursive: true, force: true });\n\n // Copy program files\n await cp(resolvedPath, tempInstallPath, { recursive: true });\n\n // Remove old version if exists, then rename\n await rm(installPath, { recursive: true, force: true });\n await rename(tempInstallPath, installPath);\n\n // 5. Ensure data directory exists\n const dataDir = join(userConfigDir, \"data\", manifest.id);\n await mkdir(dataDir, { recursive: true });\n\n // 6. Persist mount to CWD config (same as mount add)\n await persistMount(cwd, {\n path: mountPath,\n uri: `fs://${installPath}`,\n });\n\n // 7. Notify daemon to reload programs (best-effort)\n const { notifyDaemonReload } = await import(\"../program/daemon-integration.js\");\n const { getDaemonStatus } = await import(\"../daemon/manager.js\");\n await notifyDaemonReload({ getDaemonStatus });\n\n return {\n success: true,\n programId: manifest.id,\n programName: manifest.name,\n installPath,\n mountPath,\n };\n } finally {\n // 8. Cleanup temp dir\n if (tempDir) {\n await rm(tempDir, { recursive: true, force: true });\n }\n }\n}\n\n/**\n * List installed programs by scanning ~/.afs-config/programs/.\n */\nexport async function listInstalledPrograms(options?: InstallOptions): Promise<InstalledProgram[]> {\n const userConfigDir = getUserConfigDir(options?.userConfigDir);\n const programsDir = join(userConfigDir, \"programs\");\n\n let entries: string[];\n try {\n entries = await readdir(programsDir);\n } catch {\n return []; // programs/ directory doesn't exist yet\n }\n\n const programs: InstalledProgram[] = [];\n\n for (const entry of entries) {\n const dirPath = join(programsDir, entry);\n try {\n const manifest = await validateProgramDir(dirPath);\n programs.push({\n id: manifest.id,\n name: manifest.name,\n entrypoint: manifest.entrypoint,\n installPath: dirPath,\n mountPath: `/programs/${manifest.id}`,\n });\n } catch {\n // Skip directories without valid program.yaml\n }\n }\n\n return programs;\n}\n\n/**\n * Remove an installed program.\n */\nexport async function removeProgram(\n programId: string,\n options?: RemoveOptions,\n): Promise<RemoveResult> {\n const userConfigDir = getUserConfigDir(options?.userConfigDir);\n const cwd = options?.cwd ?? process.cwd();\n const installPath = join(userConfigDir, \"programs\", programId);\n\n // Verify program exists\n try {\n await readdir(installPath);\n } catch {\n throw new Error(`Program \"${programId}\" is not installed`);\n }\n\n // Remove program directory\n await rm(installPath, { recursive: true, force: true });\n\n // Remove mount from config (searches cwd → project → user)\n await unpersistMount(cwd, `/programs/${programId}`);\n\n // Optionally purge data\n let purgedData = false;\n if (options?.purge) {\n const dataDir = join(userConfigDir, \"data\", programId);\n await rm(dataDir, { recursive: true, force: true });\n purgedData = true;\n }\n\n // Notify daemon to reload programs (best-effort)\n const { notifyDaemonReload } = await import(\"../program/daemon-integration.js\");\n const { getDaemonStatus } = await import(\"../daemon/manager.js\");\n await notifyDaemonReload({ getDaemonStatus });\n\n return {\n success: true,\n programId,\n purgedData,\n };\n}\n"],"mappings":"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"}
|