Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 50 additions & 22 deletions src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,56 +5,72 @@ import * as fs from "fs";
import * as path from "path";
import JSON5 from "json5";
import { select } from "@inquirer/prompts";
import { StoreManager, ProjectStoreManager, Scope, Profile, OmosConfigManager, SettingsManager, OmosPresetConfig } from "../store";
import { StoreManager, ProjectStoreManager, Scope, Profile, OmosConfigManager, SettingsManager, OmosPresetConfig, OmosConfig } from "../store";
import { Validator } from "../utils/validator";
import { OmosValidator } from "../utils/omos-validator";
import { downloadFile, readBundledAsset } from "../utils/downloader";
import { readBundledAsset } from "../utils/downloader";
import { resolveProjectRoot, findProjectRoot } from "../utils/scope-resolver";

const OMO_SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json";

/**
* Ensure the OMO (oh-my-opencode) JSON schema is present in the local cache and return its file path.
*
* If the schema is already cached this returns the cached path. If not cached but a bundled schema asset
* is available, the bundled schema is written to the cache and its path is returned.
*
* @returns The absolute path to the cached `oh-my-opencode.schema.json` file.
* @throws Error if the schema is not cached and no bundled fallback is available.
*/
async function ensureOmoSchemaAvailable(store: StoreManager): Promise<string> {
const schemaPath = path.join(store.getCacheSchemaPath(), "oh-my-opencode.schema.json");

if (fs.existsSync(schemaPath)) {
return schemaPath;
}

try {
await downloadFile(
OMO_SCHEMA_URL,
store.getCacheSchemaPath(),
"oh-my-opencode.schema.json",
{ source: "github" }
);
// Schema not in cache - try bundled fallback
const bundledSchema = readBundledAsset("oh-my-opencode.schema.json");
if (bundledSchema) {
store.saveCacheFile(store.getCacheSchemaPath(), "oh-my-opencode.schema.json", bundledSchema, { source: "bundled" });
return schemaPath;
} catch {
const bundledSchema = readBundledAsset("oh-my-opencode.schema.json");
if (bundledSchema) {
store.saveCacheFile(store.getCacheSchemaPath(), "oh-my-opencode.schema.json", bundledSchema, { source: "bundled" });
return schemaPath;
}
throw new Error("Failed to download or find bundled schema");
}

throw new Error(
"Schema not found in cache. Run 'omo-switch init' or 'omo-switch schema refresh' to download the schema first."
);
}

/**
* Ensures the slim Oh-My-Opencode JSON schema is available in the store cache, saving a bundled fallback if present.
*
* @param store - Store manager used to resolve cache paths and persist the schema file.
* @returns The absolute path to the slim schema file in the store cache.
* @throws Error if the schema is not found in the cache and no bundled fallback is available; suggests running 'omo-switch init' or 'omo-switch schema refresh'.
*/
async function ensureOmosSchemaAvailable(store: StoreManager): Promise<string> {
const schemaPath = path.join(store.getCacheSchemaPath(), "oh-my-opencode-slim.schema.json");

if (fs.existsSync(schemaPath)) {
return schemaPath;
}

// For OMOS, we use the bundled schema directly (no remote URL currently)
// Schema not in cache - try bundled fallback
const bundledSchema = readBundledAsset("oh-my-opencode-slim.schema.json");
if (bundledSchema) {
store.saveCacheFile(store.getCacheSchemaPath(), "oh-my-opencode-slim.schema.json", bundledSchema, { source: "bundled" });
return schemaPath;
}

throw new Error("OMOS schema not found in bundled assets");
throw new Error(
"Slim schema not found in cache. Run 'omo-switch init' or 'omo-switch schema refresh' to download the schema first."
);
}

/**
* Create a URL-friendly identifier from an arbitrary name.
*
* @param name - The source name to convert into an identifier
* @returns A lowercase identifier containing only letters, digits, and single hyphens (no leading or trailing hyphens), truncated to at most 50 characters
*/
function deriveIdFromName(name: string): string {
return name
.toLowerCase()
Expand All @@ -71,7 +87,19 @@ interface AddOptions {
}

/**
* Handle adding a preset in OMOS mode
* Add and validate an OMOS preset file into the selected scope (user or project).
*
* Reads and parses a `.json` preset file, validates it against the OMOS schema, optionally prompts
* for the target scope, creates a backup of existing configuration, and then adds or updates the
* preset in the chosen store.
*
* This function will terminate the process with a non-zero exit code on fatal errors such as a
* missing file, invalid extension, JSON parse errors, schema validation failures, an invalid scope,
* or an attempt to overwrite an existing preset without `--force`.
*
* @param file - Path to the preset `.json` file to import
* @param options - AddOptions controlling id/name/force/scope overrides
* @param spinner - Ora spinner instance used to display progress and status
*/
async function handleOmosAdd(
file: string,
Expand Down Expand Up @@ -110,7 +138,7 @@ async function handleOmosAdd(
spinner.text = "Validating preset configuration...";
const schemaPath = await ensureOmosSchemaAvailable(globalStore);
const validator = new OmosValidator(schemaPath);
const validation = validator.validatePreset(presetConfig);
const validation = validator.validate(presetConfig as OmosConfig);

if (!validation.valid) {
spinner.fail("Preset validation failed");
Expand Down
18 changes: 17 additions & 1 deletion src/utils/downloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ import * as http from "http";
import * as fs from "fs";
import * as path from "path";

/**
* Download the resource at `url`, write it to `cacheDir/fileName`, and write metadata to `cacheDir/meta.json`.
*
* @param url - The HTTP(S) URL to download.
* @param cacheDir - Directory where the downloaded file and `meta.json` will be written.
* @param fileName - Filename to use for the downloaded content.
* @param meta - Additional properties to include in `meta.json`; an `updatedAt` timestamp is added automatically.
* @returns `true` if the file and metadata were written successfully.
* @throws Error when the HTTP response status is not 200, when the request fails, or when writing files fails.
*/
export async function downloadFile(url: string, cacheDir: string, fileName: string, meta: Record<string, unknown> = {}): Promise<boolean> {
const filePath = path.join(cacheDir, fileName);
const metaPath = path.join(cacheDir, "meta.json");
Expand Down Expand Up @@ -34,8 +44,14 @@ export async function downloadFile(url: string, cacheDir: string, fileName: stri
});
}

/**
* Load a bundled asset from the project's shared assets directory.
*
* @param relativePath - File path relative to the `shared/assets` directory
* @returns The file contents as a UTF-8 string if the asset exists, `null` otherwise
*/
export function readBundledAsset(relativePath: string): string | null {
const projectRoot = path.resolve(__dirname, "../../../");
const projectRoot = path.resolve(__dirname, "../../");
const assetPath = path.join(projectRoot, "shared", "assets", relativePath);
if (!fs.existsSync(assetPath)) {
return null;
Expand Down