refactor: migrate storage paths ~/.uncaged/workflow → ~/.uwf
CI / check (pull_request) Failing after 8m2s
CI / check (pull_request) Failing after 8m2s
- Default storage root: ~/.uncaged/workflow → ~/.uwf - Default CAS root: ~/.uncaged/json-cas → ~/.ocas - Env var priority: UWF_STORAGE_ROOT → WORKFLOW_STORAGE_ROOT → UNCAGED_WORKFLOW_STORAGE_ROOT (legacy) - CAS env var: OCAS_DIR → UNCAGED_CAS_DIR (legacy) - Auto-migration: symlink old paths on first run + deprecation warning - Updated all comments, JSDoc, reference docs, CLAUDE.md - New test: store-storage-root.test.ts Closes #9
This commit is contained in:
@@ -6,45 +6,64 @@ import { createUwfStore, getCasDir, getGlobalCasDir } from "../store.js";
|
||||
|
||||
describe("Global CAS directory", () => {
|
||||
let tmpDir: string;
|
||||
let originalEnv: string | undefined;
|
||||
let originalOcasDir: string | undefined;
|
||||
let originalLegacyCasDir: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = join(tmpdir(), `uwf-test-global-cas-${Date.now()}`);
|
||||
await mkdir(tmpDir, { recursive: true });
|
||||
originalEnv = process.env.UNCAGED_CAS_DIR;
|
||||
originalOcasDir = process.env.OCAS_DIR;
|
||||
originalLegacyCasDir = process.env.UNCAGED_CAS_DIR;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tmpDir) {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
if (originalEnv === undefined) {
|
||||
if (originalOcasDir === undefined) {
|
||||
delete process.env.OCAS_DIR;
|
||||
} else {
|
||||
process.env.OCAS_DIR = originalOcasDir;
|
||||
}
|
||||
if (originalLegacyCasDir === undefined) {
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
} else {
|
||||
process.env.UNCAGED_CAS_DIR = originalEnv;
|
||||
process.env.UNCAGED_CAS_DIR = originalLegacyCasDir;
|
||||
}
|
||||
});
|
||||
|
||||
test("getGlobalCasDir returns default path when no env var set", () => {
|
||||
delete process.env.OCAS_DIR;
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
const casDir = getGlobalCasDir();
|
||||
// Should return ~/.uncaged/json-cas
|
||||
expect(casDir).toContain(".uncaged");
|
||||
expect(casDir).toContain("json-cas");
|
||||
expect(casDir).toContain(".ocas");
|
||||
});
|
||||
|
||||
test("getGlobalCasDir respects OCAS_DIR environment variable", () => {
|
||||
const customPath = join(tmpDir, "custom-cas");
|
||||
process.env.OCAS_DIR = customPath;
|
||||
const casDir = getGlobalCasDir();
|
||||
expect(casDir).toBe(customPath);
|
||||
});
|
||||
|
||||
test("getGlobalCasDir respects UNCAGED_CAS_DIR environment variable", () => {
|
||||
const customPath = join(tmpDir, "custom-cas");
|
||||
const customPath = join(tmpDir, "legacy-cas");
|
||||
process.env.UNCAGED_CAS_DIR = customPath;
|
||||
const casDir = getGlobalCasDir();
|
||||
expect(casDir).toBe(customPath);
|
||||
});
|
||||
|
||||
test("getGlobalCasDir ignores empty UNCAGED_CAS_DIR", () => {
|
||||
process.env.UNCAGED_CAS_DIR = "";
|
||||
test("getGlobalCasDir prefers OCAS_DIR over UNCAGED_CAS_DIR", () => {
|
||||
process.env.OCAS_DIR = join(tmpDir, "primary-cas");
|
||||
process.env.UNCAGED_CAS_DIR = join(tmpDir, "legacy-cas");
|
||||
expect(getGlobalCasDir()).toBe(join(tmpDir, "primary-cas"));
|
||||
});
|
||||
|
||||
test("getGlobalCasDir ignores empty OCAS_DIR", () => {
|
||||
process.env.OCAS_DIR = "";
|
||||
delete process.env.UNCAGED_CAS_DIR;
|
||||
const casDir = getGlobalCasDir();
|
||||
expect(casDir).toContain(".uncaged");
|
||||
expect(casDir).toContain("json-cas");
|
||||
expect(casDir).toContain(".ocas");
|
||||
});
|
||||
|
||||
test("getCasDir is deprecated but still works for backward compatibility", () => {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { lstat, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
getDefaultStorageRoot,
|
||||
getGlobalCasDir,
|
||||
migrateStorageIfNeeded,
|
||||
resolveStorageRoot,
|
||||
} from "../store.js";
|
||||
|
||||
describe("Storage root resolution", () => {
|
||||
const envKeys = [
|
||||
"UWF_STORAGE_ROOT",
|
||||
"WORKFLOW_STORAGE_ROOT",
|
||||
"UNCAGED_WORKFLOW_STORAGE_ROOT",
|
||||
"OCAS_DIR",
|
||||
"UNCAGED_CAS_DIR",
|
||||
] as const;
|
||||
const savedEnv: Partial<Record<(typeof envKeys)[number], string | undefined>> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of envKeys) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of envKeys) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = savedEnv[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("getDefaultStorageRoot returns ~/.uwf", () => {
|
||||
expect(getDefaultStorageRoot()).toBe(join(homedir(), ".uwf"));
|
||||
});
|
||||
|
||||
test("resolveStorageRoot prefers UWF_STORAGE_ROOT", () => {
|
||||
process.env.UWF_STORAGE_ROOT = "/tmp/uwf-primary";
|
||||
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/uwf-fallback";
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "/tmp/uwf-legacy";
|
||||
expect(resolveStorageRoot()).toBe("/tmp/uwf-primary");
|
||||
});
|
||||
|
||||
test("resolveStorageRoot falls back to WORKFLOW_STORAGE_ROOT", () => {
|
||||
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/uwf-fallback";
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "/tmp/uwf-legacy";
|
||||
expect(resolveStorageRoot()).toBe("/tmp/uwf-fallback");
|
||||
});
|
||||
|
||||
test("resolveStorageRoot falls back to UNCAGED_WORKFLOW_STORAGE_ROOT", () => {
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "/tmp/uwf-legacy";
|
||||
expect(resolveStorageRoot()).toBe("/tmp/uwf-legacy");
|
||||
});
|
||||
|
||||
test("getGlobalCasDir returns ~/.ocas by default", () => {
|
||||
const casDir = getGlobalCasDir();
|
||||
expect(casDir).toBe(join(homedir(), ".ocas"));
|
||||
});
|
||||
|
||||
test("getGlobalCasDir prefers OCAS_DIR over UNCAGED_CAS_DIR", () => {
|
||||
process.env.OCAS_DIR = "/tmp/ocas-primary";
|
||||
process.env.UNCAGED_CAS_DIR = "/tmp/ocas-legacy";
|
||||
expect(getGlobalCasDir()).toBe("/tmp/ocas-primary");
|
||||
});
|
||||
|
||||
test("getGlobalCasDir falls back to UNCAGED_CAS_DIR", () => {
|
||||
process.env.UNCAGED_CAS_DIR = "/tmp/ocas-legacy";
|
||||
expect(getGlobalCasDir()).toBe("/tmp/ocas-legacy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateStorageIfNeeded", () => {
|
||||
let fakeHome: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
fakeHome = join(
|
||||
homedir(),
|
||||
`.uwf-migrate-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
await mkdir(fakeHome, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(fakeHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("creates symlinks from legacy paths when new paths are missing", async () => {
|
||||
const oldWorkflow = join(fakeHome, ".uncaged", "workflow");
|
||||
const oldCas = join(fakeHome, ".uncaged", "json-cas");
|
||||
await mkdir(oldWorkflow, { recursive: true });
|
||||
await mkdir(oldCas, { recursive: true });
|
||||
await writeFile(join(oldWorkflow, "config.yaml"), "defaultAgent: test\n", "utf8");
|
||||
|
||||
migrateStorageIfNeeded(fakeHome);
|
||||
|
||||
const newWorkflow = join(fakeHome, ".uwf");
|
||||
const newCas = join(fakeHome, ".ocas");
|
||||
const workflowStat = await lstat(newWorkflow);
|
||||
const casStat = await lstat(newCas);
|
||||
expect(workflowStat.isSymbolicLink()).toBe(true);
|
||||
expect(casStat.isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
test("skips migration when new paths already exist", async () => {
|
||||
const oldWorkflow = join(fakeHome, ".uncaged", "workflow");
|
||||
const newWorkflow = join(fakeHome, ".uwf");
|
||||
await mkdir(oldWorkflow, { recursive: true });
|
||||
await mkdir(newWorkflow, { recursive: true });
|
||||
|
||||
migrateStorageIfNeeded(fakeHome);
|
||||
|
||||
const stat = await lstat(newWorkflow);
|
||||
expect(stat.isDirectory()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CasRef, ThreadId } from "@united-workforce/protocol";
|
||||
|
||||
/** Marker file stored at ~/.uncaged/workflow/running/<thread-id>.json */
|
||||
/** Marker file stored at ~/.uwf/running/<thread-id>.json */
|
||||
export type RunningMarker = {
|
||||
thread: ThreadId;
|
||||
workflow: CasRef;
|
||||
|
||||
@@ -41,7 +41,9 @@ import {
|
||||
import { parseTimeInput } from "./commands/thread-time-parser.js";
|
||||
import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js";
|
||||
import { formatOutput, type OutputFormat } from "./format.js";
|
||||
import { resolveStorageRoot } from "./store.js";
|
||||
import { migrateStorageIfNeeded, resolveStorageRoot } from "./store.js";
|
||||
|
||||
migrateStorageIfNeeded();
|
||||
|
||||
function writeOutput(data: unknown): void {
|
||||
const fmt = program.opts().format as OutputFormat;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import { existsSync, symlinkSync } from "node:fs";
|
||||
import { access, appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -105,27 +106,55 @@ export async function discoverProjectWorkflows(
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
|
||||
/** Default filesystem root for uwf data (`~/.uwf`). */
|
||||
export function getDefaultStorageRoot(): string {
|
||||
return join(homedir(), ".uncaged", "workflow");
|
||||
return join(homedir(), ".uwf");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve storage root.
|
||||
* Priority: `UNCAGED_WORKFLOW_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → default.
|
||||
* Priority: `UWF_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → `UNCAGED_WORKFLOW_STORAGE_ROOT` (legacy) → default.
|
||||
*/
|
||||
export function resolveStorageRoot(): string {
|
||||
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
if (internal !== undefined && internal !== "") {
|
||||
return internal;
|
||||
const primary = process.env.UWF_STORAGE_ROOT;
|
||||
if (primary !== undefined && primary !== "") {
|
||||
return primary;
|
||||
}
|
||||
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
|
||||
if (userOverride !== undefined && userOverride !== "") {
|
||||
return userOverride;
|
||||
}
|
||||
const legacy = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
if (legacy !== undefined && legacy !== "") {
|
||||
return legacy;
|
||||
}
|
||||
return getDefaultStorageRoot();
|
||||
}
|
||||
|
||||
/** Symlink legacy storage paths to ~/.uwf and ~/.ocas when upgrading from older installs. */
|
||||
export function migrateStorageIfNeeded(home: string = homedir()): void {
|
||||
const oldPath = join(home, ".uncaged", "workflow");
|
||||
const newPath = join(home, ".uwf");
|
||||
|
||||
if (!existsSync(newPath) && existsSync(oldPath)) {
|
||||
symlinkSync(oldPath, newPath);
|
||||
// biome-ignore lint/suspicious/noConsole: migration notice
|
||||
console.log("⚠️ Storage migrated: ~/.uwf → ~/.uncaged/workflow (symlink)");
|
||||
// biome-ignore lint/suspicious/noConsole: migration notice
|
||||
console.log(
|
||||
" This symlink is temporary. Copy your data to ~/.uwf/ and remove the symlink in a future version.",
|
||||
);
|
||||
}
|
||||
|
||||
const oldCas = join(home, ".uncaged", "json-cas");
|
||||
const newCas = join(home, ".ocas");
|
||||
if (!existsSync(newCas) && existsSync(oldCas)) {
|
||||
symlinkSync(oldCas, newCas);
|
||||
// biome-ignore lint/suspicious/noConsole: migration notice
|
||||
console.log("⚠️ CAS storage migrated: ~/.ocas → ~/.uncaged/json-cas (symlink)");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecated: Use `getGlobalCasDir()` instead.
|
||||
* Returns the old CAS directory for backward compatibility.
|
||||
@@ -135,15 +164,19 @@ export function getCasDir(storageRoot: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the global CAS directory shared by all uwf and json-cas tools.
|
||||
* Priority: UNCAGED_CAS_DIR environment variable → default ~/.uncaged/json-cas
|
||||
* Returns the global CAS directory shared by all uwf and ocas tools.
|
||||
* Priority: `OCAS_DIR` → `UNCAGED_CAS_DIR` (legacy) → default ~/.ocas
|
||||
*/
|
||||
export function getGlobalCasDir(): string {
|
||||
const envPath = process.env.UNCAGED_CAS_DIR;
|
||||
if (envPath !== undefined && envPath !== "") {
|
||||
return envPath;
|
||||
const primary = process.env.OCAS_DIR;
|
||||
if (primary !== undefined && primary !== "") {
|
||||
return primary;
|
||||
}
|
||||
return join(homedir(), ".uncaged", "json-cas");
|
||||
const legacy = process.env.UNCAGED_CAS_DIR;
|
||||
if (legacy !== undefined && legacy !== "") {
|
||||
return legacy;
|
||||
}
|
||||
return join(homedir(), ".ocas");
|
||||
}
|
||||
|
||||
export function getRegistryPath(storageRoot: string): string {
|
||||
|
||||
Reference in New Issue
Block a user