feat: add coding project schema v2 migration
This commit is contained in:
27
electron/coding-projects/atomic-json.ts
Normal file
27
electron/coding-projects/atomic-json.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export type JsonFileWriter = (filePath: string, value: unknown) => Promise<void>;
|
||||
|
||||
export async function atomicWriteText(filePath: string, source: string): Promise<void> {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
const temporaryPath = path.join(
|
||||
path.dirname(filePath),
|
||||
`.${path.basename(filePath)}.${randomUUID()}.tmp`,
|
||||
);
|
||||
try {
|
||||
await writeFile(temporaryPath, source, { encoding: 'utf8', flag: 'wx' });
|
||||
await rename(temporaryPath, filePath);
|
||||
} finally {
|
||||
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function atomicWriteJson(filePath: string, value: unknown): Promise<void> {
|
||||
await atomicWriteText(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export async function readJsonFile(filePath: string): Promise<unknown> {
|
||||
return JSON.parse(await readFile(filePath, 'utf8')) as unknown;
|
||||
}
|
||||
257
electron/coding-projects/conversation-store.ts
Normal file
257
electron/coding-projects/conversation-store.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import type { ConversationModelState, ProductModelRef } from '../coding-runtime/contracts';
|
||||
import { atomicWriteJson, readJsonFile, type JsonFileWriter } from './atomic-json';
|
||||
import { normalizeProductModelRef } from './project-config';
|
||||
|
||||
export const CODING_CONVERSATIONS_PATH = '.niancode/conversations.json';
|
||||
|
||||
export interface CodingConversationV2 extends ConversationModelState {
|
||||
id: string;
|
||||
agentId: string;
|
||||
title: string;
|
||||
piSessionId?: string;
|
||||
sessionKey?: string;
|
||||
archivedAt: string | null;
|
||||
unread: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CodingConversationFileV2 {
|
||||
schemaVersion: 2;
|
||||
conversations: CodingConversationV2[];
|
||||
}
|
||||
|
||||
export interface CreateCodingConversationInput {
|
||||
agentId: string;
|
||||
title: string;
|
||||
model: ProductModelRef | null;
|
||||
modelResolution: 'resolved' | 'required';
|
||||
}
|
||||
|
||||
export interface PiSessionBinding {
|
||||
piSessionId: string;
|
||||
sessionKey: string;
|
||||
}
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const SESSION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
|
||||
function conversationFilePath(projectPath: string): string {
|
||||
return path.join(projectPath, CODING_CONVERSATIONS_PATH);
|
||||
}
|
||||
|
||||
function cleanString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeModelState(value: {
|
||||
model?: unknown;
|
||||
modelResolution?: unknown;
|
||||
}): ConversationModelState {
|
||||
if (value.modelResolution === 'required') {
|
||||
if (value.model !== null) throw new Error('Required Conversation model must be null');
|
||||
return { model: null, modelResolution: 'required' };
|
||||
}
|
||||
if (value.modelResolution !== 'resolved') throw new Error('Conversation model resolution is invalid');
|
||||
return { model: normalizeProductModelRef(value.model), modelResolution: 'resolved' };
|
||||
}
|
||||
|
||||
export function validateSessionKey(value: unknown): string {
|
||||
const sessionKey = typeof value === 'string' ? value : '';
|
||||
if (!SESSION_KEY_PATTERN.test(sessionKey) || path.isAbsolute(sessionKey)) {
|
||||
throw new Error('Pi session key must be an opaque relative key');
|
||||
}
|
||||
return sessionKey;
|
||||
}
|
||||
|
||||
function normalizeConversation(value: unknown): CodingConversationV2 {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Coding Conversation must be an object');
|
||||
}
|
||||
const record = value as Partial<CodingConversationV2>;
|
||||
const id = cleanString(record.id);
|
||||
const agentId = cleanString(record.agentId);
|
||||
const title = cleanString(record.title);
|
||||
const createdAt = cleanString(record.createdAt);
|
||||
const updatedAt = cleanString(record.updatedAt);
|
||||
if (!UUID_PATTERN.test(id)) throw new Error('Conversation id must be a UUID');
|
||||
if (!agentId) throw new Error('Conversation Agent id is required');
|
||||
if (!title) throw new Error('Conversation title is required');
|
||||
if (!createdAt || !updatedAt) throw new Error('Conversation timestamps are required');
|
||||
const sessionKey = record.sessionKey === undefined ? undefined : validateSessionKey(record.sessionKey);
|
||||
const piSessionId = cleanString(record.piSessionId) || undefined;
|
||||
if ((sessionKey === undefined) !== (piSessionId === undefined)) {
|
||||
throw new Error('Pi session id and session key must be persisted together');
|
||||
}
|
||||
return {
|
||||
id,
|
||||
agentId,
|
||||
title,
|
||||
...normalizeModelState(record),
|
||||
...(piSessionId ? { piSessionId } : {}),
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
archivedAt: cleanString(record.archivedAt) || null,
|
||||
unread: record.unread === true,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyConversationFileV2(): CodingConversationFileV2 {
|
||||
return { schemaVersion: 2, conversations: [] };
|
||||
}
|
||||
|
||||
export function normalizeCodingConversationFileV2(value: unknown): CodingConversationFileV2 {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Coding Conversation file must be an object');
|
||||
}
|
||||
const record = value as Partial<CodingConversationFileV2>;
|
||||
if (record.schemaVersion !== 2) throw new Error('Unsupported coding Conversation schema');
|
||||
if (!Array.isArray(record.conversations)) throw new Error('Conversations must be an array');
|
||||
const conversations = record.conversations.map(normalizeConversation);
|
||||
if (new Set(conversations.map((conversation) => conversation.id)).size !== conversations.length) {
|
||||
throw new Error('Duplicate Conversation id');
|
||||
}
|
||||
return { schemaVersion: 2, conversations };
|
||||
}
|
||||
|
||||
export function createCodingConversationStore(
|
||||
projectPath: string,
|
||||
options: {
|
||||
createId?: () => string;
|
||||
now?: () => string;
|
||||
writer?: JsonFileWriter;
|
||||
} = {},
|
||||
) {
|
||||
const createId = options.createId ?? randomUUID;
|
||||
const now = options.now ?? (() => new Date().toISOString());
|
||||
const writer = options.writer ?? atomicWriteJson;
|
||||
const bindingFlights = new Map<string, Promise<CodingConversationV2>>();
|
||||
let mutationTail = Promise.resolve();
|
||||
|
||||
async function readNow(): Promise<CodingConversationFileV2> {
|
||||
try {
|
||||
return normalizeCodingConversationFileV2(await readJsonFile(conversationFilePath(projectPath)));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return createEmptyConversationFileV2();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function mutate<T>(operation: (file: CodingConversationFileV2) => Promise<{
|
||||
result: T;
|
||||
file: CodingConversationFileV2;
|
||||
}>): Promise<T> {
|
||||
const execute = async () => {
|
||||
const change = await operation(await readNow());
|
||||
const normalized = normalizeCodingConversationFileV2(change.file);
|
||||
await writer(conversationFilePath(projectPath), normalized);
|
||||
return change.result;
|
||||
};
|
||||
const result = mutationTail.then(execute, execute);
|
||||
mutationTail = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function getConversation(conversationId: string): Promise<CodingConversationV2 | null> {
|
||||
await mutationTail;
|
||||
return (await readNow()).conversations.find((item) => item.id === conversationId) ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
async read(): Promise<CodingConversationFileV2> {
|
||||
await mutationTail;
|
||||
return await readNow();
|
||||
},
|
||||
|
||||
async create(input: CreateCodingConversationInput): Promise<CodingConversationV2> {
|
||||
return await mutate(async (file) => {
|
||||
const timestamp = now();
|
||||
const conversation = normalizeConversation({
|
||||
id: createId(),
|
||||
agentId: input.agentId,
|
||||
title: input.title,
|
||||
model: input.model,
|
||||
modelResolution: input.modelResolution,
|
||||
archivedAt: null,
|
||||
unread: false,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
return {
|
||||
result: conversation,
|
||||
file: { schemaVersion: 2, conversations: [conversation, ...file.conversations] },
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
get: getConversation,
|
||||
|
||||
async patchMetadata(
|
||||
conversationId: string,
|
||||
patch: Partial<Pick<CodingConversationV2, 'title' | 'archivedAt' | 'unread'>>,
|
||||
): Promise<CodingConversationV2> {
|
||||
return await mutate(async (file) => {
|
||||
const current = file.conversations.find((item) => item.id === conversationId);
|
||||
if (!current) throw new Error('Conversation does not exist');
|
||||
const updated = normalizeConversation({
|
||||
...current,
|
||||
...(patch.title !== undefined ? { title: patch.title } : {}),
|
||||
...(patch.archivedAt !== undefined ? { archivedAt: patch.archivedAt } : {}),
|
||||
...(patch.unread !== undefined ? { unread: patch.unread } : {}),
|
||||
updatedAt: now(),
|
||||
});
|
||||
return {
|
||||
result: updated,
|
||||
file: {
|
||||
schemaVersion: 2,
|
||||
conversations: file.conversations.map((item) => item.id === conversationId ? updated : item),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async ensureSessionBinding(
|
||||
conversationId: string,
|
||||
createBinding: () => Promise<PiSessionBinding>,
|
||||
): Promise<CodingConversationV2> {
|
||||
const inFlight = bindingFlights.get(conversationId);
|
||||
if (inFlight) return await inFlight;
|
||||
const flight = (async () => {
|
||||
const current = await getConversation(conversationId);
|
||||
if (!current) throw new Error('Conversation does not exist');
|
||||
if (current.sessionKey && current.piSessionId) return current;
|
||||
const binding = await createBinding();
|
||||
const sessionKey = validateSessionKey(binding.sessionKey);
|
||||
const piSessionId = cleanString(binding.piSessionId);
|
||||
if (!piSessionId) throw new Error('Pi session id is required');
|
||||
return await mutate(async (file) => {
|
||||
const latest = file.conversations.find((item) => item.id === conversationId);
|
||||
if (!latest) throw new Error('Conversation does not exist');
|
||||
if (latest.sessionKey && latest.piSessionId) return { result: latest, file };
|
||||
const updated = normalizeConversation({
|
||||
...latest,
|
||||
sessionKey,
|
||||
piSessionId,
|
||||
updatedAt: now(),
|
||||
});
|
||||
return {
|
||||
result: updated,
|
||||
file: {
|
||||
schemaVersion: 2,
|
||||
conversations: file.conversations.map((item) => item.id === conversationId ? updated : item),
|
||||
},
|
||||
};
|
||||
});
|
||||
})();
|
||||
bindingFlights.set(conversationId, flight);
|
||||
try {
|
||||
return await flight;
|
||||
} finally {
|
||||
bindingFlights.delete(conversationId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
318
electron/coding-projects/migration.ts
Normal file
318
electron/coding-projects/migration.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import {
|
||||
copyFile,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
unlink,
|
||||
} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { ProductModelRef } from '../coding-runtime/contracts';
|
||||
import {
|
||||
buildProjectAgentManifest,
|
||||
normalizeProjectConfig,
|
||||
} from '../opencode/project-config';
|
||||
import type { ProjectAgentConfig, ProjectConfig } from '../../shared/project-config';
|
||||
import {
|
||||
atomicWriteJson,
|
||||
atomicWriteText,
|
||||
type JsonFileWriter,
|
||||
} from './atomic-json';
|
||||
import {
|
||||
CODING_PROJECT_CONFIG_PATH,
|
||||
normalizeCodingProjectConfigV2,
|
||||
type CodingProjectAgentV2,
|
||||
type CodingProjectConfigV2,
|
||||
} from './project-config';
|
||||
import {
|
||||
CODING_CONVERSATIONS_PATH,
|
||||
createEmptyConversationFileV2,
|
||||
} from './conversation-store';
|
||||
|
||||
export interface LegacyModelResolutionInput {
|
||||
agentId: string;
|
||||
legacyModel: string;
|
||||
}
|
||||
|
||||
export type LegacyModelResolver = (
|
||||
input: LegacyModelResolutionInput,
|
||||
) => Promise<ProductModelRef | null>;
|
||||
|
||||
export interface CodingProjectMigrationDependencies {
|
||||
resolveLegacyModel: LegacyModelResolver;
|
||||
now?: () => string;
|
||||
writeJson?: JsonFileWriter;
|
||||
copyFile?: typeof copyFile;
|
||||
}
|
||||
|
||||
export type CodingProjectMigrationResult =
|
||||
| {
|
||||
status: 'already-current';
|
||||
config: CodingProjectConfigV2;
|
||||
}
|
||||
| {
|
||||
status: 'migrated';
|
||||
config: CodingProjectConfigV2;
|
||||
backupDirectory: string;
|
||||
removedGeneratedAgents: string[];
|
||||
backedUpUncertainAgents: string[];
|
||||
};
|
||||
|
||||
type AgentFileSnapshot = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
content: string;
|
||||
generated: boolean;
|
||||
backupPath?: string;
|
||||
};
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException).code === 'ENOENT';
|
||||
}
|
||||
|
||||
function timestampKey(value: string): string {
|
||||
return value.replace(/[^0-9A-Za-z_-]/g, '-');
|
||||
}
|
||||
|
||||
async function readOptionalText(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function snapshotLegacyAgentFiles(
|
||||
projectPath: string,
|
||||
config: ProjectConfig,
|
||||
backupDirectory: string,
|
||||
copy: typeof copyFile,
|
||||
): Promise<AgentFileSnapshot[]> {
|
||||
const agentDirectory = path.join(projectPath, '.opencode', 'agent');
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(agentDirectory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return [];
|
||||
throw error;
|
||||
}
|
||||
|
||||
const expected = new Map(buildProjectAgentManifest(config).entries.map((entry) => [
|
||||
path.basename(entry.relativePath),
|
||||
entry.content,
|
||||
]));
|
||||
const snapshots: AgentFileSnapshot[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
const filePath = path.join(agentDirectory, entry.name);
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const generated = expected.get(entry.name) === content;
|
||||
if (generated) {
|
||||
snapshots.push({ fileName: entry.name, filePath, content, generated });
|
||||
continue;
|
||||
}
|
||||
const backupPath = path.join(backupDirectory, '.opencode', 'agent', entry.name);
|
||||
await mkdir(path.dirname(backupPath), { recursive: true });
|
||||
await copy(filePath, backupPath);
|
||||
snapshots.push({ fileName: entry.name, filePath, content, generated, backupPath });
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
function legacyModelForAgent(config: ProjectConfig, agent: ProjectAgentConfig): string | null {
|
||||
const direct = typeof agent.model === 'string' ? agent.model.trim() : '';
|
||||
const fallback = typeof config.defaultModel === 'string' ? config.defaultModel.trim() : '';
|
||||
return direct || fallback || null;
|
||||
}
|
||||
|
||||
function preserveLegacyAgentFields(
|
||||
rawProject: unknown,
|
||||
normalized: ProjectConfig,
|
||||
): ProjectConfig {
|
||||
if (!rawProject || typeof rawProject !== 'object' || Array.isArray(rawProject)) return normalized;
|
||||
const rawAgents = (rawProject as { agents?: unknown }).agents;
|
||||
if (!Array.isArray(rawAgents)) return normalized;
|
||||
const rawById = new Map<string, { prompt?: unknown; skillIds?: unknown }>();
|
||||
for (const value of rawAgents) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
||||
const record = value as { id?: unknown; prompt?: unknown; skillIds?: unknown };
|
||||
const id = typeof record.id === 'string' ? record.id.trim() : '';
|
||||
if (id) rawById.set(id, record);
|
||||
}
|
||||
return {
|
||||
...normalized,
|
||||
agents: normalized.agents.map((agent) => {
|
||||
const raw = rawById.get(agent.id);
|
||||
const rawSkillIds = Array.isArray(raw?.skillIds)
|
||||
? [...new Set(raw.skillIds
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean))]
|
||||
: agent.skillIds;
|
||||
return {
|
||||
...agent,
|
||||
prompt: typeof raw?.prompt === 'string' ? raw.prompt : agent.prompt,
|
||||
skillIds: rawSkillIds,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function migrateAgent(
|
||||
config: ProjectConfig,
|
||||
agent: ProjectAgentConfig,
|
||||
resolveLegacyModel: LegacyModelResolver,
|
||||
fallbackTimestamp: string,
|
||||
): Promise<CodingProjectAgentV2> {
|
||||
const legacyModel = legacyModelForAgent(config, agent);
|
||||
const model = legacyModel
|
||||
? await resolveLegacyModel({ agentId: agent.id, legacyModel })
|
||||
: null;
|
||||
return {
|
||||
id: agent.id,
|
||||
avatarId: agent.avatarId,
|
||||
...(agent.avatarDataUrl ? { avatarDataUrl: agent.avatarDataUrl } : {}),
|
||||
roleName: agent.roleName,
|
||||
name: agent.name,
|
||||
builtIn: agent.builtIn,
|
||||
enabled: agent.enabled,
|
||||
model,
|
||||
modelResolution: model ? 'resolved' : 'required',
|
||||
skillIds: [...agent.skillIds],
|
||||
responsibility: {
|
||||
mission: agent.responsibility.mission,
|
||||
owns: [...agent.responsibility.owns],
|
||||
boundaries: [...agent.responsibility.boundaries],
|
||||
collaborators: [...agent.responsibility.collaborators],
|
||||
principles: [...agent.responsibility.principles],
|
||||
},
|
||||
prompt: agent.prompt,
|
||||
archivedAt: agent.archivedAt ?? null,
|
||||
pinned: agent.pinned === true,
|
||||
createdAt: agent.createdAt ?? fallbackTimestamp,
|
||||
updatedAt: agent.updatedAt ?? fallbackTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
async function restoreOriginalState(
|
||||
projectConfigPath: string,
|
||||
projectSource: string,
|
||||
conversationsPath: string,
|
||||
conversationsSource: string | null,
|
||||
agentFiles: AgentFileSnapshot[],
|
||||
): Promise<void> {
|
||||
const failures: string[] = [];
|
||||
const restore = async (label: string, operation: () => Promise<void>) => {
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
failures.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
await restore('project config', () => atomicWriteText(projectConfigPath, projectSource));
|
||||
if (conversationsSource === null) {
|
||||
await restore('Conversation metadata', () => rm(conversationsPath, { force: true }));
|
||||
} else {
|
||||
await restore('Conversation metadata', () => atomicWriteText(conversationsPath, conversationsSource));
|
||||
}
|
||||
for (const agent of agentFiles) {
|
||||
await restore(`Agent ${agent.fileName}`, () => atomicWriteText(agent.filePath, agent.content));
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`Pi cutover rollback was incomplete: ${failures.join('; ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrateCodingProjectToV2(
|
||||
projectPath: string,
|
||||
dependencies: CodingProjectMigrationDependencies,
|
||||
): Promise<CodingProjectMigrationResult> {
|
||||
const projectConfigPath = path.join(projectPath, CODING_PROJECT_CONFIG_PATH);
|
||||
const conversationsPath = path.join(projectPath, CODING_CONVERSATIONS_PATH);
|
||||
const projectSource = await readFile(projectConfigPath, 'utf8');
|
||||
const rawProject = JSON.parse(projectSource) as unknown;
|
||||
if (rawProject && typeof rawProject === 'object' && !Array.isArray(rawProject)
|
||||
&& (rawProject as { schemaVersion?: unknown }).schemaVersion === 2) {
|
||||
return {
|
||||
status: 'already-current',
|
||||
config: normalizeCodingProjectConfigV2(rawProject),
|
||||
};
|
||||
}
|
||||
const normalizedLegacyConfig = normalizeProjectConfig(rawProject);
|
||||
const legacyConfig = preserveLegacyAgentFields(rawProject, normalizedLegacyConfig);
|
||||
const conversationsSource = await readOptionalText(conversationsPath);
|
||||
const migrationTimestamp = dependencies.now?.() ?? new Date().toISOString();
|
||||
const backupDirectory = path.join(
|
||||
projectPath,
|
||||
'.niancode',
|
||||
'migration-backups',
|
||||
`opencode-cutover-${timestampKey(migrationTimestamp)}`,
|
||||
);
|
||||
const copy = dependencies.copyFile ?? copyFile;
|
||||
const writeJson = dependencies.writeJson ?? atomicWriteJson;
|
||||
|
||||
await mkdir(path.dirname(backupDirectory), { recursive: true });
|
||||
await mkdir(backupDirectory, { recursive: false });
|
||||
await copy(projectConfigPath, path.join(backupDirectory, 'project.json'));
|
||||
if (conversationsSource !== null) {
|
||||
await copy(conversationsPath, path.join(backupDirectory, 'conversations.json'));
|
||||
}
|
||||
const agentFiles = await snapshotLegacyAgentFiles(
|
||||
projectPath,
|
||||
normalizedLegacyConfig,
|
||||
backupDirectory,
|
||||
copy,
|
||||
);
|
||||
|
||||
const agents = await Promise.all(legacyConfig.agents.map((agent) => migrateAgent(
|
||||
legacyConfig,
|
||||
agent,
|
||||
dependencies.resolveLegacyModel,
|
||||
migrationTimestamp,
|
||||
)));
|
||||
const migratedConfig = normalizeCodingProjectConfigV2({
|
||||
schemaVersion: 2,
|
||||
projectType: legacyConfig.projectType,
|
||||
initialized: legacyConfig.initialized,
|
||||
agents,
|
||||
knowledgeDirectory: 'knowledge',
|
||||
legacyConversationNotice: 'pending',
|
||||
createdAt: legacyConfig.createdAt,
|
||||
updatedAt: migrationTimestamp,
|
||||
});
|
||||
|
||||
try {
|
||||
await writeJson(conversationsPath, createEmptyConversationFileV2());
|
||||
await writeJson(projectConfigPath, migratedConfig);
|
||||
for (const agent of agentFiles) await unlink(agent.filePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
await restoreOriginalState(
|
||||
projectConfigPath,
|
||||
projectSource,
|
||||
conversationsPath,
|
||||
conversationsSource,
|
||||
agentFiles,
|
||||
);
|
||||
} catch (rollbackError) {
|
||||
const rollbackMessage = rollbackError instanceof Error
|
||||
? rollbackError.message
|
||||
: String(rollbackError);
|
||||
const migrationMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Pi project migration failed (${migrationMessage}) and rollback was incomplete: ${rollbackMessage}`,
|
||||
{ cause: rollbackError },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'migrated',
|
||||
config: migratedConfig,
|
||||
backupDirectory,
|
||||
removedGeneratedAgents: agentFiles.filter((agent) => agent.generated).map((agent) => agent.fileName),
|
||||
backedUpUncertainAgents: agentFiles.filter((agent) => !agent.generated).map((agent) => agent.fileName),
|
||||
};
|
||||
}
|
||||
334
electron/coding-projects/project-config.ts
Normal file
334
electron/coding-projects/project-config.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { mkdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
isProjectAgentAvatarDataUrl,
|
||||
isProjectType,
|
||||
type ProjectAgentResponsibility,
|
||||
type ProjectType,
|
||||
} from '../../shared/project-config';
|
||||
import type {
|
||||
ConversationModelState,
|
||||
ConversationThinkingLevel,
|
||||
ProductModelRef,
|
||||
} from '../coding-runtime/contracts';
|
||||
import { atomicWriteJson, readJsonFile, type JsonFileWriter } from './atomic-json';
|
||||
|
||||
export const CODING_PROJECT_CONFIG_PATH = '.niancode/project.json';
|
||||
export const LEGACY_CONVERSATION_NOTICE_VALUES = ['none', 'pending', 'acknowledged'] as const;
|
||||
export type LegacyConversationNotice = (typeof LEGACY_CONVERSATION_NOTICE_VALUES)[number];
|
||||
|
||||
export interface CodingProjectAgentV2 extends ConversationModelState {
|
||||
id: string;
|
||||
avatarId: string;
|
||||
avatarDataUrl?: string;
|
||||
roleName: string;
|
||||
name: string;
|
||||
builtIn: boolean;
|
||||
enabled: boolean;
|
||||
skillIds: string[];
|
||||
responsibility: ProjectAgentResponsibility;
|
||||
prompt: string;
|
||||
archivedAt: string | null;
|
||||
pinned: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CodingProjectConfigV2 {
|
||||
schemaVersion: 2;
|
||||
projectType: ProjectType;
|
||||
initialized: boolean;
|
||||
agents: CodingProjectAgentV2[];
|
||||
knowledgeDirectory: 'knowledge';
|
||||
legacyConversationNotice: LegacyConversationNotice;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type CodingProjectConfigReadResult =
|
||||
| { status: 'valid'; config: CodingProjectConfigV2 }
|
||||
| { status: 'missing' }
|
||||
| { status: 'invalid'; error: string };
|
||||
|
||||
export interface CreateCodingProjectAgentInput {
|
||||
id: string;
|
||||
avatarId: string;
|
||||
avatarDataUrl?: string;
|
||||
roleName: string;
|
||||
name: string;
|
||||
model: ProductModelRef | null;
|
||||
modelResolution: 'resolved' | 'required';
|
||||
skillIds?: string[];
|
||||
responsibility: ProjectAgentResponsibility;
|
||||
prompt?: string;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
const AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
const AVATAR_ID_PATTERN = /^avatar-(0[1-9]|1[0-6])$/;
|
||||
const THINKING_LEVELS = new Set<ConversationThinkingLevel>([
|
||||
'off',
|
||||
'minimal',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
]);
|
||||
|
||||
function projectConfigPath(projectPath: string): string {
|
||||
return path.join(projectPath, CODING_PROJECT_CONFIG_PATH);
|
||||
}
|
||||
|
||||
function cleanString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function cleanStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return [...new Set(value.map(cleanString).filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeResponsibility(value: unknown): ProjectAgentResponsibility {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Project Agent responsibility is invalid');
|
||||
}
|
||||
const record = value as Partial<ProjectAgentResponsibility>;
|
||||
if (typeof record.mission !== 'string'
|
||||
|| !Array.isArray(record.owns)
|
||||
|| !Array.isArray(record.boundaries)
|
||||
|| !Array.isArray(record.collaborators)
|
||||
|| !Array.isArray(record.principles)) {
|
||||
throw new Error('Project Agent responsibility is invalid');
|
||||
}
|
||||
return {
|
||||
mission: cleanString(record.mission),
|
||||
owns: cleanStringList(record.owns),
|
||||
boundaries: cleanStringList(record.boundaries),
|
||||
collaborators: cleanStringList(record.collaborators),
|
||||
principles: cleanStringList(record.principles),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeProductModelRef(value: unknown): ProductModelRef {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Product model must be an object');
|
||||
}
|
||||
const record = value as Partial<ProductModelRef>;
|
||||
const accountId = cleanString(record.accountId);
|
||||
const modelId = cleanString(record.modelId);
|
||||
if (!accountId || !modelId || !THINKING_LEVELS.has(record.thinkingLevel as ConversationThinkingLevel)) {
|
||||
throw new Error('Product model reference is invalid');
|
||||
}
|
||||
return {
|
||||
accountId,
|
||||
modelId,
|
||||
thinkingLevel: record.thinkingLevel as ConversationThinkingLevel,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeModelState(value: {
|
||||
model?: unknown;
|
||||
modelResolution?: unknown;
|
||||
}): ConversationModelState {
|
||||
if (value.modelResolution === 'required') {
|
||||
if (value.model !== null) throw new Error('Required model selection must not contain a model');
|
||||
return { model: null, modelResolution: 'required' };
|
||||
}
|
||||
if (value.modelResolution !== 'resolved') throw new Error('Project Agent model resolution is invalid');
|
||||
return { model: normalizeProductModelRef(value.model), modelResolution: 'resolved' };
|
||||
}
|
||||
|
||||
function normalizeAgent(value: unknown): CodingProjectAgentV2 {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Project Agent must be an object');
|
||||
}
|
||||
const record = value as Partial<CodingProjectAgentV2>;
|
||||
const id = cleanString(record.id);
|
||||
const name = cleanString(record.name);
|
||||
const avatarId = cleanString(record.avatarId);
|
||||
const roleName = cleanString(record.roleName);
|
||||
const createdAt = cleanString(record.createdAt);
|
||||
const updatedAt = cleanString(record.updatedAt);
|
||||
if (!AGENT_ID_PATTERN.test(id)) throw new Error('Project Agent id is invalid');
|
||||
if (!name) throw new Error('Project Agent name is required');
|
||||
if (!AVATAR_ID_PATTERN.test(avatarId)) throw new Error('Project Agent avatar is invalid');
|
||||
if (!roleName) throw new Error('Project Agent role is required');
|
||||
if (!createdAt || !updatedAt) throw new Error('Project Agent timestamps are required');
|
||||
if (typeof record.builtIn !== 'boolean'
|
||||
|| typeof record.enabled !== 'boolean'
|
||||
|| typeof record.pinned !== 'boolean'
|
||||
|| !Array.isArray(record.skillIds)
|
||||
|| typeof record.prompt !== 'string'
|
||||
|| !(record.archivedAt === null || typeof record.archivedAt === 'string')) {
|
||||
throw new Error('Project Agent metadata is invalid');
|
||||
}
|
||||
return {
|
||||
id,
|
||||
avatarId,
|
||||
...(isProjectAgentAvatarDataUrl(record.avatarDataUrl)
|
||||
? { avatarDataUrl: record.avatarDataUrl }
|
||||
: {}),
|
||||
roleName,
|
||||
name,
|
||||
builtIn: record.builtIn === true,
|
||||
enabled: record.enabled !== false,
|
||||
...normalizeModelState(record),
|
||||
skillIds: cleanStringList(record.skillIds),
|
||||
responsibility: normalizeResponsibility(record.responsibility),
|
||||
prompt: record.prompt,
|
||||
archivedAt: cleanString(record.archivedAt) || null,
|
||||
pinned: record.pinned === true,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function validateAgentNames(agents: CodingProjectAgentV2[]): void {
|
||||
const names = new Set<string>();
|
||||
for (const agent of agents) {
|
||||
if (agent.name.length > 30) throw new Error('Project Agent name is too long');
|
||||
const key = agent.name.toLocaleLowerCase();
|
||||
if (names.has(key)) throw new Error('Duplicate project Agent name');
|
||||
names.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectConfigV2 {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Coding project config must be an object');
|
||||
}
|
||||
const record = value as Partial<CodingProjectConfigV2>;
|
||||
if (record.schemaVersion !== 2) throw new Error('Unsupported coding project config schema');
|
||||
if (!isProjectType(record.projectType)) throw new Error('Invalid project type');
|
||||
if (typeof record.initialized !== 'boolean') throw new Error('Project initialized state is invalid');
|
||||
if (record.knowledgeDirectory !== 'knowledge') throw new Error('Project knowledge directory is invalid');
|
||||
if (!Array.isArray(record.agents)) throw new Error('Project Agents must be an array');
|
||||
const createdAt = cleanString(record.createdAt);
|
||||
const updatedAt = cleanString(record.updatedAt);
|
||||
if (!createdAt || !updatedAt) throw new Error('Project timestamps are required');
|
||||
if (!LEGACY_CONVERSATION_NOTICE_VALUES.includes(record.legacyConversationNotice as LegacyConversationNotice)) {
|
||||
throw new Error('Legacy Conversation notice state is invalid');
|
||||
}
|
||||
const agents = record.agents.map(normalizeAgent);
|
||||
if (new Set(agents.map((agent) => agent.id)).size !== agents.length) {
|
||||
throw new Error('Duplicate project Agent id');
|
||||
}
|
||||
validateAgentNames(agents);
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
projectType: record.projectType,
|
||||
initialized: record.initialized === true,
|
||||
agents,
|
||||
knowledgeDirectory: 'knowledge',
|
||||
legacyConversationNotice: record.legacyConversationNotice as LegacyConversationNotice,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCodingProjectConfigV2(
|
||||
now = new Date().toISOString(),
|
||||
projectType: ProjectType = 'custom',
|
||||
): CodingProjectConfigV2 {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
projectType,
|
||||
initialized: false,
|
||||
agents: [],
|
||||
knowledgeDirectory: 'knowledge',
|
||||
legacyConversationNotice: 'none',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readCodingProjectConfigV2(
|
||||
projectPath: string,
|
||||
): Promise<CodingProjectConfigReadResult> {
|
||||
try {
|
||||
return {
|
||||
status: 'valid',
|
||||
config: normalizeCodingProjectConfigV2(await readJsonFile(projectConfigPath(projectPath))),
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' };
|
||||
return { status: 'invalid', error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeCodingProjectConfigV2(
|
||||
projectPath: string,
|
||||
value: unknown,
|
||||
writer: JsonFileWriter = atomicWriteJson,
|
||||
): Promise<CodingProjectConfigV2> {
|
||||
const config = normalizeCodingProjectConfigV2(value);
|
||||
await writer(projectConfigPath(projectPath), config);
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function createCodingProjectMetadata(
|
||||
projectPath: string,
|
||||
options: {
|
||||
projectType?: ProjectType;
|
||||
now?: string;
|
||||
writer?: JsonFileWriter;
|
||||
} = {},
|
||||
): Promise<CodingProjectConfigV2> {
|
||||
try {
|
||||
await stat(projectConfigPath(projectPath));
|
||||
throw new Error('Coding project configuration already exists');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
const config = createCodingProjectConfigV2(options.now, options.projectType);
|
||||
await Promise.all([
|
||||
mkdir(path.join(projectPath, '.niancode'), { recursive: true }),
|
||||
mkdir(path.join(projectPath, 'knowledge'), { recursive: true }),
|
||||
]);
|
||||
await (options.writer ?? atomicWriteJson)(projectConfigPath(projectPath), config);
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function createCodingProjectAgent(
|
||||
projectPath: string,
|
||||
input: CreateCodingProjectAgentInput,
|
||||
options: { now?: string; writer?: JsonFileWriter } = {},
|
||||
): Promise<CodingProjectAgentV2> {
|
||||
const result = await readCodingProjectConfigV2(projectPath);
|
||||
if (result.status !== 'valid') throw new Error('Coding project configuration is missing or invalid');
|
||||
if (result.config.agents.some((agent) => agent.id === input.id.trim())) {
|
||||
throw new Error('Project Agent id already exists');
|
||||
}
|
||||
const now = options.now ?? new Date().toISOString();
|
||||
const agent = normalizeAgent({
|
||||
...input,
|
||||
builtIn: false,
|
||||
enabled: true,
|
||||
skillIds: input.skillIds ?? [],
|
||||
prompt: input.prompt ?? '',
|
||||
archivedAt: null,
|
||||
pinned: input.pinned ?? false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await writeCodingProjectConfigV2(projectPath, {
|
||||
...result.config,
|
||||
initialized: true,
|
||||
agents: [...result.config.agents, agent],
|
||||
updatedAt: now,
|
||||
}, options.writer);
|
||||
return agent;
|
||||
}
|
||||
|
||||
export async function acknowledgeLegacyConversationNotice(
|
||||
projectPath: string,
|
||||
options: { now?: string; writer?: JsonFileWriter } = {},
|
||||
): Promise<CodingProjectConfigV2> {
|
||||
const result = await readCodingProjectConfigV2(projectPath);
|
||||
if (result.status !== 'valid') throw new Error('Coding project configuration is missing or invalid');
|
||||
if (result.config.legacyConversationNotice !== 'pending') return result.config;
|
||||
return await writeCodingProjectConfigV2(projectPath, {
|
||||
...result.config,
|
||||
legacyConversationNotice: 'acknowledged',
|
||||
updatedAt: options.now ?? new Date().toISOString(),
|
||||
}, options.writer);
|
||||
}
|
||||
180
electron/coding-projects/project-store.ts
Normal file
180
electron/coding-projects/project-store.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import type { ProjectType } from '../../shared/project-config';
|
||||
import {
|
||||
createCodingProjectMetadata,
|
||||
type CodingProjectConfigV2,
|
||||
} from './project-config';
|
||||
|
||||
export interface CodingProject {
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastOpenedAt: string;
|
||||
}
|
||||
|
||||
export interface CodingProjectStoreData {
|
||||
projects: Record<string, CodingProject>;
|
||||
activeProjectId: string | null;
|
||||
}
|
||||
|
||||
export interface CodingProjectStorage {
|
||||
read(): Promise<CodingProjectStoreData | undefined>;
|
||||
write(data: CodingProjectStoreData): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CodingProjectKeyValueStore {
|
||||
get(key: string): unknown;
|
||||
set(key: string, value: CodingProjectStoreData): unknown;
|
||||
}
|
||||
|
||||
export type CodingProjectStore = ReturnType<typeof createCodingProjectStore>;
|
||||
|
||||
const DEFAULT_PROJECT_STORE_KEY = 'coding-projects-v2';
|
||||
|
||||
export function normalizeCodingProjectPath(input: string): string {
|
||||
const normalized = path.normalize(path.resolve(input.trim()));
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
export function createMemoryCodingProjectStorage(
|
||||
initial?: CodingProjectStoreData,
|
||||
): CodingProjectStorage {
|
||||
let stored = initial ? structuredClone(initial) : undefined;
|
||||
return {
|
||||
async read() {
|
||||
return stored ? structuredClone(stored) : undefined;
|
||||
},
|
||||
async write(data) {
|
||||
stored = structuredClone(data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createCodingProjectStorageFromStore(
|
||||
store: CodingProjectKeyValueStore,
|
||||
key = DEFAULT_PROJECT_STORE_KEY,
|
||||
): CodingProjectStorage {
|
||||
return {
|
||||
async read() {
|
||||
const data = store.get(key);
|
||||
return data ? structuredClone(data) as CodingProjectStoreData : undefined;
|
||||
},
|
||||
async write(data) {
|
||||
store.set(key, structuredClone(data));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emptyStoreData(): CodingProjectStoreData {
|
||||
return { projects: {}, activeProjectId: null };
|
||||
}
|
||||
|
||||
export function createCodingProjectStore(
|
||||
storage: CodingProjectStorage,
|
||||
options: {
|
||||
createId?: () => string;
|
||||
now?: () => string;
|
||||
} = {},
|
||||
) {
|
||||
const createId = options.createId ?? randomUUID;
|
||||
const now = options.now ?? (() => new Date().toISOString());
|
||||
let mutationTail = Promise.resolve();
|
||||
|
||||
async function readData(): Promise<CodingProjectStoreData> {
|
||||
return (await storage.read()) ?? emptyStoreData();
|
||||
}
|
||||
|
||||
function mutate<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = mutationTail.then(operation, operation);
|
||||
mutationTail = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function upsertProject(input: string, activate: boolean): Promise<CodingProject> {
|
||||
return await mutate(async () => {
|
||||
const data = await readData();
|
||||
const normalizedPath = normalizeCodingProjectPath(input);
|
||||
const existing = Object.values(data.projects)
|
||||
.find((project) => project.path === normalizedPath);
|
||||
const timestamp = now();
|
||||
const project: CodingProject = existing
|
||||
? {
|
||||
...existing,
|
||||
name: path.basename(normalizedPath),
|
||||
updatedAt: timestamp,
|
||||
lastOpenedAt: timestamp,
|
||||
}
|
||||
: {
|
||||
id: createId(),
|
||||
path: normalizedPath,
|
||||
name: path.basename(normalizedPath),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
lastOpenedAt: timestamp,
|
||||
};
|
||||
data.projects[project.id] = project;
|
||||
if (activate) data.activeProjectId = project.id;
|
||||
await storage.write(data);
|
||||
return project;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
async openFolder(input: string): Promise<CodingProject> {
|
||||
return await upsertProject(input, true);
|
||||
},
|
||||
|
||||
async rememberProject(input: string): Promise<CodingProject> {
|
||||
return await upsertProject(input, false);
|
||||
},
|
||||
|
||||
async listProjects(): Promise<CodingProject[]> {
|
||||
await mutationTail;
|
||||
return Object.values((await readData()).projects)
|
||||
.sort((left, right) => right.lastOpenedAt.localeCompare(left.lastOpenedAt));
|
||||
},
|
||||
|
||||
async getActiveProject(): Promise<CodingProject | null> {
|
||||
await mutationTail;
|
||||
const data = await readData();
|
||||
return data.activeProjectId ? data.projects[data.activeProjectId] ?? null : null;
|
||||
},
|
||||
|
||||
async setActiveProject(projectId: string | null): Promise<CodingProject | null> {
|
||||
return await mutate(async () => {
|
||||
const data = await readData();
|
||||
data.activeProjectId = projectId && data.projects[projectId] ? projectId : null;
|
||||
await storage.write(data);
|
||||
return data.activeProjectId ? data.projects[data.activeProjectId] : null;
|
||||
});
|
||||
},
|
||||
|
||||
async removeProject(projectId: string): Promise<void> {
|
||||
await mutate(async () => {
|
||||
const data = await readData();
|
||||
delete data.projects[projectId];
|
||||
if (data.activeProjectId === projectId) data.activeProjectId = null;
|
||||
await storage.write(data);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createLocalCodingProject(
|
||||
input: {
|
||||
projectPath: string;
|
||||
projectType?: ProjectType;
|
||||
now?: string;
|
||||
},
|
||||
store: CodingProjectStore,
|
||||
): Promise<{ project: CodingProject; config: CodingProjectConfigV2 }> {
|
||||
const config = await createCodingProjectMetadata(input.projectPath, {
|
||||
projectType: input.projectType,
|
||||
now: input.now,
|
||||
});
|
||||
const project = await store.openFolder(input.projectPath);
|
||||
return { project, config };
|
||||
}
|
||||
Reference in New Issue
Block a user