From 80ac69b1cd0bbcac302e3bc6b495b2d44aaca7fb Mon Sep 17 00:00:00 2001 From: kevinwang Date: Tue, 2 Sep 2025 19:57:00 -0400 Subject: [PATCH 01/67] Added login helper and trying out parser abstraction --- src/common/initialize-plugins.ts | 5 ++- src/connect/login-helper.ts | 60 ++++++++++++++++++++++++++++++++ src/orchestrators/login.ts | 9 ++--- src/orchestrators/plan.ts | 5 ++- src/parser/cloud/cloud-parser.ts | 9 +++++ 5 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 src/connect/login-helper.ts create mode 100644 src/parser/cloud/cloud-parser.ts diff --git a/src/common/initialize-plugins.ts b/src/common/initialize-plugins.ts index 5696310d..1122f9ea 100644 --- a/src/common/initialize-plugins.ts +++ b/src/common/initialize-plugins.ts @@ -7,6 +7,7 @@ import { SubProcessName, ctx } from '../events/context.js'; import { CODIFY_FILE_REGEX, CodifyParser } from '../parser/index.js'; import { DependencyMap, PluginManager } from '../plugins/plugin-manager.js'; import { Reporter } from '../ui/reporters/reporter.js'; +import { LoginHelper } from '../connect/login-helper.js'; export interface InitializeArgs { path?: string; @@ -55,7 +56,9 @@ export class PluginInitOrchestrator { ? await PluginInitOrchestrator.findCodifyJson() : fileOrDir - if (!pathToParse && !allowEmptyProject) { + const isLoggedIn = LoginHelper.get()?.isLoggedIn ?? false; + + if (!pathToParse && !allowEmptyProject && !isLoggedIn) { ctx.subprocessFinished(SubProcessName.PARSE); ctx.subprocessStarted(SubProcessName.CREATE_ROOT_FILE) const createRootCodifyFile = await reporter.promptConfirmation('\nNo codify file found. Do you want to create a root file at ~/codify.jsonc?'); diff --git a/src/connect/login-helper.ts b/src/connect/login-helper.ts new file mode 100644 index 00000000..ef337b81 --- /dev/null +++ b/src/connect/login-helper.ts @@ -0,0 +1,60 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +interface Credentials { + accessToken: string; + email: string; + userId: string; + expiry: string; +} + +export class LoginHelper { + private static instance: LoginHelper; + + private constructor( + public isLoggedIn: boolean, + public credentials?: Credentials + ) {}; + + static async load(): Promise { + if (LoginHelper.instance) { + return LoginHelper.instance; + } + + const credentials = await LoginHelper.read(); + if (!credentials) { + LoginHelper.instance = new LoginHelper(false); + return LoginHelper.instance; + } + + if (new Date(credentials.expiry).getTime() < Date.now()) { + LoginHelper.instance = new LoginHelper(false); + return LoginHelper.instance; + } + + LoginHelper.instance = new LoginHelper(true, credentials); + return LoginHelper.instance; + } + + static get(): LoginHelper | undefined { + return LoginHelper.instance; + } + + static async save(credentials: Credentials) { + const credentialsPath = path.join(os.homedir(), '.codify', 'credentials.json'); + console.log(`Saving credentials to ${credentialsPath}`); + await fs.writeFile(credentialsPath, JSON.stringify(credentials)); + } + + private static async read(): Promise { + const credentialsPath = path.join(os.homedir(), '.codify', 'credentials.json'); + const credentialsStr = await fs.readFile(credentialsPath, 'utf8'); + + try { + return JSON.parse(credentialsStr); + } catch { + return undefined; + } + } +} diff --git a/src/orchestrators/login.ts b/src/orchestrators/login.ts index 2bd4073f..9139e22c 100644 --- a/src/orchestrators/login.ts +++ b/src/orchestrators/login.ts @@ -7,6 +7,7 @@ import open from 'open'; import { config } from '../config.js'; import { ajv } from '../utils/ajv.js'; +import { LoginHelper } from '../connect/login-helper.js'; const schema = { type: 'object', @@ -48,7 +49,7 @@ export class LoginOrchestrator { return res.status(400).send({ message: ajv.errorsText() }) } - await LoginOrchestrator.saveCredentials(body) + await LoginHelper.save(body); return res.sendStatus(200); }); @@ -57,10 +58,4 @@ export class LoginOrchestrator { open('http://localhost:3000/auth/cli'); }) } - - private static async saveCredentials(credentials: Credentials) { - const credentialsPath = path.join(os.homedir(), '.codify', 'credentials.json'); - console.log(`Saving credentials to ${credentialsPath}`); - await fs.writeFile(credentialsPath, JSON.stringify(credentials)); - } } diff --git a/src/orchestrators/plan.ts b/src/orchestrators/plan.ts index f4183ff3..e60edca3 100644 --- a/src/orchestrators/plan.ts +++ b/src/orchestrators/plan.ts @@ -6,6 +6,7 @@ import { PluginManager } from '../plugins/plugin-manager.js'; import { Reporter } from '../ui/reporters/reporter.js'; import { createStartupShellScriptsIfNotExists } from '../utils/file.js'; import { ValidateOrchestrator } from './validate.js'; +import { LoginHelper } from '../connect/login-helper.js'; export interface PlanArgs { path?: string; @@ -21,7 +22,9 @@ export interface PlanOrchestratorResponse { export class PlanOrchestrator { static async run(args: PlanArgs, reporter: Reporter): Promise { - ctx.processStarted(ProcessName.PLAN) + ctx.processStarted(ProcessName.PLAN); + + await LoginHelper.load(); const initializationResult = await PluginInitOrchestrator.run({ ...args, diff --git a/src/parser/cloud/cloud-parser.ts b/src/parser/cloud/cloud-parser.ts new file mode 100644 index 00000000..6616a5ea --- /dev/null +++ b/src/parser/cloud/cloud-parser.ts @@ -0,0 +1,9 @@ +import { InMemoryFile, LanguageSpecificParser, ParsedConfig } from '../entities.js'; +import { SourceMapCache } from '../source-maps.js'; + +export class CloudParser implements LanguageSpecificParser { + parse(file: InMemoryFile, sourceMaps: SourceMapCache): ParsedConfig[] { + throw new Error('Method not implemented.'); + } + +} From 893133699333f8dac7c7806ca60e249d48d7ddcd Mon Sep 17 00:00:00 2001 From: kevinwang Date: Tue, 2 Sep 2025 21:51:58 -0400 Subject: [PATCH 02/67] Added ability to load, apply and plan cloud files --- src/api/dashboard/index.ts | 25 +++++++++++ src/api/dashboard/types.ts | 6 +++ src/commands/init.ts | 2 + src/commands/login.ts | 2 + src/common/initialize-plugins.ts | 10 ++--- src/parser/cloud/cloud-parser.ts | 16 ++++++-- src/parser/entities.ts | 1 + src/parser/index.ts | 41 ++++++++++++++----- src/parser/jsonc/json-parser.ts | 6 +-- src/parser/reader/cloud-reader.ts | 16 ++++++++ .../{reader.ts => reader/file-reader.ts} | 15 +++---- src/parser/reader/index.ts | 5 +++ 12 files changed, 114 insertions(+), 31 deletions(-) create mode 100644 src/api/dashboard/index.ts create mode 100644 src/api/dashboard/types.ts create mode 100644 src/parser/reader/cloud-reader.ts rename src/parser/{reader.ts => reader/file-reader.ts} (70%) create mode 100644 src/parser/reader/index.ts diff --git a/src/api/dashboard/index.ts b/src/api/dashboard/index.ts new file mode 100644 index 00000000..aa0b74a0 --- /dev/null +++ b/src/api/dashboard/index.ts @@ -0,0 +1,25 @@ +import { LoginHelper } from '../../connect/login-helper.js'; +import { CloudDocument } from './types.js'; + +const API_BASE_URL = 'http://localhost:3000' + +export const DashboardApiClient = { + async getDocument(id: string): Promise { + const login = LoginHelper.get()?.credentials; + if (!login) { + throw new Error('Not logged in'); + } + + const res = await fetch( + `${API_BASE_URL}/api/documents/${id}`, + { method: 'GET', headers: { 'Content-Type': 'application/json', 'authorization': login.accessToken } }, + ); + + const json = await res.json(); + if (!res.ok) { + throw new Error(JSON.stringify(json, null, 2)); + } + + return json; + } +} diff --git a/src/api/dashboard/types.ts b/src/api/dashboard/types.ts new file mode 100644 index 00000000..c36ba44f --- /dev/null +++ b/src/api/dashboard/types.ts @@ -0,0 +1,6 @@ +import { Config } from 'codify-schemas'; + +export interface CloudDocument { + id: string; + contents: Array +} diff --git a/src/commands/init.ts b/src/commands/init.ts index 03fcb560..26d01e22 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -30,5 +30,7 @@ For more information, visit: https://docs.codifycli.com/commands/init` await InitializeOrchestrator.run({ verbosityLevel: flags.debug ? 3 : 0, },this.reporter); + + process.exit(0) } } diff --git a/src/commands/login.ts b/src/commands/login.ts index 85907c8b..de899b0b 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -19,5 +19,7 @@ For more information, visit: https://docs.codifycli.com/commands/validate const { flags } = await this.parse(Login) await LoginOrchestrator.run(); + + process.exit(0) } } diff --git a/src/common/initialize-plugins.ts b/src/common/initialize-plugins.ts index 1122f9ea..765300f4 100644 --- a/src/common/initialize-plugins.ts +++ b/src/common/initialize-plugins.ts @@ -52,13 +52,9 @@ export class PluginInitOrchestrator { ): Promise { ctx.subprocessStarted(SubProcessName.PARSE); - const pathToParse = (fileOrDir === undefined) - ? await PluginInitOrchestrator.findCodifyJson() - : fileOrDir - const isLoggedIn = LoginHelper.get()?.isLoggedIn ?? false; - if (!pathToParse && !allowEmptyProject && !isLoggedIn) { + if (!fileOrDir && !allowEmptyProject && !isLoggedIn) { ctx.subprocessFinished(SubProcessName.PARSE); ctx.subprocessStarted(SubProcessName.CREATE_ROOT_FILE) const createRootCodifyFile = await reporter.promptConfirmation('\nNo codify file found. Do you want to create a root file at ~/codify.jsonc?'); @@ -77,8 +73,8 @@ export class PluginInitOrchestrator { process.exit(0); } - const project = pathToParse - ? await CodifyParser.parse(pathToParse) + const project = fileOrDir + ? await CodifyParser.parse(fileOrDir) : Project.empty() ctx.subprocessFinished(SubProcessName.PARSE); diff --git a/src/parser/cloud/cloud-parser.ts b/src/parser/cloud/cloud-parser.ts index 6616a5ea..3230879c 100644 --- a/src/parser/cloud/cloud-parser.ts +++ b/src/parser/cloud/cloud-parser.ts @@ -1,9 +1,19 @@ +import { Config } from 'codify-schemas'; + import { InMemoryFile, LanguageSpecificParser, ParsedConfig } from '../entities.js'; import { SourceMapCache } from '../source-maps.js'; export class CloudParser implements LanguageSpecificParser { - parse(file: InMemoryFile, sourceMaps: SourceMapCache): ParsedConfig[] { - throw new Error('Method not implemented.'); - } + parse(file: InMemoryFile, sourceMaps: SourceMapCache): ParsedConfig[] { + const contents = JSON.parse(file.contents) as Array; + + return contents.map((content) => { + const { id, type, ...config } = content; + return { + contents: { type, ...config }, + sourceMapKey: id as string, + } + }) + } } diff --git a/src/parser/entities.ts b/src/parser/entities.ts index b52ad02d..6633c89d 100644 --- a/src/parser/entities.ts +++ b/src/parser/entities.ts @@ -21,4 +21,5 @@ export enum FileType { YAML = 'yaml', JSON5 = 'json5', JSONC = 'jsonc', + CLOUD = 'cloud', } diff --git a/src/parser/index.ts b/src/parser/index.ts index 4789fc32..51fce4c5 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -1,15 +1,19 @@ import * as fs from 'node:fs/promises'; import path from 'node:path'; +import { validate } from 'uuid' import { InternalError } from '../common/errors.js'; import { ConfigBlock } from '../entities/config.js'; import { Project } from '../entities/project.js'; +import { FileUtils } from '../utils/file.js'; +import { CloudParser } from './cloud/cloud-parser.js'; import { ConfigFactory } from './config-factory.js'; import { FileType, InMemoryFile, ParsedConfig } from './entities.js'; import { JsonParser } from './json/json-parser.js'; import { Json5Parser } from './json5/json-parser.js'; import { JsoncParser } from './jsonc/json-parser.js'; -import { FileReader } from './reader.js'; +import { CloudReader } from './reader/cloud-reader.js'; +import { FileReader } from './reader/file-reader.js'; import { SourceMapCache } from './source-maps.js'; import { YamlParser } from './yaml/yaml-parser.js'; @@ -20,13 +24,13 @@ class Parser { [FileType.JSON]: new JsonParser(), [FileType.YAML]: new YamlParser(), [FileType.JSON5]: new Json5Parser(), - [FileType.JSONC]: new JsoncParser() + [FileType.JSONC]: new JsoncParser(), + [FileType.CLOUD]: new CloudParser(), } async parse(dirOrFile: string): Promise { - const absolutePath = path.resolve(dirOrFile); const sourceMaps = new SourceMapCache() - const codifyFiles = await this.getFilePaths(absolutePath) + const codifyFiles = await this.getFilePaths(dirOrFile) const configs = await this.readFiles(codifyFiles) .then((files) => this.parseContents(files, sourceMaps)) @@ -36,28 +40,43 @@ class Parser { } private async getFilePaths(dirOrFile: string): Promise { - const isDirectory = (await fs.lstat(dirOrFile)).isDirectory(); + // A cloud file is represented as an uuid. Skip file checks if it's a cloud file; + if (validate(dirOrFile)) { + return [dirOrFile]; + } + + const absolutePath = path.resolve(dirOrFile); + const isDirectory = (await fs.lstat(absolutePath)).isDirectory(); // A single file was passed in. We need to test if the file satisfies the codify file regex if (!isDirectory) { - const fileName = path.basename(dirOrFile); + const fileName = path.basename(absolutePath); if (!CODIFY_FILE_REGEX.test(fileName)) { - throw new Error(`Invalid file path provided ${dirOrFile} ${fileName}. Expected the file to be *.codify.jsonc, *.codify.json5, *.codify.json, or *.codify.yaml `) + throw new Error(`Invalid file path provided ${absolutePath} ${fileName}. Expected the file to be *.codify.jsonc, *.codify.json5, *.codify.json, or *.codify.yaml `) } - return [dirOrFile]; + return [absolutePath]; } - const filesInDir = await fs.readdir(dirOrFile); + const filesInDir = await fs.readdir(absolutePath); return filesInDir .filter((name) => CODIFY_FILE_REGEX.test(name)) - .map((name) => path.join(dirOrFile, name)) + .map((name) => path.join(absolutePath, name)) } private readFiles(filePaths: string[]): Promise { + const cloudReader = new CloudReader(); + const fileReader = new FileReader(); + return Promise.all(filePaths.map( - (p) => FileReader.read(p) + async (p) => { + if (validate(p) && !(await FileUtils.fileExists(p))) { + return cloudReader.read(p) + } + + return fileReader.read(p) + } )) } diff --git a/src/parser/jsonc/json-parser.ts b/src/parser/jsonc/json-parser.ts index ce9135f6..383ba04d 100644 --- a/src/parser/jsonc/json-parser.ts +++ b/src/parser/jsonc/json-parser.ts @@ -30,9 +30,9 @@ export class JsoncParser implements LanguageSpecificParser { } return content.map((contents, idx) => ({ - contents, - sourceMapKey: SourceMapCache.constructKey(file.filePath, `/${idx}`) - })) + contents, + sourceMapKey: SourceMapCache.constructKey(file.filePath, `/${idx}`) + })) } private validate(content: unknown): content is Config[] { diff --git a/src/parser/reader/cloud-reader.ts b/src/parser/reader/cloud-reader.ts new file mode 100644 index 00000000..c03ae722 --- /dev/null +++ b/src/parser/reader/cloud-reader.ts @@ -0,0 +1,16 @@ +import { DashboardApiClient } from '../../api/dashboard/index.js'; +import { FileType, InMemoryFile } from '../entities.js'; +import { Reader } from './index.js'; + +export class CloudReader implements Reader { + async read(filePath: string): Promise { + const document = await DashboardApiClient.getDocument(filePath); + + return { + contents: JSON.stringify(document.contents), + filePath, + fileType: FileType.CLOUD, + } + } + +} diff --git a/src/parser/reader.ts b/src/parser/reader/file-reader.ts similarity index 70% rename from src/parser/reader.ts rename to src/parser/reader/file-reader.ts index ddfe048c..1089e90e 100644 --- a/src/parser/reader.ts +++ b/src/parser/reader/file-reader.ts @@ -1,15 +1,16 @@ import * as fs from 'node:fs/promises'; -import { InternalError } from '../common/errors.js'; -import { FileType, InMemoryFile } from './entities.js'; +import { InternalError } from '../../common/errors.js'; +import { FileType, InMemoryFile } from '../entities.js'; +import { Reader } from './index.js'; -export class FileReader { - static async read(filePath: string): Promise { +export class FileReader implements Reader { + async read(filePath: string): Promise { const contents = await fs.readFile(filePath, 'utf8'); - + return { contents, - filePath: filePath, + filePath, fileType: FileReader.getFileType(filePath), } } @@ -30,7 +31,7 @@ export class FileReader { if (filePath.endsWith('.jsonc')) { return FileType.JSONC } - + throw new InternalError(`Unsupported file type passed to FileReader. File path: ${filePath}`); } } diff --git a/src/parser/reader/index.ts b/src/parser/reader/index.ts new file mode 100644 index 00000000..d2671e11 --- /dev/null +++ b/src/parser/reader/index.ts @@ -0,0 +1,5 @@ +import { InMemoryFile } from '../entities.js'; + +export interface Reader { + read(filePath: string): Promise; +} From 4b58c7f5d634653649390bbf83581ae020ff317f Mon Sep 17 00:00:00 2001 From: kevinwang Date: Thu, 4 Sep 2025 23:09:41 -0400 Subject: [PATCH 03/67] Moved login init to base command. Fixes --- src/api/dashboard/index.ts | 2 +- src/commands/import.ts | 2 +- src/common/base-command.ts | 3 +++ src/orchestrators/plan.ts | 2 -- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/api/dashboard/index.ts b/src/api/dashboard/index.ts index aa0b74a0..a58880e5 100644 --- a/src/api/dashboard/index.ts +++ b/src/api/dashboard/index.ts @@ -11,7 +11,7 @@ export const DashboardApiClient = { } const res = await fetch( - `${API_BASE_URL}/api/documents/${id}`, + `${API_BASE_URL}/api/v1/documents/${id}`, { method: 'GET', headers: { 'Content-Type': 'application/json', 'authorization': login.accessToken } }, ); diff --git a/src/commands/import.ts b/src/commands/import.ts index 833ae648..deec9a0b 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -52,7 +52,7 @@ For more information, visit: https://docs.codifycli.com/commands/import` this.log(`Applying Codify from: ${flags.path}`); } - const resolvedPath = path.resolve(flags.path ?? '.'); + const resolvedPath = flags.path ?? '.'; const args = raw .filter((r) => r.type === 'arg') diff --git a/src/common/base-command.ts b/src/common/base-command.ts index ceec0ede..cd242422 100644 --- a/src/common/base-command.ts +++ b/src/common/base-command.ts @@ -4,6 +4,7 @@ import chalk from 'chalk'; import { PressKeyToContinueRequestData, SudoRequestData } from 'codify-schemas'; import createDebug from 'debug'; +import { LoginHelper } from '../connect/login-helper.js'; import { Event, ctx } from '../events/context.js'; import { Reporter, ReporterFactory, ReporterType } from '../ui/reporters/reporter.js'; import { SudoUtils } from '../utils/sudo.js'; @@ -63,6 +64,8 @@ export abstract class BaseCommand extends Command { await this.reporter.promptPressKeyToContinue(data.promptMessage) ctx.pressKeyToContinueCompleted(pluginName) }) + + await LoginHelper.load(); } protected async catch(err: Error): Promise { diff --git a/src/orchestrators/plan.ts b/src/orchestrators/plan.ts index e60edca3..c23b4ce7 100644 --- a/src/orchestrators/plan.ts +++ b/src/orchestrators/plan.ts @@ -24,8 +24,6 @@ export class PlanOrchestrator { static async run(args: PlanArgs, reporter: Reporter): Promise { ctx.processStarted(ProcessName.PLAN); - await LoginHelper.load(); - const initializationResult = await PluginInitOrchestrator.run({ ...args, }, reporter); From ab8c4f7c8babc7aa1c74b077f2ecd2a1ff52efe8 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Thu, 4 Sep 2025 23:52:16 -0400 Subject: [PATCH 04/67] Added new codify file resolution logic. WIP default document endpoint fetch --- src/api/dashboard/index.ts | 19 ++++++ src/common/initialize-plugins.ts | 90 ++++++++++++++++----------- src/parser/index.ts | 1 + src/ui/reporters/default-reporter.tsx | 4 +- 4 files changed, 78 insertions(+), 36 deletions(-) diff --git a/src/api/dashboard/index.ts b/src/api/dashboard/index.ts index a58880e5..5321d93a 100644 --- a/src/api/dashboard/index.ts +++ b/src/api/dashboard/index.ts @@ -21,5 +21,24 @@ export const DashboardApiClient = { } return json; + }, + + async getDefaultDocumentId(): Promise { + const login = LoginHelper.get()?.credentials; + if (!login) { + throw new Error('Not logged in'); + } + + // const res = await fetch( + // `${API_BASE_URL}/api/v1/documents/default/id`, + // { method: 'GET', headers: { 'Content-Type': 'application/json', 'authorization': login.accessToken } }, + // ); + + // const json = await res.json(); + // if (!res.ok) { + // throw new Error(JSON.stringify(json, null, 2)); + // } + + return '1b80818e-5304-4158-80a3-82e17ff2c79e'; } } diff --git a/src/common/initialize-plugins.ts b/src/common/initialize-plugins.ts index 765300f4..b874b183 100644 --- a/src/common/initialize-plugins.ts +++ b/src/common/initialize-plugins.ts @@ -8,6 +8,9 @@ import { CODIFY_FILE_REGEX, CodifyParser } from '../parser/index.js'; import { DependencyMap, PluginManager } from '../plugins/plugin-manager.js'; import { Reporter } from '../ui/reporters/reporter.js'; import { LoginHelper } from '../connect/login-helper.js'; +import { FileUtils } from '../utils/file.js'; +import { validate } from 'uuid'; +import { DashboardApiClient } from '../api/dashboard/index.js'; export interface InitializeArgs { path?: string; @@ -28,11 +31,11 @@ export class PluginInitOrchestrator { args: InitializeArgs, reporter: Reporter, ): Promise { + const codifyPath = await PluginInitOrchestrator.resolveCodifyRootPath(args, reporter); + let project = await PluginInitOrchestrator.parse( - args.path, - args.allowEmptyProject ?? false, - reporter - ) + codifyPath, + ); if (args.transformProject) { project = await args.transformProject(project); } @@ -47,32 +50,9 @@ export class PluginInitOrchestrator { private static async parse( fileOrDir: string | undefined, - allowEmptyProject: boolean, - reporter: Reporter ): Promise { ctx.subprocessStarted(SubProcessName.PARSE); - const isLoggedIn = LoginHelper.get()?.isLoggedIn ?? false; - - if (!fileOrDir && !allowEmptyProject && !isLoggedIn) { - ctx.subprocessFinished(SubProcessName.PARSE); - ctx.subprocessStarted(SubProcessName.CREATE_ROOT_FILE) - const createRootCodifyFile = await reporter.promptConfirmation('\nNo codify file found. Do you want to create a root file at ~/codify.jsonc?'); - - if (createRootCodifyFile) { - await fs.writeFile( - path.resolve(os.homedir(), 'codify.jsonc'), - '[]', - { encoding: 'utf8', flag: 'wx' } - ); // flag: 'wx' prevents overwrites if the file exists - } - - ctx.subprocessFinished(SubProcessName.CREATE_ROOT_FILE) - - console.log('Created ~/codify.jsonc file') - process.exit(0); - } - const project = fileOrDir ? await CodifyParser.parse(fileOrDir) : Project.empty() @@ -82,19 +62,59 @@ export class PluginInitOrchestrator { return project } - private static async findCodifyJson(dir?: string): Promise { - dir = dir ?? process.cwd(); + /** Resolve the root codify file to run. + * Order: + * 1. If path is specified, return that. + * 2. If path is a dir with only one *codify.json|*codify.jsonc|*codify.json5|*codify.yaml, return that. + * 3. If path is a UUID, return file from Codify cloud. + * 4. If multiple exists in the path (dir), then prompt the user to select one. + * 5. If no path is provided, run steps 2 - 4 for the current dir. + * 6. If none exists, return default file from codify cloud. + * 7. If user is not logged in, return an error. + * + * @param args + * @private + */ + private static async resolveCodifyRootPath(args: InitializeArgs, reporter: Reporter): Promise { + const inputPath = args.path ?? process.cwd(); + + // Cloud files will be fetched and processed later in the parser. + const isCloud = validate(inputPath); + if (isCloud) { + return inputPath; + } + + // Direct files can have its path returned. + const isPathDir = await FileUtils.isDir(inputPath); + if (!isPathDir) { + return inputPath; + } + + const filesInDir = await fs.readdir(inputPath); + const codifyFiles = filesInDir.filter((f) => CODIFY_FILE_REGEX.test(f)) + + if (codifyFiles.length === 1) { + return codifyFiles[0]; + } + + if (codifyFiles.length > 0) { + const answer = await reporter.promptOptions( + 'Multiple codify files found in dir. Please select one:', + codifyFiles, + ); + + return path.join(inputPath, codifyFiles[answer]); + } - const filesInDir = await fs.readdir(dir); - if (filesInDir.some((f) => CODIFY_FILE_REGEX.test(f))) { - return dir; + if (LoginHelper.get()?.isLoggedIn) { + return DashboardApiClient.getDefaultDocumentId(); } - if (dir.includes(os.homedir()) && dir !== os.homedir()) { - return this.findCodifyJson(path.dirname(dir)) + if (args.allowEmptyProject) { + return undefined; } - return null; + throw new Error('No codify files found.'); } } diff --git a/src/parser/index.ts b/src/parser/index.ts index 51fce4c5..7f4c2ab3 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -71,6 +71,7 @@ class Parser { return Promise.all(filePaths.map( async (p) => { + // If path is a uuid and doesn't exist as a file, it's a cloud file if (validate(p) && !(await FileUtils.fileExists(p))) { return cloudReader.read(p) } diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index fbfab4ef..2dcc04e4 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -219,6 +219,8 @@ export class DefaultReporter implements Reporter { } async promptOptions(message:string, options:string[]): Promise { + const prevRenderState = this.getRenderState(); + const result = await this.updateStateAndAwaitEvent( () => this.updateRenderState(RenderStatus.PROMPT_OPTIONS, { message, options }), RenderEvent.PROMPT_RESULT @@ -229,7 +231,7 @@ export class DefaultReporter implements Reporter { // This was added because there was a very hard to debug memory bug with Yoga (ink.js layout engine). Could not // identify the root cause of the problem but this alleviates it. await sleep(50) - this.updateRenderState(RenderStatus.NOTHING, null); + this.updateRenderState(prevRenderState.status, prevRenderState.data); await sleep(50); return options.indexOf(result); From 6b7273fe64223f2ea71da83b6c515127d6296085 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Fri, 5 Sep 2025 00:22:48 -0400 Subject: [PATCH 05/67] Added path as an arg to apply and plan --- src/commands/apply.ts | 14 +++++++++++--- src/commands/plan.ts | 13 +++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/commands/apply.ts b/src/commands/apply.ts index 6b9d9348..7b922548 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -1,4 +1,4 @@ -import { Flags } from '@oclif/core' +import { Args, Flags } from '@oclif/core' import chalk from 'chalk'; import { BaseCommand } from '../common/base-command.js'; @@ -27,6 +27,10 @@ For more information, visit: https://docs.codifycli.com/commands/apply }), } + static args = { + pathArgs: Args.string(), + } + static examples = [ '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --path ~', @@ -40,10 +44,14 @@ For more information, visit: https://docs.codifycli.com/commands/apply } public async run(): Promise { - const { flags } = await this.parse(Apply) + const { flags, args } = await this.parse(Apply) + + if (flags.path && args.pathArgs) { + throw new Error('Cannot specify both --path and path argument'); + } await ApplyOrchestrator.run({ - path: flags.path, + path: flags.path ?? args.pathArgs, verbosityLevel: flags.debug ? 3 : 0, // secure: flags.secure, }, this.reporter); diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 3b470c7e..1e3573b2 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -1,6 +1,7 @@ import chalk from 'chalk'; import { BaseCommand } from '../common/base-command.js'; import { PlanOrchestrator } from '../orchestrators/plan.js'; +import { Args } from '@oclif/core'; export default class Plan extends BaseCommand { static description = @@ -22,16 +23,24 @@ For more information, visit: https://docs.codifycli.com/commands/plan` '<%= config.bin %> <%= command.id %> -p ../', ] + static args = { + pathArgs: Args.string(), + } + async init(): Promise { return super.init(); } public async run(): Promise { - const { flags } = await this.parse(Plan) + const { flags, args } = await this.parse(Plan) + + if (flags.path && args.pathArgs) { + throw new Error('Cannot specify both --path and path argument'); + } await PlanOrchestrator.run({ verbosityLevel: flags.debug ? 3 : 0, - path: flags.path, + path: flags.path ?? args.pathArgs, secureMode: flags.secure, }, this.reporter); From 5a8c7ce5c843c122418e5f9bbe6ae01f909125ac Mon Sep 17 00:00:00 2001 From: kevinwang Date: Fri, 5 Sep 2025 00:24:21 -0400 Subject: [PATCH 06/67] Added path as an arg to validate --- src/commands/validate.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/commands/validate.ts b/src/commands/validate.ts index d376cd8a..f4861fcf 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -1,6 +1,8 @@ +import { Args } from '@oclif/core'; import { BaseCommand } from '../common/base-command.js'; import { ValidateOrchestrator } from '../orchestrators/validate.js'; import { CodifyParser } from '../parser/index.js'; +import Apply from './apply.js'; export default class Validate extends BaseCommand { static description = @@ -11,6 +13,10 @@ For more information, visit: https://docs.codifycli.com/commands/validate static flags = {} + static args = { + pathArgs: Args.string(), + } + static examples = [ '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --path=../../import.codify.jsonc', @@ -22,13 +28,17 @@ For more information, visit: https://docs.codifycli.com/commands/validate } public async run(): Promise { - const { flags } = await this.parse(Validate) + const { flags, args } = await this.parse(Apply) + + if (flags.path && args.pathArgs) { + throw new Error('Cannot specify both --path and path argument'); + } await ValidateOrchestrator.run({ - path: flags.path, + path: flags.path ?? args.pathArgs, }, this.reporter) - await CodifyParser.parse(flags.path); + await CodifyParser.parse(flags.path ?? args.pathArgs ?? '.'); process.exit(0); } From 35b0b645c6d88ff86dc0bda0cd3840b72760e4ab Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sat, 6 Sep 2025 10:49:24 -0400 Subject: [PATCH 07/67] Fixed import logic to support cloud files --- .eslintrc.json | 2 + src/api/dashboard/index.ts | 9 ++ src/entities/project.ts | 6 ++ .../file-modification-calculator.ts | 89 ++++++------------- src/generators/index.ts | 16 ++++ src/generators/writer.ts | 16 ++++ src/orchestrators/import.ts | 8 +- src/parser/cloud/cloud-parser.ts | 11 ++- 8 files changed, 87 insertions(+), 70 deletions(-) rename src/{utils => generators}/file-modification-calculator.ts (75%) create mode 100644 src/generators/index.ts create mode 100644 src/generators/writer.ts diff --git a/.eslintrc.json b/.eslintrc.json index e11cb306..44d7c2ba 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -18,6 +18,8 @@ "unicorn/no-array-for-each": "off", "unicorn/prefer-object-from-entries": "off", "unicorn/prefer-type-error": "off", + "unicorn/no-static-only-class": "off", + "@typescript-eslint/no-duplicate-enum-values": "off", "quotes": [ "error", "single" diff --git a/src/api/dashboard/index.ts b/src/api/dashboard/index.ts index 5321d93a..d3626380 100644 --- a/src/api/dashboard/index.ts +++ b/src/api/dashboard/index.ts @@ -40,5 +40,14 @@ export const DashboardApiClient = { // } return '1b80818e-5304-4158-80a3-82e17ff2c79e'; + }, + + async saveDocumentUpdate(id: string, contents: string): Promise { + const login = LoginHelper.get()?.credentials; + if (!login) { + throw new Error('Not logged in'); + } + + } } diff --git a/src/entities/project.ts b/src/entities/project.ts index 94e55799..11ead293 100644 --- a/src/entities/project.ts +++ b/src/entities/project.ts @@ -1,4 +1,5 @@ import { PlanRequestData, ResourceOperation, ValidateResponseData } from 'codify-schemas'; +import { validate } from 'uuid' import { PluginValidationError, PluginValidationErrorParams, TypeNotFoundError } from '../common/errors.js'; import { ctx } from '../events/context.js'; @@ -60,6 +61,11 @@ ${JSON.stringify(projectConfigs, null, 2)}`); return this.stateConfigs !== null && this.stateConfigs !== undefined && this.stateConfigs.length > 0; } + // TODO: Update to a more robust method in the future + isCloud(): boolean { + return validate(this.codifyFiles[0]) + } + filter(ids: string[]): Project { this.resourceConfigs = this.resourceConfigs.filter((r) => ids.find((id) => r.id.includes(id))); this.stateConfigs = this.stateConfigs?.filter((s) => ids.includes(s.id)) ?? null; diff --git a/src/utils/file-modification-calculator.ts b/src/generators/file-modification-calculator.ts similarity index 75% rename from src/utils/file-modification-calculator.ts rename to src/generators/file-modification-calculator.ts index 6972e660..705ade78 100644 --- a/src/utils/file-modification-calculator.ts +++ b/src/generators/file-modification-calculator.ts @@ -1,40 +1,24 @@ -import { ResourceConfig } from '../entities/resource-config.js'; - -import * as jsonSourceMap from 'json-source-map'; +import detectIndent from 'detect-indent'; import jju from 'jju' -import { FileType, InMemoryFile } from '../parser/entities.js'; -import { SourceMap, SourceMapCache } from '../parser/source-maps.js'; -import detectIndent from 'detect-indent'; import { Project } from '../entities/project.js'; import { ProjectConfig } from '../entities/project-config.js'; +import { ResourceConfig } from '../entities/resource-config.js'; +import { FileType, InMemoryFile } from '../parser/entities.js'; +import { SourceMap } from '../parser/source-maps.js'; import { prettyFormatFileDiff } from '../ui/file-diff-pretty-printer.js'; -import { deepEqual } from './index.js'; - -export enum ModificationType { - INSERT_OR_UPDATE, - DELETE -} - -export interface ModifiedResource { - resource: ResourceConfig; - modification: ModificationType -} - -export interface FileModificationResult { - newFile: string; - diff: string; -} +import { deepEqual } from '../utils/index.js'; +import { FileModificationResult, ModificationType, ModifiedResource } from './index.js'; export class FileModificationCalculator { - private existingFile: InMemoryFile; + private readonly existingFile: InMemoryFile; private existingConfigs: ResourceConfig[]; - private sourceMap: SourceMap; + private readonly sourceMap: SourceMap; private totalConfigLength: number; - private indentString: string; + private readonly indentString: string; constructor(existing: Project) { - const { file, sourceMap } = existing.sourceMaps?.getSourceMap(existing.codifyFiles[0])!; + const { file, sourceMap } = existing.sourceMaps!.getSourceMap(existing.codifyFiles[0])!; this.existingFile = file; this.sourceMap = sourceMap; this.existingConfigs = [...existing.resourceConfigs]; @@ -69,12 +53,11 @@ export class FileModificationCalculator { continue; } - const duplicateSourceKey = existing.sourceMapKey?.split('#').at(1)!; - const sourceIndex = Number.parseInt(duplicateSourceKey.split('/').at(1)!) - const isOnly = this.totalConfigLength === 1; + const duplicateSourceKey = existing.sourceMapKey!.split('#').at(1)!; + const sourceIndex = Number.parseInt(duplicateSourceKey.split('/').at(1)!, 10) if (modified.modification === ModificationType.DELETE) { - newFile = this.remove(newFile, this.sourceMap, sourceIndex, isOnly); + newFile = this.remove(newFile, sourceIndex); this.totalConfigLength -= 1; continue; @@ -99,7 +82,7 @@ export class FileModificationCalculator { } return { - newFile: newFile, + newFile, diff: prettyFormatFileDiff(this.existingFile.contents, newFile), } } @@ -110,7 +93,7 @@ export class FileModificationCalculator { return; } - if (this.existingFile?.fileType !== FileType.JSON && this.existingFile?.fileType !== FileType.JSON5 && this.existingFile?.fileType !== FileType.JSONC) { + if (this.existingFile?.fileType !== FileType.JSON && this.existingFile?.fileType !== FileType.JSON5 && this.existingFile?.fileType !== FileType.JSONC && this.existingFile?.fileType !== FileType.CLOUD) { throw new Error(`Only updating .json, .json5, and .jsonc files are currently supported. Found ${this.existingFile?.filePath}`); } @@ -167,9 +150,7 @@ export class FileModificationCalculator { private remove( file: string, - sourceMap: SourceMap, sourceIndex: number, - isOnly: boolean, ): string { const isLast = sourceIndex === this.totalConfigLength - 1; const isFirst = sourceIndex === 0; @@ -200,42 +181,18 @@ export class FileModificationCalculator { ): string { // Updates: for now let's remove and re-add the entire object. Only two formatting availalbe either same line or multi-line const { value, valueEnd } = this.sourceMap.lookup(`/${sourceIndex}`)!; - const isFirst = sourceIndex === 0; const sortedResource = this.sortKeys(resource.raw, existing.raw); - let content = jju.update(file.slice(value.position, valueEnd.position), sortedResource) - return this.splice(file, value?.position!, valueEnd.position - value.position, content); - } - - /** Attempt to make arrays and objects oneliners if they were before. It does this by creating a new source map */ - private updateParamsToOnelineIfNeeded(content: string, sourceMap: SourceMap, sourceIndex: number): string { - // Attempt to make arrays and objects oneliners if they were before. It does this by creating a new source map - const parsedContent = JSON.parse(content); - const parsedPointers = jsonSourceMap.parse(content); - const parsedSourceMap = new SourceMapCache() - parsedSourceMap.addSourceMap({ filePath: '', fileType: FileType.JSON, contents: parsedContent }, parsedPointers); - - for (const [key, value] of Object.entries(parsedContent)) { - const source = sourceMap.lookup(`/${sourceIndex}/${key}`); - if ((Array.isArray(value) || typeof value === 'object') && source && source.value.line === source.valueEnd.line) { - const { value, valueEnd } = parsedSourceMap.lookup(`#/${key}`)! - content = this.splice( - content, - value.position, valueEnd.position - value.position, - JSON.stringify(parsedContent[key]).replaceAll('\n', '').replaceAll(/}$/g, ' }') - ) - } - } - - return content; + const content = jju.update(file.slice(value.position, valueEnd.position), sortedResource) + return this.splice(file, value.position!, valueEnd.position - value.position, content); } private splice(s: string, start: number, deleteCount = 0, insert = '') { - return s.substring(0, start) + insert + s.substring(start + deleteCount); + return s.slice(0, start) + insert + s.slice(start + deleteCount); } private removeSlice(s: string, start: number, end: number) { - return s.substring(0, start) + s.substring(end); + return s.slice(0, start) + s.slice(end); } private isResourceConfig(config: ProjectConfig | ResourceConfig): config is ResourceConfig { @@ -244,7 +201,7 @@ export class FileModificationCalculator { private sortKeys(obj: Record, referenceOrder?: Record): Record { const reference = Object.keys(referenceOrder - ?? Object.fromEntries([...Object.keys(obj)].sort().map((k) => [k, undefined])) + ?? Object.fromEntries(Object.keys(obj).sort().map((k) => [k, undefined])) ); return Object.fromEntries( @@ -262,7 +219,7 @@ export class FileModificationCalculator { ) } - private fileTypeString(fileType: FileType): 'json' | 'json5' | 'cjson' { + private fileTypeString(fileType: FileType): 'cjson' | 'json' | 'json5' { if (fileType === FileType.JSON) { return 'json' } @@ -275,6 +232,10 @@ export class FileModificationCalculator { return 'cjson' } + if (fileType === FileType.CLOUD) { + return 'cjson' + } + throw new Error(`Unsupported file type ${fileType} when trying to generate new configs`); } } diff --git a/src/generators/index.ts b/src/generators/index.ts new file mode 100644 index 00000000..2eff8a16 --- /dev/null +++ b/src/generators/index.ts @@ -0,0 +1,16 @@ +import { ResourceConfig } from '../entities/resource-config.js'; + +export enum ModificationType { + INSERT_OR_UPDATE, + DELETE +} + +export interface ModifiedResource { + resource: ResourceConfig; + modification: ModificationType +} + +export interface FileModificationResult { + newFile: string; + diff: string; +} diff --git a/src/generators/writer.ts b/src/generators/writer.ts new file mode 100644 index 00000000..6b3fdb8d --- /dev/null +++ b/src/generators/writer.ts @@ -0,0 +1,16 @@ +import { validate } from 'uuid' + +import { DashboardApiClient } from '../api/dashboard/index.js'; +import { FileUtils } from '../utils/file.js'; + +export class FileUpdater { + static async write(filePath: string, content: string) { + // Cloud file + if (validate(filePath)) { + return DashboardApiClient.saveDocumentUpdate(filePath, content); + } + + return FileUtils.writeFile(filePath, content); + } + +} diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index 981d285c..9936c125 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -5,12 +5,14 @@ import { Project } from '../entities/project.js'; import { ResourceConfig } from '../entities/resource-config.js'; import { ResourceInfo } from '../entities/resource-info.js'; import { ProcessName, SubProcessName, ctx } from '../events/context.js'; +import { FileModificationCalculator } from '../generators/file-modification-calculator.js'; +import { ModificationType } from '../generators/index.js'; +import { FileUpdater } from '../generators/writer.js'; import { CodifyParser } from '../parser/index.js'; import { DependencyMap, PluginManager } from '../plugins/plugin-manager.js'; import { prettyFormatFileDiff } from '../ui/file-diff-pretty-printer.js'; import { PromptType, Reporter } from '../ui/reporters/reporter.js'; import { FileUtils } from '../utils/file.js'; -import { FileModificationCalculator, ModificationType } from '../utils/file-modification-calculator.js'; import { groupBy, sleep } from '../utils/index.js'; import { wildCardMatch } from '../utils/wild-card-match.js'; @@ -308,7 +310,7 @@ ${JSON.stringify(unsupportedTypeIds)}`); } for (const diff of diffs) { - await FileUtils.writeFile(diff.file, diff.modification.newFile); + await FileUpdater.write(diff.file, diff.modification.newFile); } reporter.displayMessage('\nšŸŽ‰ Imported completed and saved to file šŸŽ‰'); @@ -332,7 +334,7 @@ ${JSON.stringify(unsupportedTypeIds)}`); return; } - await FileUtils.writeFile(filePath, newFile); + await FileUpdater.write(filePath, newFile); reporter.displayMessage('\nšŸŽ‰ Imported completed and saved to file šŸŽ‰'); diff --git a/src/parser/cloud/cloud-parser.ts b/src/parser/cloud/cloud-parser.ts index 3230879c..f9b95e93 100644 --- a/src/parser/cloud/cloud-parser.ts +++ b/src/parser/cloud/cloud-parser.ts @@ -1,4 +1,5 @@ import { Config } from 'codify-schemas'; +import jsonSourceMap from 'json-source-map'; import { InMemoryFile, LanguageSpecificParser, ParsedConfig } from '../entities.js'; import { SourceMapCache } from '../source-maps.js'; @@ -7,11 +8,15 @@ export class CloudParser implements LanguageSpecificParser { parse(file: InMemoryFile, sourceMaps: SourceMapCache): ParsedConfig[] { const contents = JSON.parse(file.contents) as Array; - return contents.map((content) => { - const { id, type, ...config } = content; + if (sourceMaps) { + sourceMaps.addSourceMap(file, jsonSourceMap.parse(file.contents)); + } + + return contents.map((content, idx) => { + const { type, ...config } = content; return { contents: { type, ...config }, - sourceMapKey: id as string, + sourceMapKey: SourceMapCache.constructKey(file.filePath, `/${idx}`), } }) } From f2c1bf8afd10a65dc38c2607a41c25c113285ac1 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Fri, 12 Sep 2025 17:33:59 -0400 Subject: [PATCH 08/67] Fixed login bugs. - Resolve promise when logged in - Fixed login manager bug Added useful log message for connect --- src/commands/edit.ts | 23 ++++++++++++++++++++ src/connect/login-helper.ts | 3 +-- src/orchestrators/connect.ts | 5 ++++- src/orchestrators/login.ts | 41 ++++++++++++++++++++---------------- 4 files changed, 51 insertions(+), 21 deletions(-) create mode 100644 src/commands/edit.ts diff --git a/src/commands/edit.ts b/src/commands/edit.ts new file mode 100644 index 00000000..90fde6cf --- /dev/null +++ b/src/commands/edit.ts @@ -0,0 +1,23 @@ +import { BaseCommand } from '../common/base-command.js'; +import { ConnectOrchestrator } from '../orchestrators/connect.js'; + +export default class Edit extends BaseCommand { + static description = + `Edit a codify.jsonc/codify.json/codify.yaml file. + +For more information, visit: https://docs.codifycli.com/commands/validate +` + + static flags = {} + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --path=../../import.codify.jsonc', + ] + + public async run(): Promise { + const { flags } = await this.parse(Edit) + + await ConnectOrchestrator.run(); + } +} diff --git a/src/connect/login-helper.ts b/src/connect/login-helper.ts index ef337b81..e697fa39 100644 --- a/src/connect/login-helper.ts +++ b/src/connect/login-helper.ts @@ -49,9 +49,8 @@ export class LoginHelper { private static async read(): Promise { const credentialsPath = path.join(os.homedir(), '.codify', 'credentials.json'); - const credentialsStr = await fs.readFile(credentialsPath, 'utf8'); - try { + const credentialsStr = await fs.readFile(credentialsPath, 'utf8'); return JSON.parse(credentialsStr); } catch { return undefined; diff --git a/src/orchestrators/connect.ts b/src/orchestrators/connect.ts index 526e0f02..15b07c99 100644 --- a/src/orchestrators/connect.ts +++ b/src/orchestrators/connect.ts @@ -2,7 +2,6 @@ import cors from 'cors'; import express, { json } from 'express'; import { randomBytes } from 'node:crypto'; import open from 'open'; -import { WebSocket } from 'ws'; import { config } from '../config.js'; import HttpRouteHandler from '../connect/http-route-handler.js'; @@ -20,6 +19,10 @@ export class ConnectOrchestrator { const server = app.listen(config.connectServerPort, () => { open(`http://localhost:3000/connection/success?code=${connectionSecret}`) + console.log(`Open browser window to store code. + +If unsuccessful manually enter the code: +${connectionSecret}`) }); const wsManager = WsServerManager.init(server, connectionSecret) diff --git a/src/orchestrators/login.ts b/src/orchestrators/login.ts index 9139e22c..a70e7d79 100644 --- a/src/orchestrators/login.ts +++ b/src/orchestrators/login.ts @@ -1,13 +1,10 @@ import cors from 'cors'; import express, { json } from 'express'; -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import path from 'node:path'; import open from 'open'; import { config } from '../config.js'; -import { ajv } from '../utils/ajv.js'; import { LoginHelper } from '../connect/login-helper.js'; +import { ajv } from '../utils/ajv.js'; const schema = { type: 'object', @@ -22,7 +19,7 @@ const schema = { type: 'string', }, expiry: { - type: 'string', + type: 'number', } }, additionalProperties: false, @@ -37,25 +34,33 @@ interface Credentials { } export class LoginOrchestrator { - static async run(){ + static async run() { const app = express(); app.use(cors({ origin: config.corsAllowedOrigins })) app.use(json()) - app.post('/', async (req, res) => { - const body = req.body as Credentials; - if (!ajv.validate(schema, body)) { - return res.status(400).send({ message: ajv.errorsText() }) - } + const [, server] = await Promise.all([ + new Promise((resolve) => { + app.post('/', async (req, res) => { + const body = req.body as Credentials; + + if (!ajv.validate(schema, body)) { + return res.status(400).send({ message: ajv.errorsText() }) + } + + await LoginHelper.save(body); + res.sendStatus(200); - await LoginHelper.save(body); - return res.sendStatus(200); - }); + resolve(); + }); + }), + app.listen(config.loginServerPort, () => { + console.log('Opening CLI auth page...') + open('http://localhost:3000/auth/cli'); + }) + ]) - app.listen(config.loginServerPort, () => { - console.log('Opening CLI auth page...') - open('http://localhost:3000/auth/cli'); - }) + server.close(() => {}); } } From b41c9e3d0d5956865e5b0a29457f07c8f343b8df Mon Sep 17 00:00:00 2001 From: kevinwang Date: Fri, 12 Sep 2025 18:26:30 -0400 Subject: [PATCH 09/67] Fixed codify apply via connect to supply tmpFile path. Added helpful log messages. Fixed header name error for websocket validation. Reduced size of token --- src/connect/apply.ts | 4 ++-- src/connect/server.ts | 15 ++++++++++----- src/orchestrators/connect.ts | 4 ++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/connect/apply.ts b/src/connect/apply.ts index 548741d5..82e08cb5 100644 --- a/src/connect/apply.ts +++ b/src/connect/apply.ts @@ -17,11 +17,11 @@ export function connectApplyInitHandler(msg: any, initWs: WebSocket, manager: Ws console.log('apply ws open', config); const tmpDir = await fs.mkdtemp(os.tmpdir()); - const filePath = path.join(tmpDir, 'codify.json'); + const filePath = path.join(tmpDir, 'codify.jsonc'); await fs.writeFile(filePath, JSON.stringify(config)); - const pty = spawn('zsh', ['-c', 'codify apply'], { + const pty = spawn('zsh', ['-c', `codify apply ${tmpDir}`], { name: 'xterm-color', cols: 80, rows: 30, diff --git a/src/connect/server.ts b/src/connect/server.ts index 5a2821cb..16ef81e2 100644 --- a/src/connect/server.ts +++ b/src/connect/server.ts @@ -53,8 +53,7 @@ export class WsServerManager { private onUpgrade = (request: IncomingMessage, socket: Duplex, head: Buffer): void => { const { pathname } = new URL(request.url!, 'ws://localhost:51040') - if (!this.validateOrigin(request.headers.origin!) - || this.validateConnectionSecret(request)) { + if (!this.validateOrigin(request.headers.origin ?? '') || !this.validateConnectionSecret(request)) { console.error('Unauthorized request from', request.headers.origin); socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') socket.destroy(); @@ -63,7 +62,10 @@ export class WsServerManager { if (pathname === '/ws' && this.handlerMap.has('default')) { const wss = this.wsServerMap.get('default'); - wss?.handleUpgrade(request, socket, head, (ws, request) => this.handlerMap.get('default')!(ws, this, request)); + wss?.handleUpgrade(request, socket, head, (ws, request) => { + console.log('New client connected!') + this.handlerMap.get('default')!(ws, this, request) + }); return; } @@ -79,7 +81,10 @@ export class WsServerManager { const wss = this.wsServerMap.get(sessionId)!; - wss.handleUpgrade(request, socket, head, (ws, request) => this.handlerMap.get(sessionId)!(ws, this, request)); + wss.handleUpgrade(request, socket, head, (ws, request) => { + console.log('New ws session!') + this.handlerMap.get(sessionId)!(ws, this, request) + }); } } @@ -87,7 +92,7 @@ export class WsServerManager { config.corsAllowedOrigins.includes(origin) private validateConnectionSecret = (request: IncomingMessage): boolean => { - const connectionSecret = request.headers['connection-secret'] as string; + const connectionSecret = request.headers['sec-websocket-protocol'] as string; return connectionSecret === this.connectionSecret; } diff --git a/src/orchestrators/connect.ts b/src/orchestrators/connect.ts index 15b07c99..267af015 100644 --- a/src/orchestrators/connect.ts +++ b/src/orchestrators/connect.ts @@ -29,7 +29,7 @@ ${connectionSecret}`) .setDefaultHandler(defaultWsHandler) } - private static tokenGenerate(length = 20): string { - return Buffer.from(randomBytes(length)).toString('hex') + private static tokenGenerate(bytes = 4): string { + return Buffer.from(randomBytes(bytes)).toString('hex') } } From 51680e372cba0e105121dff7f640f2045e81d241 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sun, 14 Sep 2025 23:12:58 -0400 Subject: [PATCH 10/67] Switched to socketIO --- README.md | 2 +- esbuild.ts | 2 +- src/commands/apply.ts | 2 +- src/commands/destroy.ts | 2 +- src/config.ts | 2 +- src/connect/apply.ts | 51 --------- src/connect/http-route-handler.ts | 54 --------- .../http-routes/handlers/create-command.ts | 52 +++++++++ src/connect/http-routes/handlers/index.ts | 20 ++++ src/connect/http-routes/router.ts | 13 +++ src/connect/login-helper.ts | 6 +- src/connect/server.ts | 104 ------------------ src/connect/socket-server.ts | 69 ++++++++++++ src/connect/ws-route-handler.ts | 35 ------ src/orchestrators/connect.ts | 13 ++- src/parser/yaml/source-map.ts | 4 +- src/parser/yaml/yaml-parser.test.ts | 2 +- test/orchestrator/apply/apply.test.ts | 4 +- 18 files changed, 174 insertions(+), 263 deletions(-) delete mode 100644 src/connect/apply.ts delete mode 100644 src/connect/http-route-handler.ts create mode 100644 src/connect/http-routes/handlers/create-command.ts create mode 100644 src/connect/http-routes/handlers/index.ts create mode 100644 src/connect/http-routes/router.ts delete mode 100644 src/connect/server.ts create mode 100644 src/connect/socket-server.ts delete mode 100644 src/connect/ws-route-handler.ts diff --git a/README.md b/README.md index 7b245d41..9230428c 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ USAGE ``` # Commands - + * [`codify apply`](#codify-apply) * [`codify destroy`](#codify-destroy) * [`codify help [COMMAND]`](#codify-help-command) diff --git a/esbuild.ts b/esbuild.ts index 9fe6dd1b..ed77cd8b 100644 --- a/esbuild.ts +++ b/esbuild.ts @@ -1,7 +1,7 @@ import { build } from 'esbuild'; const result = await build({ - entryPoints: ['src/commands/**/index.ts', 'src/commands/*.ts', 'src/index.ts'], + entryPoints: ['src/handlers/**/router.ts', 'src/handlers/*.ts', 'src/router.ts'], bundle: true, minify: true, splitting: true, diff --git a/src/commands/apply.ts b/src/commands/apply.ts index 7b922548..e4f25c25 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -22,7 +22,7 @@ For more information, visit: https://docs.codifycli.com/commands/apply static flags = { 'sudoPassword': Flags.string({ optional: true, - description: 'Automatically use this password for any commands that require elevated permissions.', + description: 'Automatically use this password for any handlers that require elevated permissions.', char: 'S' }), } diff --git a/src/commands/destroy.ts b/src/commands/destroy.ts index 15fa52c8..45528b62 100644 --- a/src/commands/destroy.ts +++ b/src/commands/destroy.ts @@ -29,7 +29,7 @@ For more information, visit: https://docs.codifycli.com/commands/destory` static flags = { 'sudoPassword': Flags.string({ optional: true, - description: 'Automatically use this password for any commands that require elevated permissions.', + description: 'Automatically use this password for any handlers that require elevated permissions.', char: 'S', helpValue: '' }), diff --git a/src/config.ts b/src/config.ts index 715f0c3b..f5ede5a9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,7 @@ export const config = { connectServerPort: 51_040, corsAllowedOrigins: [ - 'https://codify-dashboard.com', + 'https://dashboard.codifycli.com', 'https://https://codify-dashboard.kevinwang5658.workers.dev', 'http://localhost:3000' ] diff --git a/src/connect/apply.ts b/src/connect/apply.ts deleted file mode 100644 index 82e08cb5..00000000 --- a/src/connect/apply.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { v4 as uuid } from 'uuid'; -import { WebSocket } from 'ws'; - -import { WsServerManager } from './server.js'; - -export function connectApplyInitHandler(msg: any, initWs: WebSocket, manager: WsServerManager) { - const sessionId = uuid(); - - manager.addAdhocWsServer(sessionId, async (ws) => { - console.log('connected apply ws'); - - const { config } = msg; - console.log('apply ws open', config); - - const tmpDir = await fs.mkdtemp(os.tmpdir()); - const filePath = path.join(tmpDir, 'codify.jsonc'); - - await fs.writeFile(filePath, JSON.stringify(config)); - - const pty = spawn('zsh', ['-c', `codify apply ${tmpDir}`], { - name: 'xterm-color', - cols: 80, - rows: 30, - cwd: process.env.HOME, - env: process.env - }); - - pty.onData((data) => { - ws.send(Buffer.from(data, 'utf8')); - }); - - ws.on('message', (message) => { - pty.write(message.toString('utf8')); - }) - - pty.onExit(({ exitCode, signal }) => { - console.log('pty exit', exitCode, signal); - // ws.close(exitCode); - ws.terminate(); - }) - }); - - initWs.send(JSON.stringify({ - cmd: 'apply_init_response', - sessionId, - })) -} diff --git a/src/connect/http-route-handler.ts b/src/connect/http-route-handler.ts deleted file mode 100644 index 0e42e02a..00000000 --- a/src/connect/http-route-handler.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; -import { ResourceConfig } from 'codify-schemas'; -import { Router } from 'express'; -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { v4 as uuid } from 'uuid'; -import { WebSocket } from 'ws'; - -import { WsServerManager } from './server.js'; - -const router = Router({ - mergeParams: true, -}); - -router.post('/apply', (req, res) => { - const sessionId = uuid(); - const manager = WsServerManager.get(); - const config = req.body; - - manager.addAdhocWsServer(sessionId, async (ws: WebSocket) => wsHandler(ws, config)); - - return res.status(200).json({ sessionId }); -}); - -async function wsHandler(ws: WebSocket, config: ResourceConfig): Promise { - const tmpDir = await fs.mkdtemp(os.tmpdir()); - const filePath = path.join(tmpDir, 'codify.json'); - - await fs.writeFile(filePath, JSON.stringify(config)); - - const pty = spawn('zsh', ['-c', 'codify apply'], { - name: 'xterm-color', - cols: 80, - rows: 30, - cwd: process.env.HOME, - env: process.env - }); - - pty.onData((data) => { - ws.send(Buffer.from(data, 'utf8')); - }); - - ws.on('message', (message) => { - pty.write(message.toString('utf8')); - }) - - pty.onExit(({ exitCode, signal }) => { - console.log('pty exit', exitCode, signal); - ws.terminate(); - }) -} - -export default router; diff --git a/src/connect/http-routes/handlers/create-command.ts b/src/connect/http-routes/handlers/create-command.ts new file mode 100644 index 00000000..a24875d8 --- /dev/null +++ b/src/connect/http-routes/handlers/create-command.ts @@ -0,0 +1,52 @@ +import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; +import { Router } from 'express'; + +import { SocketServer } from '../../socket-server.js'; + +export function createCommandHandler(command: string, args?: string): Router { + const router = Router({ + mergeParams: true, + }); + + router.post('/:sessionId/start', async (req, res) => { + const { sessionId } = req.params; + console.log(`Received request to ${command}, sessionId: ${sessionId}`) + + if (!sessionId) { + return res.status(400).json({ error: 'SessionId must be provided' }); + } + + const server = SocketServer.get(); + const socket = server.getSession(sessionId); + if (!socket) { + return res.status(400).json({ error: 'SessionId does not exist' }); + } + + if (!socket.connected) { + return res.status(400).json({ error: 'Socket not connected. Connect to socket before calling this endpoint' }); + } + + const pty = spawn('zsh', ['-c', `codify ${command} ${args ?? ''}`], { + name: 'xterm-color', + cols: 80, + rows: 30, + cwd: process.env.HOME, + env: process.env + }); + + pty.onData((data) => { + socket.emit('data', Buffer.from(data, 'utf8')); + }); + + socket.on('data', (message) => { + pty.write(message.toString('utf8')); + }) + + pty.onExit(({ exitCode, signal }) => { + console.log('pty exit', exitCode, signal); + // socket.disconnect(); + }) + }); + + return router; +} \ No newline at end of file diff --git a/src/connect/http-routes/handlers/index.ts b/src/connect/http-routes/handlers/index.ts new file mode 100644 index 00000000..2a55b1a7 --- /dev/null +++ b/src/connect/http-routes/handlers/index.ts @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import { v4 as uuid } from 'uuid'; + +import { SocketServer } from '../../socket-server.js'; + +const router = Router({ + mergeParams: true, +}); + +router.post('/session', (req, res) => { + const sessionId = uuid(); + const socketServer = SocketServer.get(); + + socketServer.addSession(sessionId); + console.log('Terminal session created!', sessionId) + + return res.status(200).json({ sessionId }); +}) + +export default router; \ No newline at end of file diff --git a/src/connect/http-routes/router.ts b/src/connect/http-routes/router.ts new file mode 100644 index 00000000..31cc67c2 --- /dev/null +++ b/src/connect/http-routes/router.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; + +import { createCommandHandler } from './handlers/create-command.js'; +import defaultHandler from './handlers/index.js'; + +const router = Router(); + +router.use('/', defaultHandler); +router.use('/apply', createCommandHandler('apply')); +router.use('/plan', createCommandHandler('plan')); +router.use('/import', createCommandHandler('import')); + +export default router; \ No newline at end of file diff --git a/src/connect/login-helper.ts b/src/connect/login-helper.ts index e697fa39..bc1be046 100644 --- a/src/connect/login-helper.ts +++ b/src/connect/login-helper.ts @@ -1,6 +1,6 @@ -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; interface Credentials { accessToken: string; diff --git a/src/connect/server.ts b/src/connect/server.ts deleted file mode 100644 index 16ef81e2..00000000 --- a/src/connect/server.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { IncomingMessage, Server, createServer } from 'node:http'; -import { Duplex } from 'node:stream'; -import { v4 as uuid } from 'uuid'; -import { WebSocket, WebSocketServer } from 'ws'; -import { config } from '../config.js'; - -let instance: WsServerManager | undefined; - -export class WsServerManager { - - server: Server; - port?: number; - - private wsServerMap = new Map(); - private handlerMap = new Map void>(); - - private connectionSecret; - - static init(server: Server, connectionSecret?: string): WsServerManager { - instance = new WsServerManager(server, connectionSecret); - return instance; - } - - static get(): WsServerManager { - if (!instance) { - throw new Error('You must call WsServerManager.init before using it'); - } - - return instance; - } - - private constructor(server: Server, connectionSecret?: string) { - this.server = server - this.connectionSecret = connectionSecret; - this.wsServerMap.set('default', this.createWssServer()); - - this.server.on('upgrade', this.onUpgrade) - } - - setDefaultHandler(handler: (ws: WebSocket, manager: WsServerManager) => void): WsServerManager { - const wss = this.createWssServer(); - this.wsServerMap.set('default', wss); - this.handlerMap.set('default', handler); - - return this; - } - - addAdhocWsServer(sessionId: string, handler: (ws: WebSocket, manager: WsServerManager) => void) { - this.wsServerMap.set(sessionId, this.createWssServer()); - this.handlerMap.set(sessionId, handler); - } - - private onUpgrade = (request: IncomingMessage, socket: Duplex, head: Buffer): void => { - const { pathname } = new URL(request.url!, 'ws://localhost:51040') - - if (!this.validateOrigin(request.headers.origin ?? '') || !this.validateConnectionSecret(request)) { - console.error('Unauthorized request from', request.headers.origin); - socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') - socket.destroy(); - return; - } - - if (pathname === '/ws' && this.handlerMap.has('default')) { - const wss = this.wsServerMap.get('default'); - wss?.handleUpgrade(request, socket, head, (ws, request) => { - console.log('New client connected!') - this.handlerMap.get('default')!(ws, this, request) - }); - return; - } - - const pathSections = pathname.split('/').filter(Boolean); - if ( - pathSections[0] === 'ws' - && pathSections[1] === 'session' - && pathSections[2] - && this.handlerMap.has(pathSections[2]) - ) { - const sessionId = pathSections[2]; - console.log('session found, upgrading', sessionId); - - const wss = this.wsServerMap.get(sessionId)!; - - wss.handleUpgrade(request, socket, head, (ws, request) => { - console.log('New ws session!') - this.handlerMap.get(sessionId)!(ws, this, request) - }); - } - } - - private validateOrigin = (origin: string): boolean => - config.corsAllowedOrigins.includes(origin) - - private validateConnectionSecret = (request: IncomingMessage): boolean => { - const connectionSecret = request.headers['sec-websocket-protocol'] as string; - return connectionSecret === this.connectionSecret; - } - - private createWssServer(): WebSocketServer { - return new WebSocketServer({ - noServer: true, - }) - } -} diff --git a/src/connect/socket-server.ts b/src/connect/socket-server.ts new file mode 100644 index 00000000..7d981115 --- /dev/null +++ b/src/connect/socket-server.ts @@ -0,0 +1,69 @@ +import { Server as HttpServer } from 'node:http'; +import { Server, Socket } from 'socket.io'; +import {config} from "../config.js"; + +let instance: SocketServer | undefined; + +export class SocketServer { + + private server: HttpServer; + private connectionSecret: string; + private io: Server + + private handlers: Array<(io: Server, socket: Socket) => void> = []; + + static init(server: HttpServer, connectionSecret: string): SocketServer { + instance = new SocketServer(server, connectionSecret); + return instance; + } + + static get(): SocketServer { + if (!instance) { + throw new Error('You must call WsServerManager.init before using it'); + } + + return instance; + } + + private constructor(server: HttpServer, connectionSecret: string) { + this.server = server; + this.connectionSecret = connectionSecret; + this.io = new Server(server, { + cors: { + origin: config.corsAllowedOrigins + } + }); +a this.io.on('connection', (socket) => { + // Only allow clients with secret to connect + if (socket.handshake.auth.token !== connectionSecret) { + console.log(`Invalid auth on connection`) + socket.disconnect(); + } + + this.handlers.forEach(handler => handler(this.io, socket)); + }); + } + + // These are connection handlers on the default 'ws://url.com/' + registerHandler(handler: (io: Server, socket: Socket) => void): void { + this.handlers.push(handler); + } + + addSession(id: string, handler?: (io: Server, socket: Socket) => void): void { + this.io.of(`/ws/session/${id}`).on('connection', (socket) => { + console.log(`Session ${id} connected!!`); + handler?.(this.io, socket); + }) + } + + // Under normal use, there should only be 1 socket (1 connection) per namespace. + getSession(id: string): Socket | undefined { + const sockets = [...this.io.of(`/ws/session/${id}`).sockets.values()]; + if (sockets.length === 0) { + return undefined; + } + + return sockets[0]; + } + +} \ No newline at end of file diff --git a/src/connect/ws-route-handler.ts b/src/connect/ws-route-handler.ts deleted file mode 100644 index c3d64ab7..00000000 --- a/src/connect/ws-route-handler.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { WebSocket } from 'ws'; - -import { connectApplyInitHandler } from './apply.js'; -import { WsServerManager } from './server.js'; - -export async function defaultWsHandler(ws: WebSocket, manager: WsServerManager) { - console.log('[WS] Connection opened'); - - ws.on('message', (message) => { - let msg; - try { - msg = JSON.parse(message.toString('utf8')); - console.log(msg); - } catch (error) { - console.error(error); - return; - } - - const { cmd } = msg; - if (!cmd) { - console.error('No cmd found'); - return; - } - - switch (cmd) { - case 'apply_init': { - connectApplyInitHandler(msg, ws, manager); - break; - } - } - - }) -} - - diff --git a/src/orchestrators/connect.ts b/src/orchestrators/connect.ts index 267af015..52a847b8 100644 --- a/src/orchestrators/connect.ts +++ b/src/orchestrators/connect.ts @@ -4,9 +4,8 @@ import { randomBytes } from 'node:crypto'; import open from 'open'; import { config } from '../config.js'; -import HttpRouteHandler from '../connect/http-route-handler.js'; -import { WsServerManager } from '../connect/server.js'; -import { defaultWsHandler } from '../connect/ws-route-handler.js'; +import router from '../connect/http-routes/router.js'; +import { SocketServer } from '../connect/socket-server.js'; export class ConnectOrchestrator { static async run() { @@ -15,7 +14,7 @@ export class ConnectOrchestrator { app.use(cors({ origin: config.corsAllowedOrigins })) app.use(json()) - app.use(HttpRouteHandler); + app.use(router); const server = app.listen(config.connectServerPort, () => { open(`http://localhost:3000/connection/success?code=${connectionSecret}`) @@ -25,8 +24,10 @@ If unsuccessful manually enter the code: ${connectionSecret}`) }); - const wsManager = WsServerManager.init(server, connectionSecret) - .setDefaultHandler(defaultWsHandler) + // const wsManager = WsServerManager.init(server, connectionSecret) + // .setDefault(defaultWsHandler) + + SocketServer.init(server, connectionSecret); } private static tokenGenerate(bytes = 4): string { diff --git a/src/parser/yaml/source-map.ts b/src/parser/yaml/source-map.ts index d98601e8..31ef745c 100644 --- a/src/parser/yaml/source-map.ts +++ b/src/parser/yaml/source-map.ts @@ -10,7 +10,7 @@ export class YamlSourceMapAdapter implements SourceMap { * (empty line) * - type: project * plugins: - * default: "../homebrew-plugin/src/index.ts" + * default: "../homebrew-plugin/src/router.ts" * - type: nvm * global: '18.20' * nodeVersions: @@ -111,7 +111,7 @@ interface YamlBTreeNode { * Example yaml: * - type: project * plugins: - * default: "../homebrew-plugin/src/index.ts" + * default: "../homebrew-plugin/src/router.ts" * - type: nvm * global: '18.20' * nodeVersions: diff --git a/src/parser/yaml/yaml-parser.test.ts b/src/parser/yaml/yaml-parser.test.ts index db51fd61..7c473946 100644 --- a/src/parser/yaml/yaml-parser.test.ts +++ b/src/parser/yaml/yaml-parser.test.ts @@ -32,7 +32,7 @@ describe('YamlParser tests', () => { expect(result.length).to.eq(4); expect(result[0]).toMatchObject({ - contents: { type: 'project', plugins: { default: '../homebrew-plugin/src/index.ts' }}, + contents: { type: 'project', plugins: { default: '../homebrew-plugin/src/router.ts' }}, }) }) diff --git a/test/orchestrator/apply/apply.test.ts b/test/orchestrator/apply/apply.test.ts index 036da888..83e1d812 100644 --- a/test/orchestrator/apply/apply.test.ts +++ b/test/orchestrator/apply/apply.test.ts @@ -45,7 +45,7 @@ describe('Apply orchestrator tests', () => { expect(applyCompleteSpy).toHaveBeenCalledOnce(); // This is two because the system by default has xcode-tools installed - // Codify is designed to always install xcode-tools regardless since a lot of the commands depends on it. + // Codify is designed to always install xcode-tools regardless since a lot of the handlers depends on it. expect(MockOs.getAll().size).to.eq(2); // These values are form the create.codify.json file. Check that they were applied to the system @@ -82,7 +82,7 @@ describe('Apply orchestrator tests', () => { expect(applyCompleteSpy).toHaveBeenCalledOnce(); // This is two because the system by default has xcode-tools installed - // Codify is designed to always install xcode-tools regardless since a lot of the commands depends on it. + // Codify is designed to always install xcode-tools regardless since a lot of the handlers depends on it. expect(MockOs.getAll().size).to.eq(1); // These values are form the codify.json file. Check that they were applied to the system From d78276156f52f3421f527df2e64d0820c507fb2d Mon Sep 17 00:00:00 2001 From: kevinwang Date: Mon, 15 Sep 2025 17:51:03 -0400 Subject: [PATCH 11/67] Switched back to websockets instead of socket io --- .../http-routes/handlers/create-command.ts | 20 +-- src/connect/socket-server.ts | 122 +++++++++++++----- 2 files changed, 100 insertions(+), 42 deletions(-) diff --git a/src/connect/http-routes/handlers/create-command.ts b/src/connect/http-routes/handlers/create-command.ts index a24875d8..c412db7f 100644 --- a/src/connect/http-routes/handlers/create-command.ts +++ b/src/connect/http-routes/handlers/create-command.ts @@ -16,17 +16,18 @@ export function createCommandHandler(command: string, args?: string): Router { return res.status(400).json({ error: 'SessionId must be provided' }); } - const server = SocketServer.get(); - const socket = server.getSession(sessionId); - if (!socket) { + const manager = SocketServer.get(); + const session = manager.getSession(sessionId); + if (!session) { return res.status(400).json({ error: 'SessionId does not exist' }); } - if (!socket.connected) { - return res.status(400).json({ error: 'Socket not connected. Connect to socket before calling this endpoint' }); + const {ws, server} = session; + if (!ws) { + return res.status(400).json({ error: 'SessionId not open' }); } - const pty = spawn('zsh', ['-c', `codify ${command} ${args ?? ''}`], { + const pty = spawn('zsh', [], { name: 'xterm-color', cols: 80, rows: 30, @@ -35,16 +36,17 @@ export function createCommandHandler(command: string, args?: string): Router { }); pty.onData((data) => { - socket.emit('data', Buffer.from(data, 'utf8')); + ws.send(Buffer.from(data, 'utf8')); }); - socket.on('data', (message) => { + ws.on('message', (message) => { pty.write(message.toString('utf8')); }) pty.onExit(({ exitCode, signal }) => { console.log('pty exit', exitCode, signal); - // socket.disconnect(); + ws.terminate(); + server.close(); }) }); diff --git a/src/connect/socket-server.ts b/src/connect/socket-server.ts index 7d981115..19e5b82e 100644 --- a/src/connect/socket-server.ts +++ b/src/connect/socket-server.ts @@ -1,16 +1,26 @@ -import { Server as HttpServer } from 'node:http'; -import { Server, Socket } from 'socket.io'; -import {config} from "../config.js"; +import { Server as HttpServer, IncomingMessage } from 'node:http'; +import { Duplex } from 'node:stream'; +import WebSocket, { WebSocketServer } from 'ws'; + +import { config } from '../config.js'; + +export interface Session { + server: WebSocketServer; + ws?: WebSocket; +} let instance: SocketServer | undefined; +/** + * Main socket server. Experimented with SocketIO but it does not work!!. xterm.js does not natively support + * websckets and the arraybuffer is mangled when trying my own implementaiton. SocketIO also does not play nice + * when used side by side with ws. + */ export class SocketServer { private server: HttpServer; private connectionSecret: string; - private io: Server - - private handlers: Array<(io: Server, socket: Socket) => void> = []; + private sessions = new Map() static init(server: HttpServer, connectionSecret: string): SocketServer { instance = new SocketServer(server, connectionSecret); @@ -28,42 +38,88 @@ export class SocketServer { private constructor(server: HttpServer, connectionSecret: string) { this.server = server; this.connectionSecret = connectionSecret; - this.io = new Server(server, { - cors: { - origin: config.corsAllowedOrigins - } - }); -a this.io.on('connection', (socket) => { - // Only allow clients with secret to connect - if (socket.handshake.auth.token !== connectionSecret) { - console.log(`Invalid auth on connection`) - socket.disconnect(); - } - this.handlers.forEach(handler => handler(this.io, socket)); - }); + this.server.on('upgrade', this.onUpgrade); } - // These are connection handlers on the default 'ws://url.com/' - registerHandler(handler: (io: Server, socket: Socket) => void): void { - this.handlers.push(handler); - } + addSession(id: string): void { + // this.io.of(`/ws/session/${id}`).on('connection', (socket) => { + // console.log(`Session ${id} connected!!`); + // handler?.(this.io, socket); + // }) - addSession(id: string, handler?: (io: Server, socket: Socket) => void): void { - this.io.of(`/ws/session/${id}`).on('connection', (socket) => { - console.log(`Session ${id} connected!!`); - handler?.(this.io, socket); - }) + this.sessions.set( + id, + { server: this.createWssServer() } + ) } // Under normal use, there should only be 1 socket (1 connection) per namespace. - getSession(id: string): Socket | undefined { - const sockets = [...this.io.of(`/ws/session/${id}`).sockets.values()]; - if (sockets.length === 0) { - return undefined; + getSession(id: string): Session | undefined { + return this.sessions.get(id); + } + + private onUpgrade = (request: IncomingMessage, socket: Duplex, head: Buffer): void => { + const { pathname } = new URL(request.url!, 'ws://localhost:51040') + + // Ignore all socket io so it does not interfere + if (pathname.includes('socket.io')) { + return; } - return sockets[0]; + if (/*!this.validateOrigin(request.headers.origin ?? request.headers.referer ?? '') ||*/ !this.validateConnectionSecret(request)) { + console.error('Unauthorized request. Connection code:', request.headers['sec-websocket-protocol']); + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') + socket.destroy(); + return; + } + + if (pathname === '/ws') { + console.log('Client connected!') + const wss = this.createWssServer(); + wss.handleUpgrade(request, socket, head, (ws: WebSocket) => {}); + } + + const pathSections = pathname.split('/').filter(Boolean); + if ( + pathSections[0] === 'ws' + && pathSections[1] === 'session' + && pathSections[2] + && this.sessions.has(pathSections[2]) + ) { + const sessionId = pathSections[2]; + console.log('Session found, upgrading', sessionId); + + const session = this.sessions.get(sessionId); + if (!session) { + return; + } + + const wss = session.server; + wss.handleUpgrade(request, socket, head, (ws: WebSocket) => { + console.log('New ws session!', sessionId) + this.sessions.get(sessionId)!.ws = ws; + }); + + wss.on('close', () => { + console.log('Session closed'); + this.sessions.delete(sessionId); + }) + } + } + + private validateOrigin = (origin: string): boolean => + config.corsAllowedOrigins.includes(origin) + + private validateConnectionSecret = (request: IncomingMessage): boolean => { + const connectionSecret = request.headers['sec-websocket-protocol'] as string; + return connectionSecret === this.connectionSecret; + } + + private createWssServer(): WebSocketServer { + return new WebSocketServer({ + noServer: true, + }) } } \ No newline at end of file From 16e42190dfff4fc23e26df806a374a749acebb79 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Mon, 15 Sep 2025 21:24:34 -0400 Subject: [PATCH 12/67] Prevent multiple pty instances from being created --- .../http-routes/handlers/create-command.ts | 39 ++++++++++++++++++- src/connect/http-routes/router.ts | 1 + src/connect/socket-server.ts | 4 +- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/connect/http-routes/handlers/create-command.ts b/src/connect/http-routes/handlers/create-command.ts index c412db7f..627873dd 100644 --- a/src/connect/http-routes/handlers/create-command.ts +++ b/src/connect/http-routes/handlers/create-command.ts @@ -3,7 +3,33 @@ import { Router } from 'express'; import { SocketServer } from '../../socket-server.js'; -export function createCommandHandler(command: string, args?: string): Router { +export enum ConnectCommand { + TERMINAL = 'terminal', + APPLY = 'apply', + PLAN = 'plan', + IMPORT = 'import' +} + +const CommandInfo = { + [ConnectCommand.TERMINAL]: { + args: [], + }, + [ConnectCommand.APPLY]: { + args: ['-c', 'codify apply'] + } + +} + +export function createCommandHandler(command: ConnectCommand): Router { + // if (!Object.values(ConnectCommand).includes(command)) { + // throw new Error(`Unknown command ${command}. Please check code`); + // } + // + // const commandInfo = CommandInfo[command]; + // if (!commandInfo) { + // throw new Error(`Command info not provided for ${command}. Please check code`); + // } + const router = Router({ mergeParams: true, }); @@ -22,11 +48,15 @@ export function createCommandHandler(command: string, args?: string): Router { return res.status(400).json({ error: 'SessionId does not exist' }); } - const {ws, server} = session; + const { ws, server } = session; if (!ws) { return res.status(400).json({ error: 'SessionId not open' }); } + if (session.pty) { + return res.status(304).json({ status: 'Already started' }) + } + const pty = spawn('zsh', [], { name: 'xterm-color', cols: 80, @@ -35,6 +65,8 @@ export function createCommandHandler(command: string, args?: string): Router { env: process.env }); + session.pty = pty; + pty.onData((data) => { ws.send(Buffer.from(data, 'utf8')); }); @@ -48,6 +80,9 @@ export function createCommandHandler(command: string, args?: string): Router { ws.terminate(); server.close(); }) + + + return res.status(204).json({}); }); return router; diff --git a/src/connect/http-routes/router.ts b/src/connect/http-routes/router.ts index 31cc67c2..21e12b85 100644 --- a/src/connect/http-routes/router.ts +++ b/src/connect/http-routes/router.ts @@ -9,5 +9,6 @@ router.use('/', defaultHandler); router.use('/apply', createCommandHandler('apply')); router.use('/plan', createCommandHandler('plan')); router.use('/import', createCommandHandler('import')); +router.use('/terminal', createCommandHandler('')); export default router; \ No newline at end of file diff --git a/src/connect/socket-server.ts b/src/connect/socket-server.ts index 19e5b82e..a5c2f61c 100644 --- a/src/connect/socket-server.ts +++ b/src/connect/socket-server.ts @@ -1,3 +1,4 @@ +import { IPty } from '@homebridge/node-pty-prebuilt-multiarch'; import { Server as HttpServer, IncomingMessage } from 'node:http'; import { Duplex } from 'node:stream'; import WebSocket, { WebSocketServer } from 'ws'; @@ -7,6 +8,7 @@ import { config } from '../config.js'; export interface Session { server: WebSocketServer; ws?: WebSocket; + pty?: IPty; } let instance: SocketServer | undefined; @@ -67,7 +69,7 @@ export class SocketServer { return; } - if (/*!this.validateOrigin(request.headers.origin ?? request.headers.referer ?? '') ||*/ !this.validateConnectionSecret(request)) { + if (/*! this.validateOrigin(request.headers.origin ?? request.headers.referer ?? '') || */ !this.validateConnectionSecret(request)) { console.error('Unauthorized request. Connection code:', request.headers['sec-websocket-protocol']); socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') socket.destroy(); From 04e8b1e31a053856147bce44ea7e6e6e23856337 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Mon, 15 Sep 2025 21:47:07 -0400 Subject: [PATCH 13/67] Improved the command handler to support more variations --- .../http-routes/handlers/create-command.ts | 39 +++++++++++++------ src/connect/http-routes/router.ts | 10 ++--- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/connect/http-routes/handlers/create-command.ts b/src/connect/http-routes/handlers/create-command.ts index 627873dd..87c9e57f 100644 --- a/src/connect/http-routes/handlers/create-command.ts +++ b/src/connect/http-routes/handlers/create-command.ts @@ -12,23 +12,32 @@ export enum ConnectCommand { const CommandInfo = { [ConnectCommand.TERMINAL]: { - args: [], + command: () => [], + requiresDocumentId: false, }, [ConnectCommand.APPLY]: { - args: ['-c', 'codify apply'] + command: (args) => ['-c', `codify apply ${args}`], + requiresDocumentId: true, + }, + [ConnectCommand.PLAN]: { + command: (args) => ['-c', `codify plan ${args}`], + requiresDocumentId: true, + }, + [ConnectCommand.IMPORT]: { + command: (args) => ['-c', `codify import ${args}`], + requiresDocumentId: true, } - } export function createCommandHandler(command: ConnectCommand): Router { - // if (!Object.values(ConnectCommand).includes(command)) { - // throw new Error(`Unknown command ${command}. Please check code`); - // } - // - // const commandInfo = CommandInfo[command]; - // if (!commandInfo) { - // throw new Error(`Command info not provided for ${command}. Please check code`); - // } + if (!Object.values(ConnectCommand).includes(command)) { + throw new Error(`Unknown command ${command}. Please check code`); + } + + const commandInfo = CommandInfo[command]; + if (!commandInfo) { + throw new Error(`Command info not provided for ${command}. Please check code`); + } const router = Router({ mergeParams: true, @@ -36,12 +45,17 @@ export function createCommandHandler(command: ConnectCommand): Router { router.post('/:sessionId/start', async (req, res) => { const { sessionId } = req.params; + const { documentId } = req.body; console.log(`Received request to ${command}, sessionId: ${sessionId}`) if (!sessionId) { return res.status(400).json({ error: 'SessionId must be provided' }); } + if (commandInfo.requiresDocumentId && !documentId) { + return res.status(400).json({ error: 'Document id must be provided' }); + } + const manager = SocketServer.get(); const session = manager.getSession(sessionId); if (!session) { @@ -57,7 +71,8 @@ export function createCommandHandler(command: ConnectCommand): Router { return res.status(304).json({ status: 'Already started' }) } - const pty = spawn('zsh', [], { + console.log('Running command:', commandInfo.command(documentId)) + const pty = spawn('zsh', commandInfo.command(documentId), { name: 'xterm-color', cols: 80, rows: 30, diff --git a/src/connect/http-routes/router.ts b/src/connect/http-routes/router.ts index 21e12b85..89ee0b64 100644 --- a/src/connect/http-routes/router.ts +++ b/src/connect/http-routes/router.ts @@ -1,14 +1,14 @@ import { Router } from 'express'; -import { createCommandHandler } from './handlers/create-command.js'; +import { ConnectCommand, createCommandHandler } from './handlers/create-command.js'; import defaultHandler from './handlers/index.js'; const router = Router(); router.use('/', defaultHandler); -router.use('/apply', createCommandHandler('apply')); -router.use('/plan', createCommandHandler('plan')); -router.use('/import', createCommandHandler('import')); -router.use('/terminal', createCommandHandler('')); +router.use('/apply', createCommandHandler(ConnectCommand.APPLY)); +router.use('/plan', createCommandHandler(ConnectCommand.PLAN)) +router.use('/import', createCommandHandler(ConnectCommand.IMPORT)); +router.use('/terminal', createCommandHandler(ConnectCommand.TERMINAL)); export default router; \ No newline at end of file From f569348e117407549b6bd70786090e4ec9cf99ad Mon Sep 17 00:00:00 2001 From: kevinwang Date: Tue, 16 Sep 2025 09:29:08 -0400 Subject: [PATCH 14/67] Fixed build bugs. Moved uuid to dependency. Fixed entry points that were messed up. Improved error handling for documents endpoint --- README.md | 2 +- esbuild.ts | 2 +- package-lock.json | 3 +-- package.json | 4 ++-- src/api/dashboard/index.ts | 5 +++-- src/connect/http-routes/handlers/create-command.ts | 2 +- src/parser/reader/cloud-reader.ts | 14 +++++++------- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 9230428c..0edfb295 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ $ npm install -g codify $ codify COMMAND running command... $ codify (--version) -codify/0.9.0 darwin-arm64 node-v20.15.1 +codify/0.9.0 darwin-arm64 node-v20.19.5 $ codify --help [COMMAND] USAGE $ codify COMMAND diff --git a/esbuild.ts b/esbuild.ts index ed77cd8b..9fe6dd1b 100644 --- a/esbuild.ts +++ b/esbuild.ts @@ -1,7 +1,7 @@ import { build } from 'esbuild'; const result = await build({ - entryPoints: ['src/handlers/**/router.ts', 'src/handlers/*.ts', 'src/router.ts'], + entryPoints: ['src/commands/**/index.ts', 'src/commands/*.ts', 'src/index.ts'], bundle: true, minify: true, splitting: true, diff --git a/package-lock.json b/package-lock.json index ddbaf141..f8acca55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "semver": "^7.5.4", "socket.io": "^4.8.1", "supports-color": "^9.4.0", + "uuid": "^10.0.0", "ws": "^8.18.3" }, "bin": { @@ -86,7 +87,6 @@ "strip-ansi": "^7.1.0", "tsx": "^4.7.3", "typescript": "5.3.3", - "uuid": "^10.0.0", "vitest": "^2.1.6" }, "engines": { @@ -14718,7 +14718,6 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "dev": true, "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/package.json b/package.json index 5d000e31..944f0ef9 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "semver": "^7.5.4", "socket.io": "^4.8.1", "supports-color": "^9.4.0", - "ws": "^8.18.3" + "ws": "^8.18.3", + "uuid": "^10.0.0" }, "description": "Codify allows users to configure settings, install new packages, and automate their systems using code instead of the GUI. Get set up on a new laptop in one click, maintain a Codify file within your project so anyone can get started and never lose your cool apps or favourite settings again.", "devDependencies": { @@ -79,7 +80,6 @@ "strip-ansi": "^7.1.0", "tsx": "^4.7.3", "typescript": "5.3.3", - "uuid": "^10.0.0", "vitest": "^2.1.6" }, "overrides": { diff --git a/src/api/dashboard/index.ts b/src/api/dashboard/index.ts index d3626380..a522735b 100644 --- a/src/api/dashboard/index.ts +++ b/src/api/dashboard/index.ts @@ -15,11 +15,12 @@ export const DashboardApiClient = { { method: 'GET', headers: { 'Content-Type': 'application/json', 'authorization': login.accessToken } }, ); - const json = await res.json(); if (!res.ok) { - throw new Error(JSON.stringify(json, null, 2)); + throw new Error(`Error fetching document: ${res.statusText}`); } + const json = await res.json(); + return json; }, diff --git a/src/connect/http-routes/handlers/create-command.ts b/src/connect/http-routes/handlers/create-command.ts index 87c9e57f..2bbc43c8 100644 --- a/src/connect/http-routes/handlers/create-command.ts +++ b/src/connect/http-routes/handlers/create-command.ts @@ -101,4 +101,4 @@ export function createCommandHandler(command: ConnectCommand): Router { }); return router; -} \ No newline at end of file +} diff --git a/src/parser/reader/cloud-reader.ts b/src/parser/reader/cloud-reader.ts index c03ae722..5e15e9de 100644 --- a/src/parser/reader/cloud-reader.ts +++ b/src/parser/reader/cloud-reader.ts @@ -3,14 +3,14 @@ import { FileType, InMemoryFile } from '../entities.js'; import { Reader } from './index.js'; export class CloudReader implements Reader { - async read(filePath: string): Promise { - const document = await DashboardApiClient.getDocument(filePath); + async read(filePath: string): Promise { + const document = await DashboardApiClient.getDocument(filePath); - return { - contents: JSON.stringify(document.contents), - filePath, - fileType: FileType.CLOUD, - } + return { + contents: JSON.stringify(document.contents), + filePath, + fileType: FileType.CLOUD, } + } } From 9737286e7e00a3315335229dd649fe4cbe3fea58 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Tue, 16 Sep 2025 23:16:02 -0400 Subject: [PATCH 15/67] Improved session ending logic. --- src/connect/http-routes/handlers/create-command.ts | 8 ++++++++ src/connect/socket-server.ts | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/connect/http-routes/handlers/create-command.ts b/src/connect/http-routes/handlers/create-command.ts index 2bbc43c8..788e7984 100644 --- a/src/connect/http-routes/handlers/create-command.ts +++ b/src/connect/http-routes/handlers/create-command.ts @@ -1,4 +1,5 @@ import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; +import chalk from 'chalk'; import { Router } from 'express'; import { SocketServer } from '../../socket-server.js'; @@ -92,10 +93,17 @@ export function createCommandHandler(command: ConnectCommand): Router { pty.onExit(({ exitCode, signal }) => { console.log('pty exit', exitCode, signal); + ws.send(Buffer.from(chalk.blue(`Session ended exit code ${exitCode}`), 'utf8')) + ws.terminate(); server.close(); }) + ws.on('close', () => { + console.log('Session ws closed. Shutting down pty'); + pty.kill(); + manager.removeSession(sessionId) + }) return res.status(204).json({}); }); diff --git a/src/connect/socket-server.ts b/src/connect/socket-server.ts index a5c2f61c..13e7b82b 100644 --- a/src/connect/socket-server.ts +++ b/src/connect/socket-server.ts @@ -56,6 +56,10 @@ export class SocketServer { ) } + removeSession(id: string) { + this.sessions.delete(id); + } + // Under normal use, there should only be 1 socket (1 connection) per namespace. getSession(id: string): Session | undefined { return this.sessions.get(id); @@ -124,4 +128,4 @@ export class SocketServer { }) } -} \ No newline at end of file +} From 58842667dab3b474088febf34c3760d6a8d6229e Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 24 Sep 2025 00:25:19 -0400 Subject: [PATCH 16/67] Added missing import flag --- src/connect/http-routes/handlers/create-command.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/connect/http-routes/handlers/create-command.ts b/src/connect/http-routes/handlers/create-command.ts index 788e7984..858eadbf 100644 --- a/src/connect/http-routes/handlers/create-command.ts +++ b/src/connect/http-routes/handlers/create-command.ts @@ -25,7 +25,7 @@ const CommandInfo = { requiresDocumentId: true, }, [ConnectCommand.IMPORT]: { - command: (args) => ['-c', `codify import ${args}`], + command: (args) => ['-c', `codify import -p ${args}`], requiresDocumentId: true, } } From 0ac13549e2021b099ba256afe0503a652c277f6c Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 24 Sep 2025 18:23:40 -0400 Subject: [PATCH 17/67] Changed connect commands to use rootCommand that way it updates with the current project and doesn't rely on global codify. Added error if port is already in use. --- src/commands/connect.ts | 4 ++-- src/commands/edit.ts | 2 +- .../http-routes/handlers/create-command.ts | 7 ++++--- src/orchestrators/connect.ts | 18 ++++++++++++++++-- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 08010949..83712bfa 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -1,5 +1,4 @@ import { BaseCommand } from '../common/base-command.js'; -import { LoginOrchestrator } from '../orchestrators/login.js'; import { ConnectOrchestrator } from '../orchestrators/connect.js'; export default class Connect extends BaseCommand { @@ -18,7 +17,8 @@ For more information, visit: https://docs.codifycli.com/commands/validate public async run(): Promise { const { flags } = await this.parse(Connect) + const config = this.config; - await ConnectOrchestrator.run(); + await ConnectOrchestrator.run(config); } } diff --git a/src/commands/edit.ts b/src/commands/edit.ts index 90fde6cf..566a079a 100644 --- a/src/commands/edit.ts +++ b/src/commands/edit.ts @@ -18,6 +18,6 @@ For more information, visit: https://docs.codifycli.com/commands/validate public async run(): Promise { const { flags } = await this.parse(Edit) - await ConnectOrchestrator.run(); + // await ConnectOrchestrator.run(); } } diff --git a/src/connect/http-routes/handlers/create-command.ts b/src/connect/http-routes/handlers/create-command.ts index 858eadbf..3c77af81 100644 --- a/src/connect/http-routes/handlers/create-command.ts +++ b/src/connect/http-routes/handlers/create-command.ts @@ -2,6 +2,7 @@ import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; import chalk from 'chalk'; import { Router } from 'express'; +import { ConnectOrchestrator } from '../../../orchestrators/connect.js'; import { SocketServer } from '../../socket-server.js'; export enum ConnectCommand { @@ -17,15 +18,15 @@ const CommandInfo = { requiresDocumentId: false, }, [ConnectCommand.APPLY]: { - command: (args) => ['-c', `codify apply ${args}`], + command: (args) => ['-c', `${ConnectOrchestrator.rootCommand} apply ${args}`], requiresDocumentId: true, }, [ConnectCommand.PLAN]: { - command: (args) => ['-c', `codify plan ${args}`], + command: (args) => ['-c', `${ConnectOrchestrator.rootCommand} plan ${args}`], requiresDocumentId: true, }, [ConnectCommand.IMPORT]: { - command: (args) => ['-c', `codify import -p ${args}`], + command: (args) => ['-c', `${ConnectOrchestrator.rootCommand} import -p ${args}`], requiresDocumentId: true, } } diff --git a/src/orchestrators/connect.ts b/src/orchestrators/connect.ts index 52a847b8..d9ca4f50 100644 --- a/src/orchestrators/connect.ts +++ b/src/orchestrators/connect.ts @@ -1,3 +1,4 @@ +import { Config } from '@oclif/core'; import cors from 'cors'; import express, { json } from 'express'; import { randomBytes } from 'node:crypto'; @@ -8,7 +9,11 @@ import router from '../connect/http-routes/router.js'; import { SocketServer } from '../connect/socket-server.js'; export class ConnectOrchestrator { - static async run() { + static rootCommand: string; + + static async run(oclifConfig: Config) { + this.rootCommand = oclifConfig.options.root; + const connectionSecret = ConnectOrchestrator.tokenGenerate() const app = express(); @@ -16,7 +21,16 @@ export class ConnectOrchestrator { app.use(json()) app.use(router); - const server = app.listen(config.connectServerPort, () => { + const server = app.listen(config.connectServerPort, (error) => { + if (error) { + if (error.message.includes('EADDRINUSE')) { + console.error('An instance of \'codify connect\' is already running.\n\nExiting...') + return; + } + + throw error; + } + open(`http://localhost:3000/connection/success?code=${connectionSecret}`) console.log(`Open browser window to store code. From 1c24ff22bd44e97d02ff8e9098afe0c34b637017 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 24 Sep 2025 21:49:26 -0400 Subject: [PATCH 18/67] Refactored how commands are handled. Allows for greater flexibility. --- codify-imports/neal.codify.jsonc | 98 +++++++++++++++++++ .../http-routes/handlers/apply-handler.ts | 52 ++++++++++ .../http-routes/handlers/create-command.ts | 56 ++++------- .../http-routes/handlers/plan-handler.ts | 52 ++++++++++ .../http-routes/handlers/terminal-handler.ts | 8 ++ src/connect/http-routes/router.ts | 13 ++- src/connect/socket-server.ts | 8 +- 7 files changed, 241 insertions(+), 46 deletions(-) create mode 100644 codify-imports/neal.codify.jsonc create mode 100644 src/connect/http-routes/handlers/apply-handler.ts create mode 100644 src/connect/http-routes/handlers/plan-handler.ts create mode 100644 src/connect/http-routes/handlers/terminal-handler.ts diff --git a/codify-imports/neal.codify.jsonc b/codify-imports/neal.codify.jsonc new file mode 100644 index 00000000..e47e5be8 --- /dev/null +++ b/codify-imports/neal.codify.jsonc @@ -0,0 +1,98 @@ +[ + // Installs homebrew and packages + { + "type": "homebrew", + "formulae": [ + "dopplerhq/cli/doppler", + "gnupg", + "hookdeck/hookdeck/hookdeck", + "libpq", + "temporal" + ], + "casks": [ + "google-chrome", + "slack", + "sublime-text" + ] + }, + + // Sets up SSH keys and helps setup github SSH + { + "type": "ssh-config", + "hosts": [ + { + "Host": "github.com", + "AddKeysToAgent": true, + "UseKeychain": true, + "IgnoreUnknown": "UseKeychain", + "IdentityFile": "~/.ssh/id_ed25519" + } + ] + }, + { + "type": "ssh-key", + "fileName": "id_ed25519", + "passphrase": "my_temp_pass", + "keyType": "ed25519" + }, + { + "type": "ssh-add", + "path": "~/.ssh/id_ed25519", + "appleUseKeychain": true + }, + { "type": "wait-github-ssh-key" }, + + // Configs global git username and email + { + "type": "git", + "email": "chandra.neal@gmail.com", + "username": "neal" + }, + + // Setup node + { + "type": "nvm", + "global": "23", + "nodeVersions": [ + "23" + ] + }, + { "type": "pnpm" }, + + // Docker. This will install docker, docker engine, docker compose docker cli + { "type": "docker" }, + + // You can add your git repos here to clone +// { +// "type": "git-repository", +// "directory": "~/Projects/example-project", +// "repository": "git@github.com:kevinwang5658/codify-homebrew-plugin.git" +// }, +// { +// "type": "git-repository", +// "directory": "~/Projects/example-project", +// "repository": "git@github.com:my_repo" +// }, + + + // Actions are custom commands you can run + { + "type": "action", + "action": "brew link --force libpq", + "dependsOn": [ + "homebrew" + ] + }, + { + "type": "action", + "action": "doppler login && doppler setup", + "dependsOn": [ + "homebrew" + ] + }, + { + "type": "action", + "action": "hookdeck login" + } +] + diff --git a/src/connect/http-routes/handlers/apply-handler.ts b/src/connect/http-routes/handlers/apply-handler.ts new file mode 100644 index 00000000..b2f92634 --- /dev/null +++ b/src/connect/http-routes/handlers/apply-handler.ts @@ -0,0 +1,52 @@ +import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; +import { ConfigFileSchema } from 'codify-schemas'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { WebSocket } from 'ws'; + +import { ConnectOrchestrator } from '../../../orchestrators/connect.js'; +import { ajv } from '../../../utils/ajv.js'; +import { Session } from '../../socket-server.js'; +import { ConnectCommand, createCommandHandler } from './create-command.js'; + +const validator = ajv.compile(ConfigFileSchema); + +export function applyHandler() { + const spawnCommand = async (body: Record, ws: WebSocket, session: Session) => { + const codifyConfig = body.config; + if (!codifyConfig) { + throw new Error('Unable to parse codify config'); + } + + if (!validator(codifyConfig)) { + throw new Error('Invalid codify config'); + } + + const tmpDir = await fs.mkdtemp(os.tmpdir()); + const filePath = path.join(tmpDir, 'codify.jsonc'); + await fs.writeFile(filePath, JSON.stringify(codifyConfig, null, 2)); + + session.additionalData.filePath = filePath; + + return spawn('zsh', ['-c', `${ConnectOrchestrator.rootCommand} apply -p ${filePath}`], { + name: 'xterm-color', + cols: 80, + rows: 30, + cwd: process.env.HOME, + env: process.env + }); + } + + const onExit = async (exitCode: number, ws: WebSocket, session: Session) => { + if (session.additionalData.filePath) { + await fs.rm(session.additionalData.filePath as string, { recursive: true, force: true }); + } + } + + return createCommandHandler({ + name: ConnectCommand.APPLY, + spawnCommand, + onExit + }); +} diff --git a/src/connect/http-routes/handlers/create-command.ts b/src/connect/http-routes/handlers/create-command.ts index 3c77af81..7fbee02e 100644 --- a/src/connect/http-routes/handlers/create-command.ts +++ b/src/connect/http-routes/handlers/create-command.ts @@ -1,9 +1,10 @@ -import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; +import { IPty, spawn } from '@homebridge/node-pty-prebuilt-multiarch'; import chalk from 'chalk'; import { Router } from 'express'; import { ConnectOrchestrator } from '../../../orchestrators/connect.js'; -import { SocketServer } from '../../socket-server.js'; +import { Session, SocketServer } from '../../socket-server.js'; +import WebSocket from 'ws'; export enum ConnectCommand { TERMINAL = 'terminal', @@ -12,33 +13,20 @@ export enum ConnectCommand { IMPORT = 'import' } -const CommandInfo = { - [ConnectCommand.TERMINAL]: { - command: () => [], - requiresDocumentId: false, - }, - [ConnectCommand.APPLY]: { - command: (args) => ['-c', `${ConnectOrchestrator.rootCommand} apply ${args}`], - requiresDocumentId: true, - }, - [ConnectCommand.PLAN]: { - command: (args) => ['-c', `${ConnectOrchestrator.rootCommand} plan ${args}`], - requiresDocumentId: true, - }, - [ConnectCommand.IMPORT]: { - command: (args) => ['-c', `${ConnectOrchestrator.rootCommand} import -p ${args}`], - requiresDocumentId: true, - } +interface Params { + name: ConnectCommand; + command?: string[]; + spawnCommand?: (body: Record, ws: WebSocket, session: Session) => IPty | Promise; + onExit?: (exitCode: number, ws: WebSocket, session: Session) => Promise | void; } -export function createCommandHandler(command: ConnectCommand): Router { - if (!Object.values(ConnectCommand).includes(command)) { - throw new Error(`Unknown command ${command}. Please check code`); +export function createCommandHandler({ name, command, spawnCommand, onExit }: Params): Router { + if (!Object.values(ConnectCommand).includes(name)) { + throw new Error(`Unknown command ${name}. Please check code`); } - const commandInfo = CommandInfo[command]; - if (!commandInfo) { - throw new Error(`Command info not provided for ${command}. Please check code`); + if (!command && !spawnCommand) { + throw new Error('One of command or spawnCommand must be provided to createCommandHandler'); } const router = Router({ @@ -47,17 +35,12 @@ export function createCommandHandler(command: ConnectCommand): Router { router.post('/:sessionId/start', async (req, res) => { const { sessionId } = req.params; - const { documentId } = req.body; - console.log(`Received request to ${command}, sessionId: ${sessionId}`) + console.log(`Received request to ${name}, sessionId: ${sessionId}`) if (!sessionId) { return res.status(400).json({ error: 'SessionId must be provided' }); } - if (commandInfo.requiresDocumentId && !documentId) { - return res.status(400).json({ error: 'Document id must be provided' }); - } - const manager = SocketServer.get(); const session = manager.getSession(sessionId); if (!session) { @@ -73,8 +56,9 @@ export function createCommandHandler(command: ConnectCommand): Router { return res.status(304).json({ status: 'Already started' }) } - console.log('Running command:', commandInfo.command(documentId)) - const pty = spawn('zsh', commandInfo.command(documentId), { + console.log(req.body); + + const pty = spawnCommand ? await spawnCommand(req.body, ws, session) : spawn('zsh', command!, { name: 'xterm-color', cols: 80, rows: 30, @@ -92,10 +76,12 @@ export function createCommandHandler(command: ConnectCommand): Router { pty.write(message.toString('utf8')); }) - pty.onExit(({ exitCode, signal }) => { - console.log('pty exit', exitCode, signal); + pty.onExit(async ({ exitCode, signal }) => { + console.log(`Command ${name} exited with exit code`, exitCode); ws.send(Buffer.from(chalk.blue(`Session ended exit code ${exitCode}`), 'utf8')) + await onExit?.(exitCode, ws, session) + ws.terminate(); server.close(); }) diff --git a/src/connect/http-routes/handlers/plan-handler.ts b/src/connect/http-routes/handlers/plan-handler.ts new file mode 100644 index 00000000..c195f7ef --- /dev/null +++ b/src/connect/http-routes/handlers/plan-handler.ts @@ -0,0 +1,52 @@ +import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; +import { ConfigFileSchema } from 'codify-schemas'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { WebSocket } from 'ws'; + +import { ConnectOrchestrator } from '../../../orchestrators/connect.js'; +import { ajv } from '../../../utils/ajv.js'; +import { Session } from '../../socket-server.js'; +import { ConnectCommand, createCommandHandler } from './create-command.js'; + +const validator = ajv.compile(ConfigFileSchema); + +export function planHandler() { + const spawnCommand = async (body: Record, ws: WebSocket, session: Session) => { + const codifyConfig = body.config; + if (!codifyConfig) { + throw new Error('Unable to parse codify config'); + } + + if (!validator(codifyConfig)) { + throw new Error('Invalid codify config'); + } + + const tmpDir = await fs.mkdtemp(os.tmpdir()); + const filePath = path.join(tmpDir, 'codify.jsonc'); + await fs.writeFile(filePath, JSON.stringify(codifyConfig, null, 2)); + + session.additionalData.filePath = filePath; + + return spawn('zsh', ['-c', `${ConnectOrchestrator.rootCommand} plan -p ${filePath}`], { + name: 'xterm-color', + cols: 80, + rows: 30, + cwd: process.env.HOME, + env: process.env + }); + } + + const onExit = async (exitCode: number, ws: WebSocket, session: Session) => { + if (session.additionalData.filePath) { + await fs.rm(session.additionalData.filePath as string, { recursive: true, force: true }); + } + } + + return createCommandHandler({ + name: ConnectCommand.APPLY, + spawnCommand, + onExit + }); +} diff --git a/src/connect/http-routes/handlers/terminal-handler.ts b/src/connect/http-routes/handlers/terminal-handler.ts new file mode 100644 index 00000000..52d6eb3c --- /dev/null +++ b/src/connect/http-routes/handlers/terminal-handler.ts @@ -0,0 +1,8 @@ +import { ConnectCommand, createCommandHandler } from './create-command.js'; + +export function terminalHandler() { + return createCommandHandler({ + name: ConnectCommand.TERMINAL, + command: [], + }) +} diff --git a/src/connect/http-routes/router.ts b/src/connect/http-routes/router.ts index 89ee0b64..9307107a 100644 --- a/src/connect/http-routes/router.ts +++ b/src/connect/http-routes/router.ts @@ -2,13 +2,16 @@ import { Router } from 'express'; import { ConnectCommand, createCommandHandler } from './handlers/create-command.js'; import defaultHandler from './handlers/index.js'; +import { terminalHandler } from './handlers/terminal-handler.js'; +import { applyHandler } from './handlers/apply-handler.js'; +import { planHandler } from './handlers/plan-handler.js'; const router = Router(); router.use('/', defaultHandler); -router.use('/apply', createCommandHandler(ConnectCommand.APPLY)); -router.use('/plan', createCommandHandler(ConnectCommand.PLAN)) -router.use('/import', createCommandHandler(ConnectCommand.IMPORT)); -router.use('/terminal', createCommandHandler(ConnectCommand.TERMINAL)); +router.use('/apply', applyHandler()); +router.use('/plan', planHandler()) +// router.use('/import', createCommandHandler(ConnectCommand.IMPORT)); +router.use('/terminal', terminalHandler()); -export default router; \ No newline at end of file +export default router; diff --git a/src/connect/socket-server.ts b/src/connect/socket-server.ts index 13e7b82b..2d4ac0d5 100644 --- a/src/connect/socket-server.ts +++ b/src/connect/socket-server.ts @@ -9,6 +9,7 @@ export interface Session { server: WebSocketServer; ws?: WebSocket; pty?: IPty; + additionalData: Record; } let instance: SocketServer | undefined; @@ -45,14 +46,9 @@ export class SocketServer { } addSession(id: string): void { - // this.io.of(`/ws/session/${id}`).on('connection', (socket) => { - // console.log(`Session ${id} connected!!`); - // handler?.(this.io, socket); - // }) - this.sessions.set( id, - { server: this.createWssServer() } + { server: this.createWssServer(), additionalData: {} } ) } From fe1788c249dcb5b58dc18c01f9149bdd3771bd36 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 24 Sep 2025 22:57:53 -0400 Subject: [PATCH 19/67] Added import handler --- .../http-routes/handlers/import-handler.ts | 82 +++++++++++++++++++ src/connect/http-routes/router.ts | 8 +- 2 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 src/connect/http-routes/handlers/import-handler.ts diff --git a/src/connect/http-routes/handlers/import-handler.ts b/src/connect/http-routes/handlers/import-handler.ts new file mode 100644 index 00000000..633f69e9 --- /dev/null +++ b/src/connect/http-routes/handlers/import-handler.ts @@ -0,0 +1,82 @@ +import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; +import { ConfigFileSchema } from 'codify-schemas'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { WebSocket } from 'ws'; + +import { ConnectOrchestrator } from '../../../orchestrators/connect.js'; +import { ajv } from '../../../utils/ajv.js'; +import { Session, SocketServer } from '../../socket-server.js'; +import { ConnectCommand, createCommandHandler } from './create-command.js'; + +enum ImportType { + REFRESH = 'refresh', + NEW_RESOURCES = 'new_resources', + NEW_ALL = 'new_all', +} + +const validator = ajv.compile(ConfigFileSchema); + +export function importHandler() { + const spawnCommand = async (body: Record, ws: WebSocket, session: Session) => { + const { config: codifyConfig, type, resourceTypes } = body; + if (!codifyConfig) { + throw new Error('Unable to parse codify config'); + } + + if (!type || !Object.values(ImportType).includes(type as ImportType)) { + throw new Error('Unable to parse import type'); + } + + if (type === ImportType.NEW_RESOURCES && (!resourceTypes || Array.isArray(resourceTypes))) { + throw new Error('For new resources import type, a list of resource types must be provided'); + } + + if (!validator(codifyConfig)) { + throw new Error('Invalid codify config'); + } + + const tmpDir = await fs.mkdtemp(os.tmpdir()); + const filePath = path.join(tmpDir, 'codify.jsonc'); + await fs.writeFile(filePath, JSON.stringify(codifyConfig, null, 2)); + session.additionalData.filePath = filePath; + + let args = ''; + switch (type as ImportType) { + case ImportType.REFRESH: { + break; + } + + case ImportType.NEW_RESOURCES: { + args = (resourceTypes as string[]).join(' '); + break; + } + + case ImportType.NEW_ALL: { + args = '*' + break; + } + } + + return spawn('zsh', ['-c', `${ConnectOrchestrator.rootCommand} import ${args} -p ${filePath}`], { + name: 'xterm-color', + cols: 80, + rows: 30, + cwd: process.env.HOME, + env: process.env + }); + } + + const onExit = async (exitCode: number, ws: WebSocket, session: Session) => { + if (session.additionalData.filePath) { + await fs.rm(session.additionalData.filePath as string, { recursive: true, force: true }); + } + } + + return createCommandHandler({ + name: ConnectCommand.APPLY, + spawnCommand, + onExit + }); +} diff --git a/src/connect/http-routes/router.ts b/src/connect/http-routes/router.ts index 9307107a..c5e4c0fb 100644 --- a/src/connect/http-routes/router.ts +++ b/src/connect/http-routes/router.ts @@ -1,17 +1,17 @@ import { Router } from 'express'; -import { ConnectCommand, createCommandHandler } from './handlers/create-command.js'; -import defaultHandler from './handlers/index.js'; -import { terminalHandler } from './handlers/terminal-handler.js'; import { applyHandler } from './handlers/apply-handler.js'; +import { importHandler } from './handlers/import-handler.js'; +import defaultHandler from './handlers/index.js'; import { planHandler } from './handlers/plan-handler.js'; +import { terminalHandler } from './handlers/terminal-handler.js'; const router = Router(); router.use('/', defaultHandler); router.use('/apply', applyHandler()); router.use('/plan', planHandler()) -// router.use('/import', createCommandHandler(ConnectCommand.IMPORT)); +router.use('/import', importHandler()); router.use('/terminal', terminalHandler()); export default router; From ac46067637957474c6c51a3dae8b3e0e618dbd3d Mon Sep 17 00:00:00 2001 From: kevinwang Date: Thu, 25 Sep 2025 08:46:46 -0400 Subject: [PATCH 20/67] Added concept of clientId. Added import writer --- .../http-routes/handlers/import-handler.ts | 19 ++++++++++++ src/connect/http-routes/handlers/index.ts | 14 ++++++--- src/connect/socket-server.ts | 31 ++++++++++++++++--- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/connect/http-routes/handlers/import-handler.ts b/src/connect/http-routes/handlers/import-handler.ts index 633f69e9..99c27c49 100644 --- a/src/connect/http-routes/handlers/import-handler.ts +++ b/src/connect/http-routes/handlers/import-handler.ts @@ -1,5 +1,6 @@ import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; import { ConfigFileSchema } from 'codify-schemas'; +import { diffChars } from 'diff'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -41,6 +42,7 @@ export function importHandler() { const filePath = path.join(tmpDir, 'codify.jsonc'); await fs.writeFile(filePath, JSON.stringify(codifyConfig, null, 2)); session.additionalData.filePath = filePath; + session.additionalData.existingFile = codifyConfig; let args = ''; switch (type as ImportType) { @@ -70,6 +72,23 @@ export function importHandler() { const onExit = async (exitCode: number, ws: WebSocket, session: Session) => { if (session.additionalData.filePath) { + const updatedFile = await fs.readFile(session.additionalData.filePath as string, 'utf8') + + // Changes were found + if (diffChars(updatedFile, session.additionalData.existingFile as string).length > 0) { + console.log('Writing imported changes to Codify dashboard'); + + const ws = SocketServer.get().getMainConnection(session.clientId); + if (!ws) { + throw new Error(`Unable to find client for clientId ${session.clientId}`); + } + + ws.send(JSON.stringify({ key: 'new_import', data: { + updated: updatedFile, + } })) + } + + await fs.rm(session.additionalData.filePath as string, { recursive: true, force: true }); } } diff --git a/src/connect/http-routes/handlers/index.ts b/src/connect/http-routes/handlers/index.ts index 2a55b1a7..dd5be914 100644 --- a/src/connect/http-routes/handlers/index.ts +++ b/src/connect/http-routes/handlers/index.ts @@ -1,5 +1,4 @@ import { Router } from 'express'; -import { v4 as uuid } from 'uuid'; import { SocketServer } from '../../socket-server.js'; @@ -8,13 +7,20 @@ const router = Router({ }); router.post('/session', (req, res) => { - const sessionId = uuid(); + const { clientId } = req.body; + if (!clientId) { + throw new Error('connectionId is required'); + } + const socketServer = SocketServer.get(); + if (!socketServer.getMainConnection(clientId)) { + throw new Error('Invalid connection id'); + } - socketServer.addSession(sessionId); + const sessionId = socketServer.createSession(clientId); console.log('Terminal session created!', sessionId) return res.status(200).json({ sessionId }); }) -export default router; \ No newline at end of file +export default router; diff --git a/src/connect/socket-server.ts b/src/connect/socket-server.ts index 2d4ac0d5..695a76c8 100644 --- a/src/connect/socket-server.ts +++ b/src/connect/socket-server.ts @@ -1,12 +1,14 @@ import { IPty } from '@homebridge/node-pty-prebuilt-multiarch'; import { Server as HttpServer, IncomingMessage } from 'node:http'; import { Duplex } from 'node:stream'; +import { v4 as uuid } from 'uuid'; import WebSocket, { WebSocketServer } from 'ws'; import { config } from '../config.js'; export interface Session { server: WebSocketServer; + clientId: string; ws?: WebSocket; pty?: IPty; additionalData: Record; @@ -23,7 +25,8 @@ export class SocketServer { private server: HttpServer; private connectionSecret: string; - private sessions = new Map() + private mainConnections = new Map(); // These are per webpage + private sessions = new Map(); static init(server: HttpServer, connectionSecret: string): SocketServer { instance = new SocketServer(server, connectionSecret); @@ -45,11 +48,19 @@ export class SocketServer { this.server.on('upgrade', this.onUpgrade); } - addSession(id: string): void { + getMainConnection(id: string): WebSocket | undefined { + return this.mainConnections.get(id); + } + + createSession(clientId: string): string { + const sessionId = uuid(); + this.sessions.set( - id, - { server: this.createWssServer(), additionalData: {} } + sessionId, + { server: this.createWssServer(), clientId, additionalData: {} } ) + + return sessionId; } removeSession(id: string) { @@ -79,7 +90,7 @@ export class SocketServer { if (pathname === '/ws') { console.log('Client connected!') const wss = this.createWssServer(); - wss.handleUpgrade(request, socket, head, (ws: WebSocket) => {}); + wss.handleUpgrade(request, socket, head, this.handleClientConnected); } const pathSections = pathname.split('/').filter(Boolean); @@ -110,6 +121,16 @@ export class SocketServer { } } + private handleClientConnected = (ws: WebSocket) => { + const clientId = uuid(); + this.mainConnections.set(clientId, ws); + ws.send(JSON.stringify({ key: 'opened', data: { clientId: uuid } })) + + ws.on('close', () => { + this.mainConnections.delete(clientId); + }) + } + private validateOrigin = (origin: string): boolean => config.corsAllowedOrigins.includes(origin) From 3fb8aa8877fa51e1f790db30f80eddca892357b4 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 1 Oct 2025 10:08:52 -0400 Subject: [PATCH 21/67] Re-worked login to get email, userId, claims directly from token. Switched to ES256 self signed tokens. Added codify edit command --- package-lock.json | 10 +++++++ package.json | 5 ++-- src/api/dashboard/index.ts | 24 +++++++++-------- src/commands/edit.ts | 8 ++++-- src/config.ts | 5 +++- src/connect/http-routes/handlers/index.ts | 2 +- src/connect/login-helper.ts | 33 +++++++++++++++++++---- src/connect/socket-server.ts | 2 +- src/orchestrators/connect.ts | 18 ++++++++++--- src/orchestrators/edit.ts | 30 +++++++++++++++++++++ src/orchestrators/login.ts | 18 ++++--------- 11 files changed, 116 insertions(+), 39 deletions(-) create mode 100644 src/orchestrators/edit.ts diff --git a/package-lock.json b/package-lock.json index f8acca55..3c31c0bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "ink-gradient": "^3.0.0", "ink-select-input": "^6.0.0", "jju": "^1.4.0", + "jose": "^6.1.0", "jotai": "^2.11.1", "js-yaml": "^4.1.0", "js-yaml-source-map": "^0.2.2", @@ -10540,6 +10541,15 @@ "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==" }, + "node_modules/jose": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz", + "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jotai": { "version": "2.11.1", "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.1.tgz", diff --git a/package.json b/package.json index 944f0ef9..361e2c45 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "ink-gradient": "^3.0.0", "ink-select-input": "^6.0.0", "jju": "^1.4.0", + "jose": "^6.1.0", "jotai": "^2.11.1", "js-yaml": "^4.1.0", "js-yaml-source-map": "^0.2.2", @@ -42,8 +43,8 @@ "semver": "^7.5.4", "socket.io": "^4.8.1", "supports-color": "^9.4.0", - "ws": "^8.18.3", - "uuid": "^10.0.0" + "uuid": "^10.0.0", + "ws": "^8.18.3" }, "description": "Codify allows users to configure settings, install new packages, and automate their systems using code instead of the GUI. Get set up on a new laptop in one click, maintain a Codify file within your project so anyone can get started and never lose your cool apps or favourite settings again.", "devDependencies": { diff --git a/src/api/dashboard/index.ts b/src/api/dashboard/index.ts index a522735b..cd1b2b39 100644 --- a/src/api/dashboard/index.ts +++ b/src/api/dashboard/index.ts @@ -12,7 +12,7 @@ export const DashboardApiClient = { const res = await fetch( `${API_BASE_URL}/api/v1/documents/${id}`, - { method: 'GET', headers: { 'Content-Type': 'application/json', 'authorization': login.accessToken } }, + { method: 'GET', headers: { 'Content-Type': 'application/json', 'authorization': `Bearer ${login.accessToken}` } }, ); if (!res.ok) { @@ -24,23 +24,25 @@ export const DashboardApiClient = { return json; }, - async getDefaultDocumentId(): Promise { + async getDefaultDocumentId(): Promise { const login = LoginHelper.get()?.credentials; if (!login) { throw new Error('Not logged in'); } - // const res = await fetch( - // `${API_BASE_URL}/api/v1/documents/default/id`, - // { method: 'GET', headers: { 'Content-Type': 'application/json', 'authorization': login.accessToken } }, - // ); + console.log('Token', login.accessToken); - // const json = await res.json(); - // if (!res.ok) { - // throw new Error(JSON.stringify(json, null, 2)); - // } + const res = await fetch( + `${API_BASE_URL}/api/v1/documents/default/id`, + { method: 'GET', headers: { 'Content-Type': 'application/json', 'authorization': `Bearer ${login.accessToken}` } }, + ); + + const json = await res.json(); + if (!res.ok) { + throw new Error(JSON.stringify(json, null, 2)); + } - return '1b80818e-5304-4158-80a3-82e17ff2c79e'; + return json.defaultDocumentId; }, async saveDocumentUpdate(id: string, contents: string): Promise { diff --git a/src/commands/edit.ts b/src/commands/edit.ts index 566a079a..70d71e8d 100644 --- a/src/commands/edit.ts +++ b/src/commands/edit.ts @@ -1,5 +1,7 @@ import { BaseCommand } from '../common/base-command.js'; import { ConnectOrchestrator } from '../orchestrators/connect.js'; +import { EditOrchestrator } from '../orchestrators/edit.js'; +import { LoginHelper } from '../connect/login-helper.js'; export default class Edit extends BaseCommand { static description = @@ -16,8 +18,10 @@ For more information, visit: https://docs.codifycli.com/commands/validate ] public async run(): Promise { - const { flags } = await this.parse(Edit) + const { flags } = await this.parse(Edit); + const config = this.config; + + await EditOrchestrator.run(config); - // await ConnectOrchestrator.run(); } } diff --git a/src/config.ts b/src/config.ts index f5ede5a9..1dc2cb4f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,5 +6,8 @@ export const config = { 'https://dashboard.codifycli.com', 'https://https://codify-dashboard.kevinwang5658.workers.dev', 'http://localhost:3000' - ] + ], + + dashboardUrl: 'https://dashboard.codifycli.com', + supabaseUrl: 'https://kdctbvqvqjfquplxhqrm.supabase.co', } diff --git a/src/connect/http-routes/handlers/index.ts b/src/connect/http-routes/handlers/index.ts index dd5be914..a33e29ce 100644 --- a/src/connect/http-routes/handlers/index.ts +++ b/src/connect/http-routes/handlers/index.ts @@ -9,7 +9,7 @@ const router = Router({ router.post('/session', (req, res) => { const { clientId } = req.body; if (!clientId) { - throw new Error('connectionId is required'); + throw new Error('clientId is required'); } const socketServer = SocketServer.get(); diff --git a/src/connect/login-helper.ts b/src/connect/login-helper.ts index bc1be046..79d906e7 100644 --- a/src/connect/login-helper.ts +++ b/src/connect/login-helper.ts @@ -1,14 +1,20 @@ +import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; +import { config } from '../config.js'; interface Credentials { accessToken: string; email: string; userId: string; - expiry: string; + expiry: number; } +const PROJECT_JWKS = createRemoteJWKSet( + new URL(`${config.supabaseUrl}/auth/v1/.well-known/jwks.json`) +) + export class LoginHelper { private static instance: LoginHelper; @@ -23,6 +29,8 @@ export class LoginHelper { } const credentials = await LoginHelper.read(); + + console.log('My credentials', credentials); if (!credentials) { LoginHelper.instance = new LoginHelper(false); return LoginHelper.instance; @@ -41,19 +49,34 @@ export class LoginHelper { return LoginHelper.instance; } - static async save(credentials: Credentials) { + static async save(accessToken: string) { const credentialsPath = path.join(os.homedir(), '.codify', 'credentials.json'); console.log(`Saving credentials to ${credentialsPath}`); - await fs.writeFile(credentialsPath, JSON.stringify(credentials)); + await fs.writeFile(credentialsPath, JSON.stringify({ accessToken })); } private static async read(): Promise { const credentialsPath = path.join(os.homedir(), '.codify', 'credentials.json'); try { const credentialsStr = await fs.readFile(credentialsPath, 'utf8'); - return JSON.parse(credentialsStr); - } catch { + const { accessToken } = JSON.parse(credentialsStr); + + await LoginHelper.verifyProjectJWT(accessToken); + const decoded = decodeJwt(accessToken); + + return { + accessToken, + email: decoded.email as string, + userId: decoded.sub!, + expiry: decoded.exp!, + } + } catch(e) { + console.error(e); return undefined; } } + + private static async verifyProjectJWT(jwt: string) { + return jwtVerify(jwt, PROJECT_JWKS) + } } diff --git a/src/connect/socket-server.ts b/src/connect/socket-server.ts index 695a76c8..1eb30840 100644 --- a/src/connect/socket-server.ts +++ b/src/connect/socket-server.ts @@ -124,7 +124,7 @@ export class SocketServer { private handleClientConnected = (ws: WebSocket) => { const clientId = uuid(); this.mainConnections.set(clientId, ws); - ws.send(JSON.stringify({ key: 'opened', data: { clientId: uuid } })) + ws.send(JSON.stringify({ key: 'opened', data: { clientId } })) ws.on('close', () => { this.mainConnections.delete(clientId); diff --git a/src/orchestrators/connect.ts b/src/orchestrators/connect.ts index d9ca4f50..c912a2a6 100644 --- a/src/orchestrators/connect.ts +++ b/src/orchestrators/connect.ts @@ -6,12 +6,20 @@ import open from 'open'; import { config } from '../config.js'; import router from '../connect/http-routes/router.js'; +import { LoginHelper } from '../connect/login-helper.js'; import { SocketServer } from '../connect/socket-server.js'; +import { LoginOrchestrator } from './login.js'; export class ConnectOrchestrator { static rootCommand: string; - static async run(oclifConfig: Config) { + static async run(oclifConfig: Config, openBrowser = true, onOpen?: (connectionCode: string) => void) { + const login = LoginHelper.get()?.isLoggedIn; + if (!login) { + await LoginOrchestrator.run(); + await LoginHelper.load(); + } + this.rootCommand = oclifConfig.options.root; const connectionSecret = ConnectOrchestrator.tokenGenerate() @@ -31,11 +39,15 @@ export class ConnectOrchestrator { throw error; } - open(`http://localhost:3000/connection/success?code=${connectionSecret}`) - console.log(`Open browser window to store code. + if (openBrowser) { + open(`http://localhost:3000/connection/success?code=${connectionSecret}`) + console.log(`Open browser window to store code. If unsuccessful manually enter the code: ${connectionSecret}`) + } + + onOpen?.(connectionSecret); }); // const wsManager = WsServerManager.init(server, connectionSecret) diff --git a/src/orchestrators/edit.ts b/src/orchestrators/edit.ts new file mode 100644 index 00000000..48bdf2ad --- /dev/null +++ b/src/orchestrators/edit.ts @@ -0,0 +1,30 @@ +import { Config } from '@oclif/core'; +import { randomBytes } from 'node:crypto'; +import open from 'open'; + +import { DashboardApiClient } from '../api/dashboard/index.js'; +import { config } from '../config.js'; +import { ConnectOrchestrator } from './connect.js'; +import { LoginHelper } from '../connect/login-helper.js'; +import { LoginOrchestrator } from './login.js'; + +export class EditOrchestrator { + static rootCommand: string; + + static async run(oclifConfig: Config) { + const login = LoginHelper.get()?.isLoggedIn; + if (!login) { + await LoginOrchestrator.run(); + await LoginHelper.load(); + } + + const defaultDocumentId = await DashboardApiClient.getDefaultDocumentId(); + const url = defaultDocumentId + ? `${config.dashboardUrl}/file/${defaultDocumentId}` + : config.dashboardUrl; + + await ConnectOrchestrator.run(oclifConfig, false, (code) => { + open(`${url}?connection_code=${code}`); + }); + } +} diff --git a/src/orchestrators/login.ts b/src/orchestrators/login.ts index a70e7d79..a92fb19e 100644 --- a/src/orchestrators/login.ts +++ b/src/orchestrators/login.ts @@ -11,15 +11,6 @@ const schema = { properties: { accessToken: { type: 'string', - }, - email: { - type: 'string', - }, - userId: { - type: 'string', - }, - expiry: { - type: 'number', } }, additionalProperties: false, @@ -45,11 +36,12 @@ export class LoginOrchestrator { app.post('/', async (req, res) => { const body = req.body as Credentials; - if (!ajv.validate(schema, body)) { - return res.status(400).send({ message: ajv.errorsText() }) - } + // if (!ajv.validate(schema, body)) { + // console.error('Received invalid credentials', body) + // return res.status(400).send({ message: ajv.errorsText() }) + // } - await LoginHelper.save(body); + await LoginHelper.save(body.accessToken); res.sendStatus(200); resolve(); From cda4885d411a765dad5ae83b0d4d3fe3be9fc013 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 1 Oct 2025 14:58:40 -0400 Subject: [PATCH 22/67] Removed extraneous logs. Added message for codify edit --- src/api/dashboard/index.ts | 9 +++------ src/connect/login-helper.ts | 5 +---- src/orchestrators/connect.ts | 3 --- src/orchestrators/edit.ts | 6 ++++++ 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/api/dashboard/index.ts b/src/api/dashboard/index.ts index cd1b2b39..79ce3986 100644 --- a/src/api/dashboard/index.ts +++ b/src/api/dashboard/index.ts @@ -1,8 +1,7 @@ +import { config } from '../../config.js'; import { LoginHelper } from '../../connect/login-helper.js'; import { CloudDocument } from './types.js'; -const API_BASE_URL = 'http://localhost:3000' - export const DashboardApiClient = { async getDocument(id: string): Promise { const login = LoginHelper.get()?.credentials; @@ -11,7 +10,7 @@ export const DashboardApiClient = { } const res = await fetch( - `${API_BASE_URL}/api/v1/documents/${id}`, + `${config.dashboardUrl}/api/v1/documents/${id}`, { method: 'GET', headers: { 'Content-Type': 'application/json', 'authorization': `Bearer ${login.accessToken}` } }, ); @@ -30,10 +29,8 @@ export const DashboardApiClient = { throw new Error('Not logged in'); } - console.log('Token', login.accessToken); - const res = await fetch( - `${API_BASE_URL}/api/v1/documents/default/id`, + `${config.dashboardUrl}/api/v1/documents/default/id`, { method: 'GET', headers: { 'Content-Type': 'application/json', 'authorization': `Bearer ${login.accessToken}` } }, ); diff --git a/src/connect/login-helper.ts b/src/connect/login-helper.ts index 79d906e7..33d74b34 100644 --- a/src/connect/login-helper.ts +++ b/src/connect/login-helper.ts @@ -29,8 +29,6 @@ export class LoginHelper { } const credentials = await LoginHelper.read(); - - console.log('My credentials', credentials); if (!credentials) { LoginHelper.instance = new LoginHelper(false); return LoginHelper.instance; @@ -70,8 +68,7 @@ export class LoginHelper { userId: decoded.sub!, expiry: decoded.exp!, } - } catch(e) { - console.error(e); + } catch { return undefined; } } diff --git a/src/orchestrators/connect.ts b/src/orchestrators/connect.ts index c912a2a6..5b65207c 100644 --- a/src/orchestrators/connect.ts +++ b/src/orchestrators/connect.ts @@ -50,9 +50,6 @@ ${connectionSecret}`) onOpen?.(connectionSecret); }); - // const wsManager = WsServerManager.init(server, connectionSecret) - // .setDefault(defaultWsHandler) - SocketServer.init(server, connectionSecret); } diff --git a/src/orchestrators/edit.ts b/src/orchestrators/edit.ts index 48bdf2ad..8b4c8ecf 100644 --- a/src/orchestrators/edit.ts +++ b/src/orchestrators/edit.ts @@ -25,6 +25,12 @@ export class EditOrchestrator { await ConnectOrchestrator.run(oclifConfig, false, (code) => { open(`${url}?connection_code=${code}`); + console.log( +`Opening default Codify file: +${url}?connection_code=${code} + +Starting connection. If unsuccessful, manually enter the code: +${code}`) }); } } From 7808a25bf188900ada69190a8af8671cb1218daf Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 1 Oct 2025 23:14:26 -0400 Subject: [PATCH 23/67] Added install beta script. Fixed urls to point to dashboard.codifycli.com. Fixed logins if another process is already using the port. Fixed logins not saving credentials if .codify folder doesn't exist. --- README.md | 2 +- package.json | 2 +- scripts/install-beta.sh | 26 ++++++++++++++++++ src/commands/login.ts | 5 +++- src/connect/login-helper.ts | 5 +++- src/orchestrators/connect.ts | 2 +- src/orchestrators/login.ts | 53 ++++++++++++++++++++++++++---------- 7 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 scripts/install-beta.sh diff --git a/README.md b/README.md index 0edfb295..157f4d6a 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ $ npm install -g codify $ codify COMMAND running command... $ codify (--version) -codify/0.9.0 darwin-arm64 node-v20.19.5 +codify/1.0.0 darwin-arm64 node-v22.19.0 $ codify --help [COMMAND] USAGE $ codify COMMAND diff --git a/package.json b/package.json index 361e2c45..7b7870a1 100644 --- a/package.json +++ b/package.json @@ -141,7 +141,7 @@ "start:dev": "./bin/dev.js", "start:vm": "npm run build && npm run pack:macos && npm run start:vm" }, - "version": "0.9.0", + "version": "1.0.0", "bugs": "https://github.com/kevinwang5658/codify/issues", "keywords": [ "oclif" diff --git a/scripts/install-beta.sh b/scripts/install-beta.sh new file mode 100644 index 00000000..20bbb12a --- /dev/null +++ b/scripts/install-beta.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -e; +# Always clean up tmp dir even if install fails +trap 'rm -rf $TEMP_DIR' EXIT + +if [ $(uname -s) != "Darwin" ]; then + echo "Only macOS systems are currently supported" + exit 1; +fi; + +ARCH=$(uname -m) +TEMP_DIR=$(mktemp -d -t "codify") + +echo "Downloading beta installer..."; +curl -L -o "$TEMP_DIR/codify-installer.pkg" "https://api.codifycli.com/v2/cli/releases/beta/$ARCH/installer"; +echo "Downloaded Codify beta installer"; + +printf "\nRunning installer... (sudo may be required)\n"; +sudo installer -pkg "$TEMP_DIR/codify-installer.pkg" -target /; + +CYAN='\033[0;36m' +END_ESCAPE='\033[0m' + +printf "${CYAN}\nšŸŽ‰ %s šŸŽ‰\n%s${END_ESCAPE}\n" "Successfully installed Codify beta. Type codify --help for a list of commands." "Visit the documentation at https://docs.codifycli.com for more info." + +exit 0; diff --git a/src/commands/login.ts b/src/commands/login.ts index de899b0b..6de67f0c 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,3 +1,5 @@ +import chalk from 'chalk'; + import { BaseCommand } from '../common/base-command.js'; import { LoginOrchestrator } from '../orchestrators/login.js'; @@ -19,7 +21,8 @@ For more information, visit: https://docs.codifycli.com/commands/validate const { flags } = await this.parse(Login) await LoginOrchestrator.run(); + console.log(chalk.green('\nSuccessfully logged in!')) - process.exit(0) + process.exit(0); } } diff --git a/src/connect/login-helper.ts b/src/connect/login-helper.ts index 33d74b34..80f37803 100644 --- a/src/connect/login-helper.ts +++ b/src/connect/login-helper.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { config } from '../config.js'; +import chalk from 'chalk'; interface Credentials { accessToken: string; @@ -49,7 +50,9 @@ export class LoginHelper { static async save(accessToken: string) { const credentialsPath = path.join(os.homedir(), '.codify', 'credentials.json'); - console.log(`Saving credentials to ${credentialsPath}`); + console.log(chalk.green(`Saving credentials to ${credentialsPath}`)); + + await fs.mkdir(path.dirname(credentialsPath), { recursive: true }); await fs.writeFile(credentialsPath, JSON.stringify({ accessToken })); } diff --git a/src/orchestrators/connect.ts b/src/orchestrators/connect.ts index 5b65207c..768ee968 100644 --- a/src/orchestrators/connect.ts +++ b/src/orchestrators/connect.ts @@ -40,7 +40,7 @@ export class ConnectOrchestrator { } if (openBrowser) { - open(`http://localhost:3000/connection/success?code=${connectionSecret}`) + open(`${config.dashboardUrl}/connection/success?code=${connectionSecret}`) console.log(`Open browser window to store code. If unsuccessful manually enter the code: diff --git a/src/orchestrators/login.ts b/src/orchestrators/login.ts index a92fb19e..14fe2232 100644 --- a/src/orchestrators/login.ts +++ b/src/orchestrators/login.ts @@ -1,3 +1,4 @@ +import chalk from 'chalk'; import cors from 'cors'; import express, { json } from 'express'; import open from 'open'; @@ -14,7 +15,7 @@ const schema = { } }, additionalProperties: false, - required: ['accessToken', 'email', 'userId', 'expiry'], + required: ['accessToken'], } interface Credentials { @@ -29,27 +30,49 @@ export class LoginOrchestrator { const app = express(); app.use(cors({ origin: config.corsAllowedOrigins })) - app.use(json()) + app.use(json()); - const [, server] = await Promise.all([ - new Promise((resolve) => { + const server = app.listen(config.loginServerPort, (error) => { + if (error) { + console.error(chalk.red('Something went wrong. Only a single instance of codify login can be run at the same time. Please terminate the other process and try again.')); + process.exit(1); + } + + console.log( +`Opening CLI auth page... +Manually open it here: ${config.dashboardUrl}/auth/cli` + ) + open(`${config.dashboardUrl}/auth/cli`); + }) + + await Promise.race([ + new Promise((resolve, reject) => { app.post('/', async (req, res) => { - const body = req.body as Credentials; + try { + const body = req.body as Credentials; - // if (!ajv.validate(schema, body)) { - // console.error('Received invalid credentials', body) - // return res.status(400).send({ message: ajv.errorsText() }) - // } + if (!ajv.validate(schema, body)) { + console.error(chalk.red('Received invalid credentials. Please submit a support ticket')) + return res.status(400).send({ message: ajv.errorsText() }) + } - await LoginHelper.save(body.accessToken); - res.sendStatus(200); + console.log(chalk.green('\nSuccessfully received sign-in credentials...')) - resolve(); + await LoginHelper.save(body.accessToken); + res.sendStatus(200); + + resolve(); + } catch (error) { + console.error(error); + reject(error); + } }); }), - app.listen(config.loginServerPort, () => { - console.log('Opening CLI auth page...') - open('http://localhost:3000/auth/cli'); + new Promise((resolve) => { + setTimeout(() => { + console.error(chalk.red('Did not receive sign-in credentials in 5 minutes, please re-run the command')); + resolve(); + }, 5 * 60 * 1000); }) ]) From a29e308aba1c53c1accaa5c23ad2a2cdb6b44b7e Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 1 Oct 2025 23:40:21 -0400 Subject: [PATCH 24/67] Fixed login behavior for connect and edit. Added log out command --- src/commands/login.ts | 4 ++-- src/commands/logout.ts | 27 +++++++++++++++++++++++++++ src/connect/login-helper.ts | 30 ++++++++++++++++++++++-------- src/orchestrators/connect.ts | 2 +- src/orchestrators/edit.ts | 4 ++-- 5 files changed, 54 insertions(+), 13 deletions(-) create mode 100644 src/commands/logout.ts diff --git a/src/commands/login.ts b/src/commands/login.ts index 6de67f0c..21bd96c2 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -5,9 +5,9 @@ import { LoginOrchestrator } from '../orchestrators/login.js'; export default class Login extends BaseCommand { static description = - `Validate a codify.jsonc/codify.json/codify.yaml file. + `Logins to codify cloud account -For more information, visit: https://docs.codifycli.com/commands/validate +For more information, visit: https://docs.codifycli.com/commands/login ` static flags = {} diff --git a/src/commands/logout.ts b/src/commands/logout.ts new file mode 100644 index 00000000..3eb9f488 --- /dev/null +++ b/src/commands/logout.ts @@ -0,0 +1,27 @@ +import chalk from 'chalk'; + +import { BaseCommand } from '../common/base-command.js'; +import { LoginOrchestrator } from '../orchestrators/login.js'; +import { LoginHelper } from '../connect/login-helper.js'; + +export default class Login extends BaseCommand { + static description = + `Logout of codify cloud account + +For more information, visit: https://docs.codifycli.com/commands/logout +` + + static flags = {} + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --path=../../import.codify.jsonc', + ] + + public async run(): Promise { + await LoginHelper.logout(); + console.log(chalk.green('\nSuccessfully logged out.')) + + process.exit(0); + } +} diff --git a/src/connect/login-helper.ts b/src/connect/login-helper.ts index 80f37803..dcbc6afa 100644 --- a/src/connect/login-helper.ts +++ b/src/connect/login-helper.ts @@ -54,6 +54,16 @@ export class LoginHelper { await fs.mkdir(path.dirname(credentialsPath), { recursive: true }); await fs.writeFile(credentialsPath, JSON.stringify({ accessToken })); + + this.instance.isLoggedIn = true; + this.instance.credentials = LoginHelper.decodeToken(accessToken); + } + + static async logout() { + try { + const credentialsPath = path.join(os.homedir(), '.codify', 'credentials.json'); + await fs.rm(credentialsPath); + } catch {} } private static async read(): Promise { @@ -63,14 +73,7 @@ export class LoginHelper { const { accessToken } = JSON.parse(credentialsStr); await LoginHelper.verifyProjectJWT(accessToken); - const decoded = decodeJwt(accessToken); - - return { - accessToken, - email: decoded.email as string, - userId: decoded.sub!, - expiry: decoded.exp!, - } + return LoginHelper.decodeToken(accessToken); } catch { return undefined; } @@ -79,4 +82,15 @@ export class LoginHelper { private static async verifyProjectJWT(jwt: string) { return jwtVerify(jwt, PROJECT_JWKS) } + + private static decodeToken(jwt: string): Credentials { + const decoded = decodeJwt(jwt); + + return { + accessToken: jwt, + email: decoded.email as string, + userId: decoded.sub!, + expiry: decoded.exp!, + } + } } diff --git a/src/orchestrators/connect.ts b/src/orchestrators/connect.ts index 768ee968..21260df5 100644 --- a/src/orchestrators/connect.ts +++ b/src/orchestrators/connect.ts @@ -16,8 +16,8 @@ export class ConnectOrchestrator { static async run(oclifConfig: Config, openBrowser = true, onOpen?: (connectionCode: string) => void) { const login = LoginHelper.get()?.isLoggedIn; if (!login) { + console.log('User is not logged in. Attempting to log in...') await LoginOrchestrator.run(); - await LoginHelper.load(); } this.rootCommand = oclifConfig.options.root; diff --git a/src/orchestrators/edit.ts b/src/orchestrators/edit.ts index 8b4c8ecf..9aec0f4c 100644 --- a/src/orchestrators/edit.ts +++ b/src/orchestrators/edit.ts @@ -4,8 +4,8 @@ import open from 'open'; import { DashboardApiClient } from '../api/dashboard/index.js'; import { config } from '../config.js'; -import { ConnectOrchestrator } from './connect.js'; import { LoginHelper } from '../connect/login-helper.js'; +import { ConnectOrchestrator } from './connect.js'; import { LoginOrchestrator } from './login.js'; export class EditOrchestrator { @@ -14,8 +14,8 @@ export class EditOrchestrator { static async run(oclifConfig: Config) { const login = LoginHelper.get()?.isLoggedIn; if (!login) { + console.log('User is not logged in. Attempting to log in...') await LoginOrchestrator.run(); - await LoginHelper.load(); } const defaultDocumentId = await DashboardApiClient.getDefaultDocumentId(); From c0e9a2006caa954aa589c9acd877ccd4cc0b566c Mon Sep 17 00:00:00 2001 From: kevinwang Date: Mon, 20 Oct 2025 21:05:57 -0400 Subject: [PATCH 25/67] Add new refresh command --- src/commands/refresh.ts | 84 +++++++++++++ src/entities/project.ts | 2 +- src/orchestrators/refresh.ts | 232 +++++++++++++++++++++++++++++++++++ 3 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 src/commands/refresh.ts create mode 100644 src/orchestrators/refresh.ts diff --git a/src/commands/refresh.ts b/src/commands/refresh.ts new file mode 100644 index 00000000..7c361a49 --- /dev/null +++ b/src/commands/refresh.ts @@ -0,0 +1,84 @@ +import chalk from 'chalk'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { BaseCommand } from '../common/base-command.js'; +import { ImportOrchestrator } from '../orchestrators/import.js'; +import { ShellUtils } from '../utils/shell.js'; +import { RefreshOrchestrator } from '../orchestrators/refresh.js'; + +export default class Refresh extends BaseCommand { + static strict = false; + static override description = +`Generate Codify configurations from already installed packages. + +Use a space-separated list of arguments to specify the resource types to import. +If a codify.jsonc file already exists, omit arguments to update the file to match the system. + +${chalk.bold('Modes:')} +1. ${chalk.bold('No args:')} If no args are specified and an *.codify.jsonc already exists, Codify +will update the existing file with new changes on the system. + +${chalk.underline('Command:')} +codify import + +2. ${chalk.bold('With args:')} Specify specific resources to import using arguments. Wild card matching is supported +using '*' and '?' (${chalk.italic('Note: in zsh * expands to the current dir and needs to be escaped using \\* or \'*\'')}). +A prompt will be shown if more information is required to complete the import. + +${chalk.underline('Examples:')} +codify import nvm asdf* +codify import \\* (for importing all supported resources) + +The results can be saved in one of three ways: + a. To an existing *.codify.jsonc file + b. To a new file + c. Printed to the console only + +Codify will attempt to smartly insert new configurations while preserving existing spacing and formatting. + +For more information, visit: https://docs.codifycli.com/commands/import` + + static override examples = [ + '<%= config.bin %> <%= command.id %> homebrew nvm asdf', + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> git-clone --path ../my/other/folder', + '<%= config.bin %> <%= command.id %> \\*' + ] + + public async run(): Promise { + const { raw, flags } = await this.parse(Refresh) + + if (flags.path) { + this.log(`Applying Codify from: ${flags.path}`); + } + + const resolvedPath = flags.path ?? '.'; + + const args = raw + .filter((r) => r.type === 'arg') + .map((r) => r.input); + + const cleanedArgs = await this.cleanupZshStarExpansion(args); + + await RefreshOrchestrator.run({ + verbosityLevel: flags.debug ? 3 : 0, + typeIds: cleanedArgs, + path: resolvedPath, + secureMode: flags.secure, + }, this.reporter) + + process.exit(0) + } + + private async cleanupZshStarExpansion(args: string[]): Promise { + const combinedArgs = args.join(' '); + const zshStarExpansion = (await ShellUtils.isZshShell()) + ? (await fs.readdir(process.cwd())).filter((name) => !name.startsWith('.')).join(' ') + : '' + + return combinedArgs + .replaceAll(zshStarExpansion, '*') + .split(' ') + } +} diff --git a/src/entities/project.ts b/src/entities/project.ts index 11ead293..1a1468d8 100644 --- a/src/entities/project.ts +++ b/src/entities/project.ts @@ -66,7 +66,7 @@ ${JSON.stringify(projectConfigs, null, 2)}`); return validate(this.codifyFiles[0]) } - filter(ids: string[]): Project { + filterInPlace(ids: string[]): Project { this.resourceConfigs = this.resourceConfigs.filter((r) => ids.find((id) => r.id.includes(id))); this.stateConfigs = this.stateConfigs?.filter((s) => ids.includes(s.id)) ?? null; diff --git a/src/orchestrators/refresh.ts b/src/orchestrators/refresh.ts new file mode 100644 index 00000000..92cb8ac7 --- /dev/null +++ b/src/orchestrators/refresh.ts @@ -0,0 +1,232 @@ +import { PluginInitOrchestrator } from '../common/initialize-plugins.js'; +import { Project } from '../entities/project.js'; +import { ResourceConfig } from '../entities/resource-config.js'; +import { ResourceInfo } from '../entities/resource-info.js'; +import { ctx, ProcessName, SubProcessName } from '../events/context.js'; +import { FileModificationCalculator } from '../generators/file-modification-calculator.js'; +import { ModificationType } from '../generators/index.js'; +import { FileUpdater } from '../generators/writer.js'; +import { CodifyParser } from '../parser/index.js'; +import { DependencyMap, PluginManager } from '../plugins/plugin-manager.js'; +import { Reporter } from '../ui/reporters/reporter.js'; +import { groupBy, sleep } from '../utils/index.js'; +import { wildCardMatch } from '../utils/wild-card-match.js'; + +export type RefreshResult = { result: ResourceConfig[], errors: string[] } + +export interface RefreshArgs { + typeIds?: string[]; + path: string; + secureMode?: boolean; + verbosityLevel?: number; +} + +export class RefreshOrchestrator { + static async run( + args: RefreshArgs, + reporter: Reporter + ) { + const typeIds = args.typeIds?.filter(Boolean) + ctx.processStarted(ProcessName.IMPORT) + + const initializationResult = await PluginInitOrchestrator.run( + { ...args, allowEmptyProject: true }, + reporter, + ); + const { project } = initializationResult; + + // if ((!typeIds || typeIds.length === 0) && project.isEmpty()) { + // throw new Error('At least one resource [type] must be specified. Ex: "codify refresh homebrew". Or the import command must be run in a directory with a valid codify file') + // } + + const { pluginManager } = initializationResult; + await pluginManager.validate(project); + const importResult = await RefreshOrchestrator.import( + pluginManager, + project.resourceConfigs.filter((r) => !typeIds || typeIds.includes(r.type)) + ); + + ctx.processFinished(ProcessName.IMPORT); + + reporter.displayImportResult(importResult, false); + + const resourceInfoList = await pluginManager.getMultipleResourceInfo( + project.resourceConfigs.map((r) => r.type), + ); + + await RefreshOrchestrator.updateExistingFiles( + reporter, + project, + importResult, + resourceInfoList, + project.codifyFiles[0], + pluginManager, + ); + } + + static async import( + pluginManager: PluginManager, + resources: ResourceConfig[], + ): Promise { + const importedConfigs: ResourceConfig[] = []; + const errors: string[] = []; + + ctx.subprocessStarted(SubProcessName.IMPORT_RESOURCE); + + await Promise.all(resources.map(async (resource) => { + + try { + const response = await pluginManager.importResource(resource.toJson()); + + if (response.result !== null && response.result.length > 0) { + importedConfigs.push(...response + ?.result + ?.map((r) => + // Keep the name on the resource if possible, this makes it easier to identify where the import came from + ResourceConfig.fromJson({ ...r, core: { ...r.core, name: resource.name } }) + ) ?? [] + ); + } else { + errors.push(`Unable to import resource '${resource.type}', resource not found`); + } + } catch (error: any) { + errors.push(error.message ?? error); + } + + })) + + ctx.subprocessFinished(SubProcessName.IMPORT_RESOURCE); + + return { + result: importedConfigs, + errors, + } + } + + private static matchTypeIds(typeIds: string[], validTypeIds: string[]): string[] { + const result: string[] = []; + const unsupportedTypeIds: string[] = []; + + for (const typeId of typeIds) { + if (!typeId.includes('*') && !typeId.includes('?')) { + const matched = validTypeIds.includes(typeId); + if (!matched) { + unsupportedTypeIds.push(typeId); + continue; + } + + result.push(typeId) + continue; + } + + const matched = validTypeIds.filter((valid) => wildCardMatch(valid, typeId)) + if (matched.length === 0) { + unsupportedTypeIds.push(typeId); + continue; + } + + result.push(...matched); + } + + if (unsupportedTypeIds.length > 0) { + throw new Error(`The following resources cannot be imported. No plugins found that support the following types: +${JSON.stringify(unsupportedTypeIds)}`); + } + + return result; + } + + private static async validate(typeIds: string[], project: Project, pluginManager: PluginManager, dependencyMap: DependencyMap): Promise { + project.validateTypeIds(dependencyMap); + + const unsupportedTypeIds = typeIds.filter((type) => !dependencyMap.has(type)); + if (unsupportedTypeIds.length > 0) { + throw new Error(`The following resources cannot be imported. No plugins found that support the following types: +${JSON.stringify(unsupportedTypeIds)}`); + } + } + + private static async updateExistingFiles( + reporter: Reporter, + existingProject: Project, + importResult: RefreshResult, + resourceInfoList: ResourceInfo[], + preferredFile: string, // File to write any new resources (unknown file path) + pluginManager: PluginManager, + ): Promise { + const groupedResults = groupBy(importResult.result, (r) => + existingProject.findSpecific(r.type, r.name)?.sourceMapKey?.split('#')?.[0] ?? 'unknown' + ) + + // New resources exists (they don't belong to any existing files) + if (groupedResults.unknown) { + groupedResults[preferredFile] = [ + ...(groupedResults.unknown ?? []), + ...(groupedResults[preferredFile] ?? []), + ] + delete groupedResults.unknown; + } + + const diffs = await Promise.all(Object.entries(groupedResults).map(async ([filePath, imported]) => { + const existing = await CodifyParser.parse(filePath!); + RefreshOrchestrator.attachResourceInfo(imported, resourceInfoList); + RefreshOrchestrator.attachResourceInfo(existing.resourceConfigs, resourceInfoList); + + const modificationCalculator = new FileModificationCalculator(existing); + const modification = await modificationCalculator.calculate( + imported.map((resource) => ({ + modification: ModificationType.INSERT_OR_UPDATE, + resource + })), + // Handle matching here since we need the plugin to determine if two configs represent the same underlying resource + async (resource, array) => { + const match = await pluginManager.match(resource, array.filter((r) => r.type === resource.type)); + return array.findIndex((i) => i.isDeepEqual(match)); + } + ); + + return { file: filePath!, modification }; + })); + + // No changes to be made + if (diffs.every((d) => d.modification.diff === '')) { + reporter.displayMessage('\nNo changes are needed! Exiting...') + + // Wait for the message to display before we exit + await sleep(100); + return; + } + + reporter.displayFileModifications(diffs); + const shouldSave = await reporter.promptConfirmation('Save the changes?'); + if (!shouldSave) { + reporter.displayMessage('\nSkipping save! Exiting...'); + + // Wait for the message to display before we exit + await sleep(100); + return; + } + + for (const diff of diffs) { + await FileUpdater.write(diff.file, diff.modification.newFile); + } + + reporter.displayMessage('\nšŸŽ‰ Imported completed and saved to file šŸŽ‰'); + + // Wait for the message to display before we exit + await sleep(100); + } + + // We have to attach additional info to the imported configs to make saving easier + private static attachResourceInfo(resources: ResourceConfig[], resourceInfoList: ResourceInfo[]): void { + resources.forEach((resource) => { + const matchedInfo = resourceInfoList.find((info) => info.type === resource.type)!; + if (!matchedInfo) { + throw new Error(`Could not find type ${resource.type} in the resource info`); + } + + resource.attachResourceInfo(matchedInfo); + }) + } +} + From 752d032e15c0a44d6e59ab5bb3ccdfcbdcc54bac Mon Sep 17 00:00:00 2001 From: kevinwang Date: Mon, 20 Oct 2025 21:36:51 -0400 Subject: [PATCH 26/67] Changed the parameterless import to mimic init (auto import everything) --- src/orchestrators/import.ts | 68 +++++++++++++++---------- src/ui/components/default-component.tsx | 4 +- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index 9936c125..0ec714e9 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -44,10 +44,51 @@ export class ImportOrchestrator { } await (!typeIds || typeIds.length === 0 - ? ImportOrchestrator.runExistingProject(reporter, initializationResult) + ? ImportOrchestrator.autoImportAll(reporter, initializationResult) : ImportOrchestrator.runNewImport(typeIds, reporter, initializationResult)); } + static async autoImportAll(reporter: Reporter, initializeResult: InitializationResult) { + const { project, pluginManager, typeIdsToDependenciesMap } = initializeResult; + + ctx.subprocessStarted(SubProcessName.IMPORT_RESOURCE) + const importResults = await Promise.all([...typeIdsToDependenciesMap.keys()].map(async (typeId) => { + try { + return await pluginManager.importResource({ + core: { type: typeId }, + parameters: {} + }, true); + } catch { + return null; + } + })) + + ctx.subprocessFinished(SubProcessName.IMPORT_RESOURCE); + + const flattenedResults = importResults.filter(Boolean).flatMap(p => p?.result).filter(Boolean) + + const userSelectedTypes = await reporter.promptInitResultSelection([...new Set(flattenedResults.map((r) => r!.core.type))]) + ctx.log('Resource types were chosen to be imported.') + + ctx.processFinished(ProcessName.IMPORT); + + const importedResources = flattenedResults.filter((r) => r && userSelectedTypes.includes(r.core.type)) + .map((r) => ResourceConfig.fromJson(r!)); + + const resourceInfoList = await pluginManager.getMultipleResourceInfo( + [...project.resourceConfigs, ...importedResources].map((r) => r.type), + ); + + await ImportOrchestrator.updateExistingFiles( + reporter, + project, + { result: importedResources, errors: [] }, + resourceInfoList, + project.codifyFiles[0], + pluginManager, + ); + } + /** Import new resources. Type ids supplied. This will ask for any required parameters */ static async runNewImport(typeIds: string[], reporter: Reporter, initializeResult: InitializationResult): Promise { const { project, pluginManager, typeIdsToDependenciesMap } = initializeResult; @@ -71,31 +112,6 @@ export class ImportOrchestrator { await ImportOrchestrator.saveResults(reporter, importResult, project, resourceInfoList, pluginManager) } - /** Update an existing project. This will use the existing resources as the parameters (no user input required). */ - static async runExistingProject(reporter: Reporter, initializeResult: InitializationResult): Promise { - const { pluginManager, project } = initializeResult; - - await pluginManager.validate(project); - const importResult = await ImportOrchestrator.import(pluginManager, project.resourceConfigs); - - ctx.processFinished(ProcessName.IMPORT); - - reporter.displayImportResult(importResult, false); - - const resourceInfoList = await pluginManager.getMultipleResourceInfo( - project.resourceConfigs.map((r) => r.type), - ); - - await ImportOrchestrator.updateExistingFiles( - reporter, - project, - importResult, - resourceInfoList, - project.codifyFiles[0], - pluginManager, - ); - } - static async import( pluginManager: PluginManager, resources: ResourceConfig[], diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 0b6354bb..8b21dbd2 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -141,9 +141,9 @@ export function DefaultComponent(props: { { renderStatus === RenderStatus.PROMPT_INIT_RESULT_SELECTION && ( - Codify found the following supported resorces on your system. + Codify found the following supported resources on your system. - Select the resources to import: + Select resources to import: ({ label: o, value: o }))} items={(renderData as string[]).map((o) => ({ label: o, value: o })).sort((a, b) => a.label.localeCompare(b.label))} From fe5fbb7b4529190f56ec29b1bd0fe0bce6d1b9d0 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Tue, 21 Oct 2025 15:49:00 -0400 Subject: [PATCH 27/67] Added refresh method to connect and fixed bugs --- .../http-routes/handlers/create-command.ts | 6 +- .../http-routes/handlers/import-handler.ts | 22 ++--- .../http-routes/handlers/plan-handler.ts | 2 +- .../http-routes/handlers/refresh-handler.ts | 95 +++++++++++++++++++ src/connect/http-routes/router.ts | 2 + src/events/context.ts | 1 + src/orchestrators/import.ts | 5 - src/orchestrators/refresh.ts | 4 +- 8 files changed, 112 insertions(+), 25 deletions(-) create mode 100644 src/connect/http-routes/handlers/refresh-handler.ts diff --git a/src/connect/http-routes/handlers/create-command.ts b/src/connect/http-routes/handlers/create-command.ts index 7fbee02e..cbb1f1d1 100644 --- a/src/connect/http-routes/handlers/create-command.ts +++ b/src/connect/http-routes/handlers/create-command.ts @@ -1,16 +1,16 @@ import { IPty, spawn } from '@homebridge/node-pty-prebuilt-multiarch'; import chalk from 'chalk'; import { Router } from 'express'; +import WebSocket from 'ws'; -import { ConnectOrchestrator } from '../../../orchestrators/connect.js'; import { Session, SocketServer } from '../../socket-server.js'; -import WebSocket from 'ws'; export enum ConnectCommand { TERMINAL = 'terminal', APPLY = 'apply', PLAN = 'plan', - IMPORT = 'import' + IMPORT = 'import', + REFRESH = 'refresh', } interface Params { diff --git a/src/connect/http-routes/handlers/import-handler.ts b/src/connect/http-routes/handlers/import-handler.ts index 99c27c49..36dcebd5 100644 --- a/src/connect/http-routes/handlers/import-handler.ts +++ b/src/connect/http-routes/handlers/import-handler.ts @@ -12,9 +12,8 @@ import { Session, SocketServer } from '../../socket-server.js'; import { ConnectCommand, createCommandHandler } from './create-command.js'; enum ImportType { - REFRESH = 'refresh', - NEW_RESOURCES = 'new_resources', - NEW_ALL = 'new_all', + IMPORT = 'import', + IMPORT_SPECIFIC = 'import_specific', } const validator = ajv.compile(ConfigFileSchema); @@ -30,8 +29,8 @@ export function importHandler() { throw new Error('Unable to parse import type'); } - if (type === ImportType.NEW_RESOURCES && (!resourceTypes || Array.isArray(resourceTypes))) { - throw new Error('For new resources import type, a list of resource types must be provided'); + if (type === ImportType.IMPORT_SPECIFIC && (!resourceTypes || Array.isArray(resourceTypes))) { + throw new Error('For import specific, a list of resource types must be provided'); } if (!validator(codifyConfig)) { @@ -46,17 +45,12 @@ export function importHandler() { let args = ''; switch (type as ImportType) { - case ImportType.REFRESH: { - break; - } - - case ImportType.NEW_RESOURCES: { - args = (resourceTypes as string[]).join(' '); + case ImportType.IMPORT: { break; } - case ImportType.NEW_ALL: { - args = '*' + case ImportType.IMPORT_SPECIFIC: { + args = (resourceTypes as string[]).join(' '); break; } } @@ -94,7 +88,7 @@ export function importHandler() { } return createCommandHandler({ - name: ConnectCommand.APPLY, + name: ConnectCommand.IMPORT, spawnCommand, onExit }); diff --git a/src/connect/http-routes/handlers/plan-handler.ts b/src/connect/http-routes/handlers/plan-handler.ts index c195f7ef..439781ef 100644 --- a/src/connect/http-routes/handlers/plan-handler.ts +++ b/src/connect/http-routes/handlers/plan-handler.ts @@ -45,7 +45,7 @@ export function planHandler() { } return createCommandHandler({ - name: ConnectCommand.APPLY, + name: ConnectCommand.PLAN, spawnCommand, onExit }); diff --git a/src/connect/http-routes/handlers/refresh-handler.ts b/src/connect/http-routes/handlers/refresh-handler.ts new file mode 100644 index 00000000..ce4bbe54 --- /dev/null +++ b/src/connect/http-routes/handlers/refresh-handler.ts @@ -0,0 +1,95 @@ +import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; +import { ConfigFileSchema } from 'codify-schemas'; +import { diffChars } from 'diff'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { WebSocket } from 'ws'; + +import { ConnectOrchestrator } from '../../../orchestrators/connect.js'; +import { ajv } from '../../../utils/ajv.js'; +import { Session, SocketServer } from '../../socket-server.js'; +import { ConnectCommand, createCommandHandler } from './create-command.js'; + +enum RefreshType { + REFRESH = 'refresh', + REFRESH_SPECIFIC = 'refresh_specific' +} + +const validator = ajv.compile(ConfigFileSchema); + +export function refreshHandler() { + const spawnCommand = async (body: Record, ws: WebSocket, session: Session) => { + const { config: codifyConfig, type, resourceTypes } = body; + if (!codifyConfig) { + throw new Error('Unable to parse codify config'); + } + + if (!type || !Object.values(RefreshType).includes(type as RefreshType)) { + throw new Error('Unable to parse import type'); + } + + if (type === RefreshType.REFRESH_SPECIFIC && (!resourceTypes || Array.isArray(resourceTypes))) { + throw new Error('For refresh specific, a list of resource types must be provided'); + } + + if (!validator(codifyConfig)) { + throw new Error('Invalid codify config'); + } + + const tmpDir = await fs.mkdtemp(os.tmpdir()); + const filePath = path.join(tmpDir, 'codify.jsonc'); + await fs.writeFile(filePath, JSON.stringify(codifyConfig, null, 2)); + session.additionalData.filePath = filePath; + session.additionalData.existingFile = codifyConfig; + + let args = ''; + switch (type as RefreshType) { + case RefreshType.REFRESH: { + break; + } + + case RefreshType.REFRESH_SPECIFIC: { + args = (resourceTypes as string[]).join(' '); + break; + } + } + + return spawn('zsh', ['-c', `${ConnectOrchestrator.rootCommand} refresh ${args} -p ${filePath}`], { + name: 'xterm-color', + cols: 80, + rows: 30, + cwd: process.env.HOME, + env: process.env + }); + } + + const onExit = async (exitCode: number, ws: WebSocket, session: Session) => { + if (session.additionalData.filePath) { + const updatedFile = await fs.readFile(session.additionalData.filePath as string, 'utf8') + + // Changes were found + if (diffChars(updatedFile, session.additionalData.existingFile as string).length > 0) { + console.log('Writing imported changes to Codify dashboard'); + + const ws = SocketServer.get().getMainConnection(session.clientId); + if (!ws) { + throw new Error(`Unable to find client for clientId ${session.clientId}`); + } + + ws.send(JSON.stringify({ key: 'new_import', data: { + updated: updatedFile, + } })) + } + + + await fs.rm(session.additionalData.filePath as string, { recursive: true, force: true }); + } + } + + return createCommandHandler({ + name: ConnectCommand.REFRESH, + spawnCommand, + onExit + }); +} diff --git a/src/connect/http-routes/router.ts b/src/connect/http-routes/router.ts index c5e4c0fb..b4b20e4c 100644 --- a/src/connect/http-routes/router.ts +++ b/src/connect/http-routes/router.ts @@ -4,6 +4,7 @@ import { applyHandler } from './handlers/apply-handler.js'; import { importHandler } from './handlers/import-handler.js'; import defaultHandler from './handlers/index.js'; import { planHandler } from './handlers/plan-handler.js'; +import { refreshHandler } from './handlers/refresh-handler.js'; import { terminalHandler } from './handlers/terminal-handler.js'; const router = Router(); @@ -12,6 +13,7 @@ router.use('/', defaultHandler); router.use('/apply', applyHandler()); router.use('/plan', planHandler()) router.use('/import', importHandler()); +router.use('/refresh', refreshHandler()); router.use('/terminal', terminalHandler()); export default router; diff --git a/src/events/context.ts b/src/events/context.ts index 09ba5427..ae0a891d 100644 --- a/src/events/context.ts +++ b/src/events/context.ts @@ -24,6 +24,7 @@ export enum ProcessName { PLAN = 'plan', DESTROY = 'destroy', IMPORT = 'import', + REFRESH = 'refresh', INIT = 'init', } diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index 0ec714e9..558bed47 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -37,11 +37,6 @@ export class ImportOrchestrator { { ...args, allowEmptyProject: true }, reporter ); - const { project } = initializationResult; - - if ((!typeIds || typeIds.length === 0) && project.isEmpty()) { - throw new Error('At least one resource [type] must be specified. Ex: "codify import homebrew". Or the import command must be run in a directory with a valid codify file') - } await (!typeIds || typeIds.length === 0 ? ImportOrchestrator.autoImportAll(reporter, initializationResult) diff --git a/src/orchestrators/refresh.ts b/src/orchestrators/refresh.ts index 92cb8ac7..4bdf65cc 100644 --- a/src/orchestrators/refresh.ts +++ b/src/orchestrators/refresh.ts @@ -27,7 +27,7 @@ export class RefreshOrchestrator { reporter: Reporter ) { const typeIds = args.typeIds?.filter(Boolean) - ctx.processStarted(ProcessName.IMPORT) + ctx.processStarted(ProcessName.REFRESH) const initializationResult = await PluginInitOrchestrator.run( { ...args, allowEmptyProject: true }, @@ -46,7 +46,7 @@ export class RefreshOrchestrator { project.resourceConfigs.filter((r) => !typeIds || typeIds.includes(r.type)) ); - ctx.processFinished(ProcessName.IMPORT); + ctx.processFinished(ProcessName.REFRESH); reporter.displayImportResult(importResult, false); From dc2decfc5c90c2ca1ce8feac63cdf816589d328f Mon Sep 17 00:00:00 2001 From: kevinwang Date: Tue, 21 Oct 2025 16:08:51 -0400 Subject: [PATCH 28/67] Fixed API calls to parse error messages as non-json --- src/api/dashboard/index.ts | 11 ++++++----- src/api/index.ts | 5 +++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/api/dashboard/index.ts b/src/api/dashboard/index.ts index 79ce3986..4fb7feeb 100644 --- a/src/api/dashboard/index.ts +++ b/src/api/dashboard/index.ts @@ -15,12 +15,12 @@ export const DashboardApiClient = { ); if (!res.ok) { - throw new Error(`Error fetching document: ${res.statusText}`); + const message = await res.text(); + throw new Error(message); } const json = await res.json(); - - return json; + return json.defaultDocumentId; }, async getDefaultDocumentId(): Promise { @@ -34,11 +34,12 @@ export const DashboardApiClient = { { method: 'GET', headers: { 'Content-Type': 'application/json', 'authorization': `Bearer ${login.accessToken}` } }, ); - const json = await res.json(); if (!res.ok) { - throw new Error(JSON.stringify(json, null, 2)); + const message = await res.text(); + throw new Error(message); } + const json = await res.json(); return json.defaultDocumentId; }, diff --git a/src/api/index.ts b/src/api/index.ts index 98098bf7..cb806c54 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -16,11 +16,12 @@ export const ApiClient = { { method: 'POST', body, headers: { 'Content-Type': 'application/json' } } ); - const json = await res.json(); if (!res.ok) { - throw new Error(JSON.stringify(json, null, 2)); + const message = await res.text(); + throw new Error(message); } + const json = await res.json(); return json.results as unknown as PluginSearchResult; }, From 24a1b818bd6de0e92ec97c4cb054e6092f915f6f Mon Sep 17 00:00:00 2001 From: kevinwang Date: Tue, 21 Oct 2025 20:46:29 -0400 Subject: [PATCH 29/67] Fixed bugs: - Check for login expiration was in milliseconds (should be seconds) - The proper node binary was not used by connect handlers - Lack of refresh command name + Deploy script --- package.json | 3 +- .../http-routes/handlers/apply-handler.ts | 2 +- .../http-routes/handlers/import-handler.ts | 2 +- .../http-routes/handlers/plan-handler.ts | 2 +- .../http-routes/handlers/refresh-handler.ts | 2 +- src/connect/login-helper.ts | 3 +- src/orchestrators/connect.ts | 6 ++- src/orchestrators/login.ts | 42 ++++++++----------- src/ui/reporters/default-reporter.tsx | 1 + 9 files changed, 30 insertions(+), 33 deletions(-) diff --git a/package.json b/package.json index 7b7870a1..34f8000a 100644 --- a/package.json +++ b/package.json @@ -139,7 +139,8 @@ "test": "vitest", "version": "oclif readme && git add README.md", "start:dev": "./bin/dev.js", - "start:vm": "npm run build && npm run pack:macos && npm run start:vm" + "start:vm": "npm run build && npm run pack:macos && npm run start:vm", + "deploy": "npm run pkg && npm run notarize && npm run upload" }, "version": "1.0.0", "bugs": "https://github.com/kevinwang5658/codify/issues", diff --git a/src/connect/http-routes/handlers/apply-handler.ts b/src/connect/http-routes/handlers/apply-handler.ts index b2f92634..96ab646f 100644 --- a/src/connect/http-routes/handlers/apply-handler.ts +++ b/src/connect/http-routes/handlers/apply-handler.ts @@ -29,7 +29,7 @@ export function applyHandler() { session.additionalData.filePath = filePath; - return spawn('zsh', ['-c', `${ConnectOrchestrator.rootCommand} apply -p ${filePath}`], { + return spawn('zsh', ['-c', `${ConnectOrchestrator.nodeBinary} ${ConnectOrchestrator.rootCommand} apply -p ${filePath}`], { name: 'xterm-color', cols: 80, rows: 30, diff --git a/src/connect/http-routes/handlers/import-handler.ts b/src/connect/http-routes/handlers/import-handler.ts index 36dcebd5..06224962 100644 --- a/src/connect/http-routes/handlers/import-handler.ts +++ b/src/connect/http-routes/handlers/import-handler.ts @@ -55,7 +55,7 @@ export function importHandler() { } } - return spawn('zsh', ['-c', `${ConnectOrchestrator.rootCommand} import ${args} -p ${filePath}`], { + return spawn('zsh', ['-c', `${ConnectOrchestrator.nodeBinary} ${ConnectOrchestrator.rootCommand} import ${args} -p ${filePath}`], { name: 'xterm-color', cols: 80, rows: 30, diff --git a/src/connect/http-routes/handlers/plan-handler.ts b/src/connect/http-routes/handlers/plan-handler.ts index 439781ef..0066de4d 100644 --- a/src/connect/http-routes/handlers/plan-handler.ts +++ b/src/connect/http-routes/handlers/plan-handler.ts @@ -29,7 +29,7 @@ export function planHandler() { session.additionalData.filePath = filePath; - return spawn('zsh', ['-c', `${ConnectOrchestrator.rootCommand} plan -p ${filePath}`], { + return spawn('zsh', ['-c', `${ConnectOrchestrator.nodeBinary} ${ConnectOrchestrator.rootCommand} plan -p ${filePath}`], { name: 'xterm-color', cols: 80, rows: 30, diff --git a/src/connect/http-routes/handlers/refresh-handler.ts b/src/connect/http-routes/handlers/refresh-handler.ts index ce4bbe54..4b19922a 100644 --- a/src/connect/http-routes/handlers/refresh-handler.ts +++ b/src/connect/http-routes/handlers/refresh-handler.ts @@ -55,7 +55,7 @@ export function refreshHandler() { } } - return spawn('zsh', ['-c', `${ConnectOrchestrator.rootCommand} refresh ${args} -p ${filePath}`], { + return spawn('zsh', ['-c', `${ConnectOrchestrator.nodeBinary} ${ConnectOrchestrator.rootCommand} refresh ${args} -p ${filePath}`], { name: 'xterm-color', cols: 80, rows: 30, diff --git a/src/connect/login-helper.ts b/src/connect/login-helper.ts index dcbc6afa..5de0be29 100644 --- a/src/connect/login-helper.ts +++ b/src/connect/login-helper.ts @@ -35,7 +35,8 @@ export class LoginHelper { return LoginHelper.instance; } - if (new Date(credentials.expiry).getTime() < Date.now()) { + // Expiry dates are in seconds, Date.now() is in milliseconds + if (new Date(credentials.expiry).getTime() < (Date.now() / 1000)) { LoginHelper.instance = new LoginHelper(false); return LoginHelper.instance; } diff --git a/src/orchestrators/connect.ts b/src/orchestrators/connect.ts index 21260df5..e58aed7a 100644 --- a/src/orchestrators/connect.ts +++ b/src/orchestrators/connect.ts @@ -12,6 +12,7 @@ import { LoginOrchestrator } from './login.js'; export class ConnectOrchestrator { static rootCommand: string; + static nodeBinary: string; static async run(oclifConfig: Config, openBrowser = true, onOpen?: (connectionCode: string) => void) { const login = LoginHelper.get()?.isLoggedIn; @@ -21,14 +22,15 @@ export class ConnectOrchestrator { } this.rootCommand = oclifConfig.options.root; + this.nodeBinary = process.execPath; const connectionSecret = ConnectOrchestrator.tokenGenerate() const app = express(); - + app.use(cors({ origin: config.corsAllowedOrigins })) app.use(json()) app.use(router); - + const server = app.listen(config.connectServerPort, (error) => { if (error) { if (error.message.includes('EADDRINUSE')) { diff --git a/src/orchestrators/login.ts b/src/orchestrators/login.ts index 14fe2232..805b7273 100644 --- a/src/orchestrators/login.ts +++ b/src/orchestrators/login.ts @@ -45,36 +45,28 @@ Manually open it here: ${config.dashboardUrl}/auth/cli` open(`${config.dashboardUrl}/auth/cli`); }) - await Promise.race([ - new Promise((resolve, reject) => { - app.post('/', async (req, res) => { - try { - const body = req.body as Credentials; + await new Promise((resolve, reject) => { + app.post('/', async (req, res) => { + try { + const body = req.body as Credentials; - if (!ajv.validate(schema, body)) { - console.error(chalk.red('Received invalid credentials. Please submit a support ticket')) - return res.status(400).send({ message: ajv.errorsText() }) - } + if (!ajv.validate(schema, body)) { + console.error(chalk.red('Received invalid credentials. Please submit a support ticket')) + return res.status(400).send({ message: ajv.errorsText() }) + } - console.log(chalk.green('\nSuccessfully received sign-in credentials...')) + console.log(chalk.green('\nSuccessfully received sign-in credentials...')) - await LoginHelper.save(body.accessToken); - res.sendStatus(200); + await LoginHelper.save(body.accessToken); + res.sendStatus(200); - resolve(); - } catch (error) { - console.error(error); - reject(error); - } - }); - }), - new Promise((resolve) => { - setTimeout(() => { - console.error(chalk.red('Did not receive sign-in credentials in 5 minutes, please re-run the command')); resolve(); - }, 5 * 60 * 1000); - }) - ]) + } catch (error) { + console.error(error); + reject(error); + } + }); + }) server.close(() => {}); } diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index 2dcc04e4..b60658fe 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -22,6 +22,7 @@ const ProgressLabelMapping = { [ProcessName.APPLY]: 'Codify apply', [ProcessName.PLAN]: 'Codify plan', [ProcessName.DESTROY]: 'Codify destroy', + [ProcessName.REFRESH]: 'Codify refresh', [ProcessName.IMPORT]: 'Codify import', [ProcessName.INIT]: 'Codify init', [SubProcessName.APPLYING_RESOURCE]: 'Applying resource', From 46c7a71a7f3ef00746456d2d8955f6755ee127ea Mon Sep 17 00:00:00 2001 From: kevinwang Date: Tue, 21 Oct 2025 21:38:39 -0400 Subject: [PATCH 30/67] Added --updateExisting flag for import command. Made refresh orchestrator empty and reliant on the import orchestrator. Refactored save file type to an individual function --- src/commands/import.ts | 9 +- .../http-routes/handlers/import-handler.ts | 2 +- src/entities/project.ts | 4 + src/orchestrators/import.ts | 225 ++++++++++-------- src/orchestrators/refresh.ts | 188 +-------------- 5 files changed, 149 insertions(+), 279 deletions(-) diff --git a/src/commands/import.ts b/src/commands/import.ts index deec9a0b..0b4f253e 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -1,6 +1,6 @@ +import { Flags } from '@oclif/core'; import chalk from 'chalk'; import fs from 'node:fs/promises'; -import path from 'node:path'; import { BaseCommand } from '../common/base-command.js'; import { ImportOrchestrator } from '../orchestrators/import.js'; @@ -45,6 +45,12 @@ For more information, visit: https://docs.codifycli.com/commands/import` '<%= config.bin %> <%= command.id %> \\*' ] + static override flags = { + 'updateExisting': Flags.boolean({ + description: 'Force the CLI to try to update an existing file instead of prompting the user with the option of creating a new file', + }), + } + public async run(): Promise { const { raw, flags } = await this.parse(Import) @@ -65,6 +71,7 @@ For more information, visit: https://docs.codifycli.com/commands/import` typeIds: cleanedArgs, path: resolvedPath, secureMode: flags.secure, + updateExisting: flags.updateExisting, }, this.reporter) process.exit(0) diff --git a/src/connect/http-routes/handlers/import-handler.ts b/src/connect/http-routes/handlers/import-handler.ts index 06224962..ce534d7e 100644 --- a/src/connect/http-routes/handlers/import-handler.ts +++ b/src/connect/http-routes/handlers/import-handler.ts @@ -55,7 +55,7 @@ export function importHandler() { } } - return spawn('zsh', ['-c', `${ConnectOrchestrator.nodeBinary} ${ConnectOrchestrator.rootCommand} import ${args} -p ${filePath}`], { + return spawn('zsh', ['-c', `${ConnectOrchestrator.nodeBinary} ${ConnectOrchestrator.rootCommand} import ${args} -p ${filePath} --updateExisting`], { name: 'xterm-color', cols: 80, rows: 30, diff --git a/src/entities/project.ts b/src/entities/project.ts index 1a1468d8..81bff0a4 100644 --- a/src/entities/project.ts +++ b/src/entities/project.ts @@ -57,6 +57,10 @@ ${JSON.stringify(projectConfigs, null, 2)}`); return this.resourceConfigs.length === 0; } + exists(): boolean { + return this.codifyFiles.length > 0; + } + isStateful(): boolean { return this.stateConfigs !== null && this.stateConfigs !== undefined && this.stateConfigs.length > 0; } diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index 558bed47..2c54fbf8 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -21,10 +21,17 @@ export type ImportResult = { result: ResourceConfig[], errors: string[] } export interface ImportArgs { typeIds?: string[]; path: string; + updateExisting?: boolean; secureMode?: boolean; verbosityLevel?: number; } +enum SaveType { + EXISTING, + NEW, + NONE +} + export class ImportOrchestrator { static async run( args: ImportArgs, @@ -39,11 +46,11 @@ export class ImportOrchestrator { ); await (!typeIds || typeIds.length === 0 - ? ImportOrchestrator.autoImportAll(reporter, initializationResult) - : ImportOrchestrator.runNewImport(typeIds, reporter, initializationResult)); + ? ImportOrchestrator.autoImportAll(reporter, initializationResult, args) + : ImportOrchestrator.runNewImport(typeIds, reporter, initializationResult, args)); } - static async autoImportAll(reporter: Reporter, initializeResult: InitializationResult) { + static async autoImportAll(reporter: Reporter, initializeResult: InitializationResult, args: ImportArgs) { const { project, pluginManager, typeIdsToDependenciesMap } = initializeResult; ctx.subprocessStarted(SubProcessName.IMPORT_RESOURCE) @@ -74,18 +81,18 @@ export class ImportOrchestrator { [...project.resourceConfigs, ...importedResources].map((r) => r.type), ); - await ImportOrchestrator.updateExistingFiles( + await ImportOrchestrator.saveResults( reporter, - project, { result: importedResources, errors: [] }, + project, resourceInfoList, - project.codifyFiles[0], pluginManager, - ); + args + ) } /** Import new resources. Type ids supplied. This will ask for any required parameters */ - static async runNewImport(typeIds: string[], reporter: Reporter, initializeResult: InitializationResult): Promise { + static async runNewImport(typeIds: string[], reporter: Reporter, initializeResult: InitializationResult, args: ImportArgs): Promise { const { project, pluginManager, typeIdsToDependenciesMap } = initializeResult; const matchedTypes = this.matchTypeIds(typeIds, [...typeIdsToDependenciesMap.keys()]) @@ -104,7 +111,7 @@ export class ImportOrchestrator { resourceInfoList.push(...(await pluginManager.getMultipleResourceInfo( project.resourceConfigs.map((r) => r.type) ))); - await ImportOrchestrator.saveResults(reporter, importResult, project, resourceInfoList, pluginManager) + await ImportOrchestrator.saveResults(reporter, importResult, project, resourceInfoList, pluginManager, args) } static async import( @@ -146,98 +153,20 @@ export class ImportOrchestrator { } } - private static matchTypeIds(typeIds: string[], validTypeIds: string[]): string[] { - const result: string[] = []; - const unsupportedTypeIds: string[] = []; - - for (const typeId of typeIds) { - if (!typeId.includes('*') && !typeId.includes('?')) { - const matched = validTypeIds.includes(typeId); - if (!matched) { - unsupportedTypeIds.push(typeId); - continue; - } - - result.push(typeId) - continue; - } - - const matched = validTypeIds.filter((valid) => wildCardMatch(valid, typeId)) - if (matched.length === 0) { - unsupportedTypeIds.push(typeId); - continue; - } - - result.push(...matched); - } - - if (unsupportedTypeIds.length > 0) { - throw new Error(`The following resources cannot be imported. No plugins found that support the following types: -${JSON.stringify(unsupportedTypeIds)}`); - } - - return result; - } - - private static async validate(typeIds: string[], project: Project, pluginManager: PluginManager, dependencyMap: DependencyMap): Promise { - project.validateTypeIds(dependencyMap); - - const unsupportedTypeIds = typeIds.filter((type) => !dependencyMap.has(type)); - if (unsupportedTypeIds.length > 0) { - throw new Error(`The following resources cannot be imported. No plugins found that support the following types: -${JSON.stringify(unsupportedTypeIds)}`); - } - } - - private static async getImportParameters(reporter: Reporter, project: Project, resourceInfoList: ResourceInfo[]): Promise> { - // Figure out which resources we need to prompt the user for additional info (based on the resource info) - const [noPrompt, askPrompt] = resourceInfoList.reduce((result, info) => { - info.getRequiredParameters().length === 0 ? result[0].push(info) : result[1].push(info); - return result; - }, [[], []]) - - askPrompt.forEach((info) => { - const matchedResources = project.findAll(info.type); - if (matchedResources.length > 0) { - info.attachDefaultValues(matchedResources[0]); - } - }) - - if (askPrompt.length > 0) { - await reporter.displayImportWarning(askPrompt.map((r) => r.type), noPrompt.map((r) => r.type)); - } - - const userSupplied = await reporter.promptUserForValues(askPrompt, PromptType.IMPORT); - - return [ - ...noPrompt.map((info) => new ResourceConfig({ type: info.type })), - ...userSupplied - ] - } - - private static async saveResults( + static async saveResults( reporter: Reporter, importResult: ImportResult, project: Project, resourceInfoList: ResourceInfo[], pluginManager: PluginManager, + args: ImportArgs, ): Promise { - const projectExists = !project.isEmpty(); const multipleCodifyFiles = project.codifyFiles.length > 1; - const promptResult = await reporter.promptOptions( - '\nDo you want to save the results?', - [ - projectExists ? - multipleCodifyFiles ? 'Update existing files' : `Update existing file (${project.codifyFiles})` - : undefined, - 'In a new file', - 'No' - ].filter(Boolean) as string[] - ) + const saveType = await ImportOrchestrator.getSaveType(reporter, project, args); // Update an existing file - if (projectExists && promptResult === 0) { + if (saveType === SaveType.EXISTING) { const file = multipleCodifyFiles ? project.codifyFiles[await reporter.promptOptions('\nIf new resources are added, where to write them?', project.codifyFiles)] : project.codifyFiles[0]; @@ -246,7 +175,7 @@ ${JSON.stringify(unsupportedTypeIds)}`); } // Write to a new file - if ((!projectExists && promptResult === 0) || (projectExists && promptResult === 1)) { + if (saveType === SaveType.NEW) { const newFileName = await ImportOrchestrator.generateNewImportFileName(); await ImportOrchestrator.saveNewFile(reporter, newFileName, importResult); return; @@ -259,7 +188,7 @@ ${JSON.stringify(unsupportedTypeIds)}`); await sleep(100); } - private static async updateExistingFiles( + static async updateExistingFiles( reporter: Reporter, existingProject: Project, importResult: ImportResult, @@ -330,6 +259,114 @@ ${JSON.stringify(unsupportedTypeIds)}`); await sleep(100); } + private static matchTypeIds(typeIds: string[], validTypeIds: string[]): string[] { + const result: string[] = []; + const unsupportedTypeIds: string[] = []; + + for (const typeId of typeIds) { + if (!typeId.includes('*') && !typeId.includes('?')) { + const matched = validTypeIds.includes(typeId); + if (!matched) { + unsupportedTypeIds.push(typeId); + continue; + } + + result.push(typeId) + continue; + } + + const matched = validTypeIds.filter((valid) => wildCardMatch(valid, typeId)) + if (matched.length === 0) { + unsupportedTypeIds.push(typeId); + continue; + } + + result.push(...matched); + } + + if (unsupportedTypeIds.length > 0) { + throw new Error(`The following resources cannot be imported. No plugins found that support the following types: +${JSON.stringify(unsupportedTypeIds)}`); + } + + return result; + } + + private static async validate(typeIds: string[], project: Project, pluginManager: PluginManager, dependencyMap: DependencyMap): Promise { + project.validateTypeIds(dependencyMap); + + const unsupportedTypeIds = typeIds.filter((type) => !dependencyMap.has(type)); + if (unsupportedTypeIds.length > 0) { + throw new Error(`The following resources cannot be imported. No plugins found that support the following types: +${JSON.stringify(unsupportedTypeIds)}`); + } + } + + private static async getImportParameters(reporter: Reporter, project: Project, resourceInfoList: ResourceInfo[]): Promise> { + // Figure out which resources we need to prompt the user for additional info (based on the resource info) + const [noPrompt, askPrompt] = resourceInfoList.reduce((result, info) => { + info.getRequiredParameters().length === 0 ? result[0].push(info) : result[1].push(info); + return result; + }, [[], []]) + + askPrompt.forEach((info) => { + const matchedResources = project.findAll(info.type); + if (matchedResources.length > 0) { + info.attachDefaultValues(matchedResources[0]); + } + }) + + if (askPrompt.length > 0) { + await reporter.displayImportWarning(askPrompt.map((r) => r.type), noPrompt.map((r) => r.type)); + } + + const userSupplied = await reporter.promptUserForValues(askPrompt, PromptType.IMPORT); + + return [ + ...noPrompt.map((info) => new ResourceConfig({ type: info.type })), + ...userSupplied + ] + } + + private static async getSaveType( + reporter: Reporter, + project: Project, + args: ImportArgs, + ): Promise { + const projectExists = project.exists(); + const multipleCodifyFiles = project.codifyFiles.length > 1; + + if (args.updateExisting && projectExists) { + return SaveType.EXISTING; + } + + const promptResult = await reporter.promptOptions( + '\nDo you want to save the results?', + [ + projectExists ? + multipleCodifyFiles ? 'Update existing files' : `Update existing file (${project.codifyFiles})` + : undefined, + 'In a new file', + 'No' + ].filter(Boolean) as string[] + ); + + if (projectExists) { + switch (promptResult) { + case 0: { return SaveType.EXISTING; } + case 1: { return SaveType.NEW; } + case 2: { return SaveType.NONE; } + } + } else { + switch (promptResult) { + case 0: { return SaveType.NEW; } + case 1: { return SaveType.NONE; } + } + } + + throw new Error('Unexpected response from prompt'); + } + private static async saveNewFile(reporter: Reporter, filePath: string, importResult: ImportResult): Promise { const newFile = JSON.stringify(importResult.result.map((r) => r.raw), null, 2); const diff = prettyFormatFileDiff('', newFile); @@ -363,7 +400,7 @@ ${JSON.stringify(unsupportedTypeIds)}`); let fileName = path.join(folderPath, 'import.codify.jsonc') let counter = 1; - while(true) { + while (true) { if (!(await FileUtils.fileExists(fileName))) { return fileName; } diff --git a/src/orchestrators/refresh.ts b/src/orchestrators/refresh.ts index 4bdf65cc..19f9a5ca 100644 --- a/src/orchestrators/refresh.ts +++ b/src/orchestrators/refresh.ts @@ -1,16 +1,8 @@ import { PluginInitOrchestrator } from '../common/initialize-plugins.js'; -import { Project } from '../entities/project.js'; import { ResourceConfig } from '../entities/resource-config.js'; -import { ResourceInfo } from '../entities/resource-info.js'; -import { ctx, ProcessName, SubProcessName } from '../events/context.js'; -import { FileModificationCalculator } from '../generators/file-modification-calculator.js'; -import { ModificationType } from '../generators/index.js'; -import { FileUpdater } from '../generators/writer.js'; -import { CodifyParser } from '../parser/index.js'; -import { DependencyMap, PluginManager } from '../plugins/plugin-manager.js'; +import { ProcessName, ctx } from '../events/context.js'; import { Reporter } from '../ui/reporters/reporter.js'; -import { groupBy, sleep } from '../utils/index.js'; -import { wildCardMatch } from '../utils/wild-card-match.js'; +import { ImportOrchestrator } from './import.js'; export type RefreshResult = { result: ResourceConfig[], errors: string[] } @@ -33,15 +25,10 @@ export class RefreshOrchestrator { { ...args, allowEmptyProject: true }, reporter, ); - const { project } = initializationResult; + const { project, pluginManager } = initializationResult; - // if ((!typeIds || typeIds.length === 0) && project.isEmpty()) { - // throw new Error('At least one resource [type] must be specified. Ex: "codify refresh homebrew". Or the import command must be run in a directory with a valid codify file') - // } - - const { pluginManager } = initializationResult; await pluginManager.validate(project); - const importResult = await RefreshOrchestrator.import( + const importResult = await ImportOrchestrator.import( pluginManager, project.resourceConfigs.filter((r) => !typeIds || typeIds.includes(r.type)) ); @@ -54,7 +41,7 @@ export class RefreshOrchestrator { project.resourceConfigs.map((r) => r.type), ); - await RefreshOrchestrator.updateExistingFiles( + await ImportOrchestrator.updateExistingFiles( reporter, project, importResult, @@ -63,170 +50,5 @@ export class RefreshOrchestrator { pluginManager, ); } - - static async import( - pluginManager: PluginManager, - resources: ResourceConfig[], - ): Promise { - const importedConfigs: ResourceConfig[] = []; - const errors: string[] = []; - - ctx.subprocessStarted(SubProcessName.IMPORT_RESOURCE); - - await Promise.all(resources.map(async (resource) => { - - try { - const response = await pluginManager.importResource(resource.toJson()); - - if (response.result !== null && response.result.length > 0) { - importedConfigs.push(...response - ?.result - ?.map((r) => - // Keep the name on the resource if possible, this makes it easier to identify where the import came from - ResourceConfig.fromJson({ ...r, core: { ...r.core, name: resource.name } }) - ) ?? [] - ); - } else { - errors.push(`Unable to import resource '${resource.type}', resource not found`); - } - } catch (error: any) { - errors.push(error.message ?? error); - } - - })) - - ctx.subprocessFinished(SubProcessName.IMPORT_RESOURCE); - - return { - result: importedConfigs, - errors, - } - } - - private static matchTypeIds(typeIds: string[], validTypeIds: string[]): string[] { - const result: string[] = []; - const unsupportedTypeIds: string[] = []; - - for (const typeId of typeIds) { - if (!typeId.includes('*') && !typeId.includes('?')) { - const matched = validTypeIds.includes(typeId); - if (!matched) { - unsupportedTypeIds.push(typeId); - continue; - } - - result.push(typeId) - continue; - } - - const matched = validTypeIds.filter((valid) => wildCardMatch(valid, typeId)) - if (matched.length === 0) { - unsupportedTypeIds.push(typeId); - continue; - } - - result.push(...matched); - } - - if (unsupportedTypeIds.length > 0) { - throw new Error(`The following resources cannot be imported. No plugins found that support the following types: -${JSON.stringify(unsupportedTypeIds)}`); - } - - return result; - } - - private static async validate(typeIds: string[], project: Project, pluginManager: PluginManager, dependencyMap: DependencyMap): Promise { - project.validateTypeIds(dependencyMap); - - const unsupportedTypeIds = typeIds.filter((type) => !dependencyMap.has(type)); - if (unsupportedTypeIds.length > 0) { - throw new Error(`The following resources cannot be imported. No plugins found that support the following types: -${JSON.stringify(unsupportedTypeIds)}`); - } - } - - private static async updateExistingFiles( - reporter: Reporter, - existingProject: Project, - importResult: RefreshResult, - resourceInfoList: ResourceInfo[], - preferredFile: string, // File to write any new resources (unknown file path) - pluginManager: PluginManager, - ): Promise { - const groupedResults = groupBy(importResult.result, (r) => - existingProject.findSpecific(r.type, r.name)?.sourceMapKey?.split('#')?.[0] ?? 'unknown' - ) - - // New resources exists (they don't belong to any existing files) - if (groupedResults.unknown) { - groupedResults[preferredFile] = [ - ...(groupedResults.unknown ?? []), - ...(groupedResults[preferredFile] ?? []), - ] - delete groupedResults.unknown; - } - - const diffs = await Promise.all(Object.entries(groupedResults).map(async ([filePath, imported]) => { - const existing = await CodifyParser.parse(filePath!); - RefreshOrchestrator.attachResourceInfo(imported, resourceInfoList); - RefreshOrchestrator.attachResourceInfo(existing.resourceConfigs, resourceInfoList); - - const modificationCalculator = new FileModificationCalculator(existing); - const modification = await modificationCalculator.calculate( - imported.map((resource) => ({ - modification: ModificationType.INSERT_OR_UPDATE, - resource - })), - // Handle matching here since we need the plugin to determine if two configs represent the same underlying resource - async (resource, array) => { - const match = await pluginManager.match(resource, array.filter((r) => r.type === resource.type)); - return array.findIndex((i) => i.isDeepEqual(match)); - } - ); - - return { file: filePath!, modification }; - })); - - // No changes to be made - if (diffs.every((d) => d.modification.diff === '')) { - reporter.displayMessage('\nNo changes are needed! Exiting...') - - // Wait for the message to display before we exit - await sleep(100); - return; - } - - reporter.displayFileModifications(diffs); - const shouldSave = await reporter.promptConfirmation('Save the changes?'); - if (!shouldSave) { - reporter.displayMessage('\nSkipping save! Exiting...'); - - // Wait for the message to display before we exit - await sleep(100); - return; - } - - for (const diff of diffs) { - await FileUpdater.write(diff.file, diff.modification.newFile); - } - - reporter.displayMessage('\nšŸŽ‰ Imported completed and saved to file šŸŽ‰'); - - // Wait for the message to display before we exit - await sleep(100); - } - - // We have to attach additional info to the imported configs to make saving easier - private static attachResourceInfo(resources: ResourceConfig[], resourceInfoList: ResourceInfo[]): void { - resources.forEach((resource) => { - const matchedInfo = resourceInfoList.find((info) => info.type === resource.type)!; - if (!matchedInfo) { - throw new Error(`Could not find type ${resource.type} in the resource info`); - } - - resource.attachResourceInfo(matchedInfo); - }) - } } From 461421a497ee25390b842049093105747dfb1216 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Tue, 21 Oct 2025 22:48:43 -0400 Subject: [PATCH 31/67] Fixed file modification calculator handling of an empty file '[]'. Added tests for auto-import --- .../file-modification-calculator.ts | 13 +- test/orchestrator/import/import.test.ts | 495 ++++++++++++------ test/orchestrator/mocks/reporter.ts | 2 +- 3 files changed, 346 insertions(+), 164 deletions(-) diff --git a/src/generators/file-modification-calculator.ts b/src/generators/file-modification-calculator.ts index 705ade78..d346c93d 100644 --- a/src/generators/file-modification-calculator.ts +++ b/src/generators/file-modification-calculator.ts @@ -35,6 +35,10 @@ export class FileModificationCalculator { this.validate(modifications); let newFile = this.existingFile!.contents.trimEnd(); + if (newFile === '[]') { + newFile = '[\n]' + } + const updateCache = [...modifications]; // Reverse the traversal order so we edit from the back. This way the line numbers won't be messed up with new edits. @@ -128,19 +132,22 @@ export class FileModificationCalculator { let result = file; const fileStyle = jju.analyze(file); + const indent = file === '[\n]' ? 2 : fileStyle.indent; for (const newResource of resources.reverse()) { const sortedResource = { ...newResource.core(true), ...this.sortKeys(newResource.parameters) } let content = jju.stringify(sortedResource, { - indent: fileStyle.indent, + indent, no_trailing_comma: true, quote: '"', quote_keys: fileStyle.quote_keys, mode: this.fileTypeString(fileType), }); - content = content.split(/\n/).map((l) => `${this.indentString}${l}`).join('\n') - content = `,\n${content}`; + const indentString = file === '[\n]' ? ' ' : this.indentString; + + content = content.split(/\n/).map((l) => `${indentString}${l}`).join('\n') + content = file === '[\n]' ? `\n${content}` : `,\n${content}`; result = this.splice(result, position, 0, content) } diff --git a/test/orchestrator/import/import.test.ts b/test/orchestrator/import/import.test.ts index e7b063de..c6c5bcf1 100644 --- a/test/orchestrator/import/import.test.ts +++ b/test/orchestrator/import/import.test.ts @@ -40,21 +40,21 @@ vi.mock('../mocks/get-mock-resources.js', async () => { return { id: 'jenv', schema: { - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "https://www.codifycli.com/jenv.json", - "type": "object", - "properties": { - "add": { - "type": "array" + '$schema': 'http://json-schema.org/draft-07/schema', + '$id': 'https://www.codifycli.com/jenv.json', + 'type': 'object', + 'properties': { + 'add': { + 'type': 'array' }, - "global": { - "type": "string" + 'global': { + 'type': 'string' }, - "requiredProp": { - "type": "string" + 'requiredProp': { + 'type': 'string' } }, - "required": ["requiredProp"] + 'required': ['requiredProp'] }, parameterSettings: { add: { type: 'array' }, @@ -71,18 +71,18 @@ vi.mock('../mocks/get-mock-resources.js', async () => { return { id: 'alias', schema: { - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "https://www.codifycli.com/alias.json", - "type": "object", - "properties": { - "alias": { - "type": "string" + '$schema': 'http://json-schema.org/draft-07/schema', + '$id': 'https://www.codifycli.com/alias.json', + 'type': 'object', + 'properties': { + 'alias': { + 'type': 'string' }, - "value": { - "type": "string" + 'value': { + 'type': 'string' }, }, - "required": ["alias"] + 'required': ['alias'] }, parameterSettings: { add: { type: 'array' }, @@ -94,6 +94,18 @@ vi.mock('../mocks/get-mock-resources.js', async () => { allowMultiple: true, } } + }, + new class extends MockResource { + getSettings(): ResourceSettings { + return { + id: 'mock2', + parameterSettings: { + propB: { type: 'number' }, + directory: { type: 'directory' }, + array: { type: 'array', canModify: true } + } + } + } } ]) } @@ -189,10 +201,10 @@ describe('Import orchestrator tests', () => { expect(JSON.parse(fileWritten)).toMatchObject([ { - "type": "mock", - "propA": "currentA", - "propB": "currentB", - "directory": "~/home" + 'type': 'mock', + 'propA': 'currentA', + 'propB': 'currentB', + 'directory': '~/home' } ]) }); @@ -202,7 +214,7 @@ describe('Import orchestrator tests', () => { processSpy.mockReturnValue('/'); fs.writeFileSync('/codify.json', -`[ + `[ { "type": "jenv", "add": [ @@ -214,7 +226,7 @@ describe('Import orchestrator tests', () => { "requiredProp": "this-jenv" } ]`, - { encoding: 'utf-8'}); + { encoding: 'utf-8' }); const reporter = new MockReporter({ promptUserForValues: (resourceInfoList): ResourceConfig[] => { @@ -223,7 +235,7 @@ describe('Import orchestrator tests', () => { expect.objectContaining({ name: 'requiredProp', type: 'string', - value: "this-jenv", + value: 'this-jenv', isRequiredForImport: true, }), ])) @@ -239,18 +251,18 @@ describe('Import orchestrator tests', () => { expect(importResult.result.length).to.eq(1); expect(importResult.result[0].type).to.eq('jenv'); expect(importResult.result[0].parameters).toMatchObject({ // Make sure the system values are returned here - "add": [ - "system", - "11", - "11.0", - "11.0.24", - "17", - "17.0.12", - "openjdk64-11.0.24", - "openjdk64-17.0.12" + 'add': [ + 'system', + '11', + '11.0', + '11.0.24', + '17', + '17.0.12', + 'openjdk64-11.0.24', + 'openjdk64-17.0.12' ], - "global": "17", - "requiredProp": "this-jenv" + 'global': '17', + 'requiredProp': 'this-jenv' }) }, // Option 0 is write to a new file (no current project exists) @@ -270,18 +282,18 @@ describe('Import orchestrator tests', () => { const promptConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); MockOs.create('jenv', { - "add": [ - "system", - "11", - "11.0", - "11.0.24", - "17", - "17.0.12", - "openjdk64-11.0.24", - "openjdk64-17.0.12" + 'add': [ + 'system', + '11', + '11.0', + '11.0.24', + '17', + '17.0.12', + 'openjdk64-11.0.24', + 'openjdk64-17.0.12' ], - "global": "17", - "requiredProp": "this-jenv" + 'global': '17', + 'requiredProp': 'this-jenv' }) await ImportOrchestrator.run( @@ -302,19 +314,19 @@ describe('Import orchestrator tests', () => { expect(JSON.parse(fileWritten)).toMatchObject([ { - "type": "jenv", - "add": [ - "system", - "11", - "11.0", - "11.0.24", - "17", - "17.0.12", - "openjdk64-11.0.24", - "openjdk64-17.0.12" + 'type': 'jenv', + 'add': [ + 'system', + '11', + '11.0', + '11.0.24', + '17', + '17.0.12', + 'openjdk64-11.0.24', + 'openjdk64-17.0.12' ], - "global": "17", - "requiredProp": "this-jenv" + 'global': '17', + 'requiredProp': 'this-jenv' } ]) }); @@ -336,7 +348,7 @@ describe('Import orchestrator tests', () => { "requiredProp": "this-jenv" } ]`, - { encoding: 'utf-8'}); + { encoding: 'utf-8' }); fs.writeFileSync('/other.codify.json', `[ @@ -347,7 +359,7 @@ describe('Import orchestrator tests', () => { "value": "git commit -v" } ]`, - { encoding: 'utf-8'}); + { encoding: 'utf-8' }); const reporter = new MockReporter({ promptUserForValues: (resourceInfoList): ResourceConfig[] => { @@ -392,23 +404,23 @@ describe('Import orchestrator tests', () => { const promptConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); MockOs.create('jenv', { - "add": [ - "system", - "11", - "11.0", - "11.0.24", - "17", - "17.0.12", - "openjdk64-11.0.24", - "openjdk64-17.0.12" + 'add': [ + 'system', + '11', + '11.0', + '11.0.24', + '17', + '17.0.12', + 'openjdk64-11.0.24', + 'openjdk64-17.0.12' ], - "global": "17", - "requiredProp": "this-jenv" + 'global': '17', + 'requiredProp': 'this-jenv' }) MockOs.create('alias', { - "alias": 'gc-new', - "value": 'gc-new-value', + 'alias': 'gc-new', + 'value': 'gc-new-value', }) await ImportOrchestrator.run( @@ -427,14 +439,14 @@ describe('Import orchestrator tests', () => { const otherCodifyFile = fs.readFileSync('/other.codify.json', 'utf8') as string; console.log(otherCodifyFile); expect(JSON.parse(otherCodifyFile)).toMatchObject([ - { "type": "alias", "alias": "gcdsdd", "value": "git clone" }, + { 'type': 'alias', 'alias': 'gcdsdd', 'value': 'git clone' }, { - "type": "alias", - "alias": "gcc", - "value": "git commit -v" + 'type': 'alias', + 'alias': 'gcc', + 'value': 'git commit -v' }, { - "type": "alias", + 'type': 'alias', 'alias': 'gc-new', 'value': 'gc-new-value', } @@ -445,19 +457,19 @@ describe('Import orchestrator tests', () => { expect(JSON.parse(codifyFile)).toMatchObject([ { - "type": "jenv", - "add": [ - "system", - "11", - "11.0", - "11.0.24", - "17", - "17.0.12", - "openjdk64-11.0.24", - "openjdk64-17.0.12" + 'type': 'jenv', + 'add': [ + 'system', + '11', + '11.0', + '11.0.24', + '17', + '17.0.12', + 'openjdk64-11.0.24', + 'openjdk64-17.0.12' ], - "global": "17", - "requiredProp": "this-jenv" + 'global': '17', + 'requiredProp': 'this-jenv' } ]) }); @@ -479,7 +491,7 @@ describe('Import orchestrator tests', () => { "requiredProp": "this-jenv" } ]`, - { encoding: 'utf-8'}); + { encoding: 'utf-8' }); const reporter = new MockReporter({ displayImportResult: (importResult) => { @@ -488,18 +500,18 @@ describe('Import orchestrator tests', () => { expect(importResult.result.length).to.eq(1); expect(importResult.result[0].type).to.eq('jenv'); expect(importResult.result[0].parameters).toMatchObject({ // Make sure the system values are returned here - "add": [ - "system", - "11", - "11.0", - "11.0.24", - "17", - "17.0.12", - "openjdk64-11.0.24", - "openjdk64-17.0.12" + 'add': [ + 'system', + '11', + '11.0', + '11.0.24', + '17', + '17.0.12', + 'openjdk64-11.0.24', + 'openjdk64-17.0.12' ], - "global": "17", - "requiredProp": "this-jenv" + 'global': '17', + 'requiredProp': 'this-jenv' }) }, // Option 0 is write to a new file (no current project exists) @@ -519,18 +531,18 @@ describe('Import orchestrator tests', () => { const promptConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); MockOs.create('jenv', { - "add": [ - "system", - "11", - "11.0", - "11.0.24", - "17", - "17.0.12", - "openjdk64-11.0.24", - "openjdk64-17.0.12" + 'add': [ + 'system', + '11', + '11.0', + '11.0.24', + '17', + '17.0.12', + 'openjdk64-11.0.24', + 'openjdk64-17.0.12' ], - "global": "17", - "requiredProp": "this-jenv" + 'global': '17', + 'requiredProp': 'this-jenv' }) await ImportOrchestrator.run( @@ -550,19 +562,19 @@ describe('Import orchestrator tests', () => { expect(JSON.parse(fileWritten)).toMatchObject([ { - "type": "jenv", - "add": [ - "system", - "11", - "11.0", - "11.0.24", - "17", - "17.0.12", - "openjdk64-11.0.24", - "openjdk64-17.0.12" + 'type': 'jenv', + 'add': [ + 'system', + '11', + '11.0', + '11.0.24', + '17', + '17.0.12', + 'openjdk64-11.0.24', + 'openjdk64-17.0.12' ], - "global": "17", - "requiredProp": "this-jenv" + 'global': '17', + 'requiredProp': 'this-jenv' } ]) }); @@ -584,7 +596,7 @@ describe('Import orchestrator tests', () => { "requiredProp": "this-jenv" } ]`, - { encoding: 'utf-8'}); + { encoding: 'utf-8' }); const reporter = new MockReporter({ promptUserForValues: (resourceInfoList): ResourceConfig[] => { @@ -593,7 +605,7 @@ describe('Import orchestrator tests', () => { expect.objectContaining({ name: 'requiredProp', type: 'string', - value: "this-jenv", + value: 'this-jenv', isRequiredForImport: true, }), ])) @@ -603,23 +615,23 @@ describe('Import orchestrator tests', () => { requiredProp: true, })] }, - displayImportResult: (importResult, showConfigs, ) => { + displayImportResult: (importResult, showConfigs,) => { expect(importResult.errors.length).to.eq(0); expect(importResult.result.length).to.eq(1); expect(importResult.result[0].type).to.eq('jenv'); expect(importResult.result[0].parameters).toMatchObject({ // Make sure the system values are returned here - "add": [ - "system", - "11", - "11.0", - "11.0.24", - "17", - "17.0.12", - "openjdk64-11.0.24", - "openjdk64-17.0.12" + 'add': [ + 'system', + '11', + '11.0', + '11.0.24', + '17', + '17.0.12', + 'openjdk64-11.0.24', + 'openjdk64-17.0.12' ], - "global": "17", - "requiredProp": "this-jenv" + 'global': '17', + 'requiredProp': 'this-jenv' }) if (showConfigs) { @@ -639,18 +651,18 @@ describe('Import orchestrator tests', () => { const promptConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); MockOs.create('jenv', { - "add": [ - "system", - "11", - "11.0", - "11.0.24", - "17", - "17.0.12", - "openjdk64-11.0.24", - "openjdk64-17.0.12" + 'add': [ + 'system', + '11', + '11.0', + '11.0.24', + '17', + '17.0.12', + 'openjdk64-11.0.24', + 'openjdk64-17.0.12' ], - "global": "17", - "requiredProp": "this-jenv" + 'global': '17', + 'requiredProp': 'this-jenv' }) await ImportOrchestrator.run( @@ -671,14 +683,177 @@ describe('Import orchestrator tests', () => { expect(JSON.parse(fileWritten)).toMatchObject([ { - "type": "jenv", - "add": [ - "system", - "11", - "11.0" + 'type': 'jenv', + 'add': [ + 'system', + '11', + '11.0' ], - "global": "17", - "requiredProp": "this-jenv" + 'global': '17', + 'requiredProp': 'this-jenv' + } + ]) + }); + + it('Can import everything automatically that doesnt have required args', async () => { + const processSpy = vi.spyOn(process, 'cwd'); + processSpy.mockReturnValue('/') + + const reporter = new MockReporter({ + promptUserForValues: (resourceInfoList): ResourceConfig[] => { + expect(resourceInfoList.length).to.eq(1); + expect(resourceInfoList[0].getRequiredParameters()).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'propA', + type: 'string', + }), + expect.objectContaining({ + name: 'propB', + type: 'number', + }) + ])) + + return [new ResourceConfig({ + type: 'mock', + propA: 'randomPropA', + propB: 'randomPropB' + })] + }, + promptInitResultSelection: (availableTypes) => { + expect(availableTypes).toMatchObject(['mock2']) + return availableTypes; + }, + // Option 0 is write to a new file (no current project exists) + promptOptions: (message, options) => { + expect(options[0]).toContain('new file'); + return 0; + }, + displayFileModifications: (diff: Array<{ file: string, modification: FileModificationResult }>) => { + expect(diff[0].file).to.eq('/codify.json') + console.log(diff[0].file); + }, + }); + + const askRequiredParametersSpy = vi.spyOn(reporter, 'promptUserForValues'); + const promptInitResultSelectionSpy = vi.spyOn(reporter, 'promptInitResultSelection'); + const displayFileModifications = vi.spyOn(reporter, 'displayFileModifications'); + const promptConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); + + MockOs.create('mock2', { + propA: 'currentA', + propB: 'currentB', + directory: '~/home' + }) + + await ImportOrchestrator.run( + { + path: '/' + }, + reporter, + ); + + expect(askRequiredParametersSpy).toHaveBeenCalledTimes(0); + expect(promptInitResultSelectionSpy).toHaveBeenCalledOnce(); + expect(displayFileModifications).toHaveBeenCalledOnce(); + expect(promptConfirmationSpy).toHaveBeenCalledOnce(); + + const fileWritten = fs.readFileSync('/codify-imports/import.codify.jsonc', 'utf8') as string; + console.log(fileWritten); + + expect(JSON.parse(fileWritten)).toMatchObject([ + { + 'type': 'mock2', + 'propA': 'currentA', + 'propB': 'currentB', + 'directory': '~/home' + } + ]) + }); + + it('Can automatically import to a partially empty file ([])', async () => { + const processSpy = vi.spyOn(process, 'cwd'); + processSpy.mockReturnValue('/') + + fs.writeFileSync('/codify.jsonc', + `[]`, + { encoding: 'utf-8' }); + + const reporter = new MockReporter({ + promptUserForValues: (resourceInfoList): ResourceConfig[] => { + expect(resourceInfoList.length).to.eq(1); + expect(resourceInfoList[0].getRequiredParameters()).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'propA', + type: 'string', + }), + expect.objectContaining({ + name: 'propB', + type: 'number', + }) + ])) + + return [new ResourceConfig({ + type: 'mock', + propA: 'randomPropA', + propB: 'randomPropB' + })] + }, + promptInitResultSelection: (availableTypes) => { + expect(availableTypes).toMatchObject(['mock2']) + return availableTypes; + }, + // Option 0 is write to a new file (no current project exists) + promptOptions: (message, options) => { + expect(options[0]).toContain('existing'); + return 0; + }, + displayFileModifications: (diff: Array<{ file: string, modification: FileModificationResult }>) => { + expect(diff[0].file).to.eq('/codify.json') + console.log(diff[0].file); + }, + }); + + const askRequiredParametersSpy = vi.spyOn(reporter, 'promptUserForValues'); + const promptInitResultSelectionSpy = vi.spyOn(reporter, 'promptInitResultSelection'); + const displayFileModifications = vi.spyOn(reporter, 'displayFileModifications'); + const promptConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); + + MockOs.create('mock2', { + propA: 'currentA', + propB: 'currentB', + directory: '~/home' + }) + + await ImportOrchestrator.run( + { + path: '/' + }, + reporter, + ); + + expect(askRequiredParametersSpy).toHaveBeenCalledTimes(0); + expect(promptInitResultSelectionSpy).toHaveBeenCalledOnce(); + expect(displayFileModifications).toHaveBeenCalledOnce(); + expect(promptConfirmationSpy).toHaveBeenCalledOnce(); + + const fileWritten = fs.readFileSync('/codify.jsonc', 'utf8') as string; + console.log(fileWritten); + + expect(fileWritten).to.eq( +`[ + { + "type": "mock2", + "directory": "~/home", + "propA": "currentA", + "propB": "currentB" + } +]`) + expect(JSON.parse(fileWritten)).toMatchObject([ + { + 'type': 'mock2', + 'propA': 'currentA', + 'propB': 'currentB', + 'directory': '~/home' } ]) }); diff --git a/test/orchestrator/mocks/reporter.ts b/test/orchestrator/mocks/reporter.ts index 42b59229..91cdb848 100644 --- a/test/orchestrator/mocks/reporter.ts +++ b/test/orchestrator/mocks/reporter.ts @@ -16,7 +16,7 @@ export interface MockReporterConfig { promptOptions?: (message: string, options: string[]) => number; promptUserForValues?: (resourceInfo: ResourceInfo[]) => Promise | ResourceConfig[]; promptInput?: (prompt: string, error?: string | undefined, validation?: (() => Promise) | undefined, autoComplete?: ((input: string) => string[]) | undefined) => Promise - promptInitResultSelection?: (availableTypes: string[]) => Promise | void; + promptInitResultSelection?: (availableTypes: string[]) => Promise | string[]; hide?: () => void; displayImportResult?: (importResult: ImportResult, showConfigs: boolean) => Promise | void; displayFileModifications?: (diff: { file: string; modification: FileModificationResult; }[]) => void, From 6e4b92e09ab5e58d9b5d5b182470cac20b3b535e Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 22 Oct 2025 16:04:03 -0400 Subject: [PATCH 32/67] Fixed file modification calculator handling of an empty file '[]'. Added tests for auto-import --- src/api/dashboard/index.ts | 7 +++++++ src/common/errors.ts | 14 ++++++++++++++ src/common/initialize-plugins.ts | 2 +- src/orchestrators/edit.ts | 10 +++++++--- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/api/dashboard/index.ts b/src/api/dashboard/index.ts index 4fb7feeb..6489cbee 100644 --- a/src/api/dashboard/index.ts +++ b/src/api/dashboard/index.ts @@ -1,6 +1,7 @@ import { config } from '../../config.js'; import { LoginHelper } from '../../connect/login-helper.js'; import { CloudDocument } from './types.js'; +import { UnauthorizedError } from '../../common/errors.js'; export const DashboardApiClient = { async getDocument(id: string): Promise { @@ -35,7 +36,13 @@ export const DashboardApiClient = { ); if (!res.ok) { + console.log('Request not okay', res.status) const message = await res.text(); + if (res.status === 401) { + console.log('Unauthorized'); + throw new UnauthorizedError({ requestName: 'getDefaultDocumentId' }); + } + throw new Error(message); } diff --git a/src/common/errors.ts b/src/common/errors.ts index 2af0ffa1..ae3d649f 100644 --- a/src/common/errors.ts +++ b/src/common/errors.ts @@ -142,6 +142,20 @@ export class SyntaxError extends CodifyError { } } +export class UnauthorizedError extends CodifyError { + name = 'UnauthorizedError' + requestName?: string + + constructor(props: Omit, 'message'>) { + super(`Unauthorized request to Codify. ${props.requestName ?? ''}`) + Object.assign(this, props); + } + + formattedMessage(): string { + return this.message + } +} + export function prettyPrintError(error: unknown): void { if (error instanceof CodifyError) { return console.error(chalk.red(error.formattedMessage())); diff --git a/src/common/initialize-plugins.ts b/src/common/initialize-plugins.ts index b874b183..c4bb8912 100644 --- a/src/common/initialize-plugins.ts +++ b/src/common/initialize-plugins.ts @@ -107,7 +107,7 @@ export class PluginInitOrchestrator { } if (LoginHelper.get()?.isLoggedIn) { - return DashboardApiClient.getDefaultDocumentId(); + return (await DashboardApiClient.getDefaultDocumentId()) ?? undefined; } if (args.allowEmptyProject) { diff --git a/src/orchestrators/edit.ts b/src/orchestrators/edit.ts index 9aec0f4c..cf5dd599 100644 --- a/src/orchestrators/edit.ts +++ b/src/orchestrators/edit.ts @@ -1,5 +1,4 @@ import { Config } from '@oclif/core'; -import { randomBytes } from 'node:crypto'; import open from 'open'; import { DashboardApiClient } from '../api/dashboard/index.js'; @@ -9,7 +8,6 @@ import { ConnectOrchestrator } from './connect.js'; import { LoginOrchestrator } from './login.js'; export class EditOrchestrator { - static rootCommand: string; static async run(oclifConfig: Config) { const login = LoginHelper.get()?.isLoggedIn; @@ -18,7 +16,13 @@ export class EditOrchestrator { await LoginOrchestrator.run(); } - const defaultDocumentId = await DashboardApiClient.getDefaultDocumentId(); + let defaultDocumentId: null | string = null; + try { + defaultDocumentId = await DashboardApiClient.getDefaultDocumentId(); + } catch { + console.warn('Mismatch accounts between local and dashboard. Cannot open default document') + } + const url = defaultDocumentId ? `${config.dashboardUrl}/file/${defaultDocumentId}` : config.dashboardUrl; From f2eefe9164c87548a4ff1721f2b916493c50e195 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sat, 25 Oct 2025 10:15:38 -0400 Subject: [PATCH 33/67] fix: Fixed bugs with refresh and import --- src/connect/http-routes/handlers/import-handler.ts | 2 +- src/connect/http-routes/handlers/refresh-handler.ts | 2 +- src/orchestrators/refresh.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/connect/http-routes/handlers/import-handler.ts b/src/connect/http-routes/handlers/import-handler.ts index ce534d7e..19fcb7d3 100644 --- a/src/connect/http-routes/handlers/import-handler.ts +++ b/src/connect/http-routes/handlers/import-handler.ts @@ -29,7 +29,7 @@ export function importHandler() { throw new Error('Unable to parse import type'); } - if (type === ImportType.IMPORT_SPECIFIC && (!resourceTypes || Array.isArray(resourceTypes))) { + if (type === ImportType.IMPORT_SPECIFIC && (!resourceTypes || !Array.isArray(resourceTypes))) { throw new Error('For import specific, a list of resource types must be provided'); } diff --git a/src/connect/http-routes/handlers/refresh-handler.ts b/src/connect/http-routes/handlers/refresh-handler.ts index 4b19922a..e986873c 100644 --- a/src/connect/http-routes/handlers/refresh-handler.ts +++ b/src/connect/http-routes/handlers/refresh-handler.ts @@ -29,7 +29,7 @@ export function refreshHandler() { throw new Error('Unable to parse import type'); } - if (type === RefreshType.REFRESH_SPECIFIC && (!resourceTypes || Array.isArray(resourceTypes))) { + if (type === RefreshType.REFRESH_SPECIFIC && (!resourceTypes || !Array.isArray(resourceTypes))) { throw new Error('For refresh specific, a list of resource types must be provided'); } diff --git a/src/orchestrators/refresh.ts b/src/orchestrators/refresh.ts index 19f9a5ca..a7b55eee 100644 --- a/src/orchestrators/refresh.ts +++ b/src/orchestrators/refresh.ts @@ -30,7 +30,7 @@ export class RefreshOrchestrator { await pluginManager.validate(project); const importResult = await ImportOrchestrator.import( pluginManager, - project.resourceConfigs.filter((r) => !typeIds || typeIds.includes(r.type)) + project.resourceConfigs.filter((r) => !typeIds || typeIds.length === 0 || typeIds.includes(r.type)) ); ctx.processFinished(ProcessName.REFRESH); From 29691b5ad98f8b6796848e331cfe30fdac2b9fdb Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sun, 26 Oct 2025 23:46:13 -0400 Subject: [PATCH 34/67] feat: Added support for Codify remote files --- package-lock.json | 13 ++--- package.json | 2 +- src/api/backend/index.ts | 91 ++++++++++++++++++++++++++++++++++ src/api/{ => backend}/types.ts | 0 src/api/index.ts | 40 --------------- src/common/base-command.ts | 36 ++++++++++++++ src/events/context.ts | 10 ++++ src/orchestrators/import.ts | 64 +++++++++++++++++++++++- src/orchestrators/refresh.ts | 4 ++ src/plugins/plugin-process.ts | 26 +++++++++- src/plugins/resolver.ts | 14 +++--- 11 files changed, 243 insertions(+), 57 deletions(-) create mode 100644 src/api/backend/index.ts rename src/api/{ => backend}/types.ts (100%) delete mode 100644 src/api/index.ts diff --git a/package-lock.json b/package-lock.json index 3c31c0bc..a99892c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codify", - "version": "0.9.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codify", - "version": "0.9.0", + "version": "1.0.0", "license": "MIT", "dependencies": { "@codifycli/ink-form": "0.0.12", @@ -22,7 +22,7 @@ "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "chalk": "^5.3.0", - "codify-schemas": "^1.0.76", + "codify-schemas": "^1.0.77", "cors": "^2.8.5", "debug": "^4.3.4", "detect-indent": "^7.0.1", @@ -6362,9 +6362,10 @@ } }, "node_modules/codify-schemas": { - "version": "1.0.76", - "resolved": "https://registry.npmjs.org/codify-schemas/-/codify-schemas-1.0.76.tgz", - "integrity": "sha512-ZytwEUc2xf6vJ98tZ3K16E4zcmI+80Y25eH1Zl+9nh5V1CoOKTv5xx/FREsfI2mTV0h4LCZL6USzqlOylfXxIg==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/codify-schemas/-/codify-schemas-1.0.77.tgz", + "integrity": "sha512-Xv4M/2k9e6pIbrI6NGDeUYsRjBHET45x0ygBxhpgLcIgGGeNnWVi5/Mh1mTn+TOwGmrAw3bTRsQCpDLmtfJeGA==", + "license": "ISC", "dependencies": { "ajv": "^8.12.0" } diff --git a/package.json b/package.json index 34f8000a..8e2020d0 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "chalk": "^5.3.0", - "codify-schemas": "^1.0.76", + "codify-schemas": "^1.0.77", "cors": "^2.8.5", "debug": "^4.3.4", "detect-indent": "^7.0.1", diff --git a/src/api/backend/index.ts b/src/api/backend/index.ts new file mode 100644 index 00000000..8407ce8f --- /dev/null +++ b/src/api/backend/index.ts @@ -0,0 +1,91 @@ +import * as fsSync from 'node:fs'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { finished } from 'node:stream/promises'; + +import { PluginSearchQuery, PluginSearchResult } from './types.js'; + +const API_BASE_URL = 'https://api.codifycli.com' + +export const ApiClient = { + async searchPlugins(query: PluginSearchQuery[]): Promise { + const body = JSON.stringify({ query }); + const res = await fetch( + `${API_BASE_URL}/v1/plugins/versions/search`, + { method: 'POST', body, headers: { 'Content-Type': 'application/json' } } + ); + + if (!res.ok) { + const message = await res.text(); + throw new Error(message); + } + + const json = await res.json(); + return json.results as unknown as PluginSearchResult; + }, + + async downloadPlugin(filePath: string, url: string): Promise { + const { body } = await fetch(url) + + const dirname = path.dirname(filePath); + if (!await fs.stat(dirname).then((s) => s.isDirectory()).catch(() => false)) { + await fs.mkdir(dirname, { recursive: true }); + } + + const ws = fsSync.createWriteStream(filePath) + // Different type definitions here for readable stream (NodeJS vs DOM). Small hack to fix that + await finished(Readable.fromWeb(body as never).pipe(ws)); + }, + + async getRemoteFileHash(filePath: string, credentials: string): Promise { + const { documentId, fileId } = this.extractCodifyFileInfo(filePath); + + const response = await fetch((`https://api.codifycli.com/v1/documents/${documentId}/file/${fileId}/hash`), { + method: 'GET', + headers: { + 'Authorization': `Bearer ${credentials}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to get remote file hash for ${filePath}`); + } + + const data = await response.json(); + return data.hash; + }, + + async updateRemoteFile(filePath: string, content: Blob, credentials: string): Promise { + const { documentId, fileId } = this.extractCodifyFileInfo(filePath); + + const formData = new FormData(); + formData.append('file', content); + + const response = await fetch((`https://api.codifycli.com/v1/documents/${documentId}/file/${fileId}`), { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${credentials}`, + }, + body: formData, + }); + + if (!response.ok) { + throw new Error(`Failed to save remote file ${filePath}`); + } + }, + + extractCodifyFileInfo(url: string) { + const regex = /codify:\/\/(.*):(.*)/ + + const [, group1, group2] = regex.exec(url) ?? []; + if (!group1 || !group2) { + throw new Error(`Invalid codify url ${url} for file`); + } + + return { + documentId: group1, + fileId: group2, + } + }, +}; diff --git a/src/api/types.ts b/src/api/backend/types.ts similarity index 100% rename from src/api/types.ts rename to src/api/backend/types.ts diff --git a/src/api/index.ts b/src/api/index.ts deleted file mode 100644 index cb806c54..00000000 --- a/src/api/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as fsSync from 'node:fs'; -import * as fs from 'node:fs/promises'; -import path from 'node:path'; -import { Readable } from 'node:stream'; -import { finished } from 'node:stream/promises'; - -import { PluginSearchQuery, PluginSearchResult } from './types.js'; - -const API_BASE_URL = 'https://api.codifycli.com' - -export const ApiClient = { - async searchPlugins(query: PluginSearchQuery[]): Promise { - const body = JSON.stringify({ query }); - const res = await fetch( - `${API_BASE_URL}/v1/plugins/versions/search`, - { method: 'POST', body, headers: { 'Content-Type': 'application/json' } } - ); - - if (!res.ok) { - const message = await res.text(); - throw new Error(message); - } - - const json = await res.json(); - return json.results as unknown as PluginSearchResult; - }, - - async downloadPlugin(filePath: string, url: string): Promise { - const { body } = await fetch(url) - - const dirname = path.dirname(filePath); - if (!await fs.stat(dirname).then((s) => s.isDirectory()).catch(() => false)) { - await fs.mkdir(dirname, { recursive: true }); - } - - const ws = fsSync.createWriteStream(filePath) - // Different type definitions here for readable stream (NodeJS vs DOM). Small hack to fix that - await finished(Readable.fromWeb(body as never).pipe(ws)); - }, -}; diff --git a/src/common/base-command.ts b/src/common/base-command.ts index cd242422..90d0b354 100644 --- a/src/common/base-command.ts +++ b/src/common/base-command.ts @@ -6,6 +6,7 @@ import createDebug from 'debug'; import { LoginHelper } from '../connect/login-helper.js'; import { Event, ctx } from '../events/context.js'; +import { LoginOrchestrator } from '../orchestrators/login.js'; import { Reporter, ReporterFactory, ReporterType } from '../ui/reporters/reporter.js'; import { SudoUtils } from '../utils/sudo.js'; import { prettyPrintError } from './errors.js'; @@ -65,7 +66,42 @@ export abstract class BaseCommand extends Command { ctx.pressKeyToContinueCompleted(pluginName) }) + ctx.on(Event.CODIFY_LOGIN_CREDENTIALS_REQUEST, async (pluginName: string) => { + if (pluginName !== 'default') { + throw new Error(`Only the default plugin can request Codify credentials. Instead received ${pluginName}`); + } + + if (LoginHelper.get()?.isLoggedIn) { + const credentials = LoginHelper.get()?.credentials?.accessToken; + if (!credentials) { + throw new Error('Unable to retrieve Codify credentials for user...'); + } + + ctx.codifyLoginCompleted(pluginName, credentials); + } else { + ctx.log('User is not currently logged. Attempt to Login to Codify...'); + await LoginOrchestrator.run(); + + if (LoginHelper.get()?.isLoggedIn) { + const credentials = LoginHelper.get()?.credentials?.accessToken; + if (!credentials) { + throw new Error('Unable to retrieve Codify credentials for user...'); + } + + ctx.codifyLoginCompleted(pluginName, credentials); + } else { + throw new Error('Unable to login...') + } + } + }) + await LoginHelper.load(); + + // Catch any un-caught exceptions + process.on('uncaughtException', (error) => { + console.log('Caught exception') + this.catch(error); + }) } protected async catch(err: Error): Promise { diff --git a/src/events/context.ts b/src/events/context.ts index ae0a891d..d2835833 100644 --- a/src/events/context.ts +++ b/src/events/context.ts @@ -17,6 +17,8 @@ export enum Event { SUDO_REQUEST_GRANTED = 'sudo_request_granted', PRESS_KEY_TO_CONTINUE_REQUEST = 'press_key_to_continue_request', PRESS_KEY_TO_CONTINUE_COMPLETED = 'press_key_to_continue_completed', + CODIFY_LOGIN_CREDENTIALS_REQUEST = 'codify_login_credentials_request', + CODIFY_LOGIN_CREDENTIALS_COMPLETED = 'codify_login_credentials_completed', } export enum ProcessName { @@ -116,6 +118,14 @@ export const ctx = new class { this.emitter.emit(Event.PRESS_KEY_TO_CONTINUE_COMPLETED, pluginName); } + codifyLoginRequested(pluginName: string) { + this.emitter.emit(Event.CODIFY_LOGIN_CREDENTIALS_REQUEST, pluginName); + } + + codifyLoginCompleted(pluginName: string, credentials: string) { + this.emitter.emit(Event.CODIFY_LOGIN_CREDENTIALS_COMPLETED, pluginName, credentials); + } + async subprocess(name: string, run: () => Promise): Promise { this.emitter.emit(Event.SUB_PROCESS_START, name); const result = await run(); diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index 2c54fbf8..7897e13b 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -1,6 +1,10 @@ +import chalk from 'chalk'; +import fs from 'node:fs/promises'; import path from 'node:path'; +import { ApiClient } from '../api/backend/index.js'; import { InitializationResult, PluginInitOrchestrator } from '../common/initialize-plugins.js'; +import { LoginHelper } from '../connect/login-helper.js'; import { Project } from '../entities/project.js'; import { ResourceConfig } from '../entities/resource-config.js'; import { ResourceInfo } from '../entities/resource-info.js'; @@ -15,6 +19,7 @@ import { PromptType, Reporter } from '../ui/reporters/reporter.js'; import { FileUtils } from '../utils/file.js'; import { groupBy, sleep } from '../utils/index.js'; import { wildCardMatch } from '../utils/wild-card-match.js'; +import { LoginOrchestrator } from './login.js'; export type ImportResult = { result: ResourceConfig[], errors: string[] } @@ -161,8 +166,10 @@ export class ImportOrchestrator { pluginManager: PluginManager, args: ImportArgs, ): Promise { - const multipleCodifyFiles = project.codifyFiles.length > 1; + // Special handling for remote-file resources. Offer to save them remotely if any changes are detected on import. + await ImportOrchestrator.handleCodifyRemoteFiles(reporter, importResult); + const multipleCodifyFiles = project.codifyFiles.length > 1; const saveType = await ImportOrchestrator.getSaveType(reporter, project, args); // Update an existing file @@ -259,6 +266,61 @@ export class ImportOrchestrator { await sleep(100); } + // Special handling for codify cloud files. Import and refresh can automatically save file updates. + static async handleCodifyRemoteFiles(reporter: Reporter, importResult: ImportResult) { + try { + if (!importResult.result.some((r) => r.type === 'remote-file')) { + return; + } + + if (!LoginHelper.get()?.isLoggedIn) { + await LoginOrchestrator.run(); + } + + const credentials = LoginHelper.get()!.credentials!.accessToken; + + const filesToUpdate = []; + const remoteFiles = importResult.result.filter((r) => r.type === 'remote-file'); + for (const file of remoteFiles) { + if (!file.parameters.remote || !file.parameters.hash) { + continue; + } + + const hash = await ApiClient.getRemoteFileHash(file.parameters.remote as string, credentials); + if (hash !== file.parameters.hash) { + filesToUpdate.push(file); + } + } + + if (filesToUpdate.length === 0) { + return; + } + + const fileNames = filesToUpdate.map((f) => `'${f.parameters.path}'`).join(', ') + const shouldUpdate = await reporter.promptConfirmation( + `The following files have been updated: [${fileNames}].\nDo you want to upload the changes to Codify cloud? ${chalk.bold('(Warning this will override any existing data!)')}`, + ); + + if (!shouldUpdate) { + return; + } + + for (const file of filesToUpdate) { + if (!file.parameters.path) { + console.warn(`Unable to find file path for file ${file.parameters.remote}`) + continue; + } + + const content = await fs.readFile(file.parameters.path as string); + await ApiClient.updateRemoteFile(file.parameters.remote as string, new Blob([content]), credentials); + } + + ctx.log('Successfully uploaded changes to Codify cloud'); + } catch { + console.warn('Unable to process remote-files'); + } + } + private static matchTypeIds(typeIds: string[], validTypeIds: string[]): string[] { const result: string[] = []; const unsupportedTypeIds: string[] = []; diff --git a/src/orchestrators/refresh.ts b/src/orchestrators/refresh.ts index a7b55eee..ad1cc9dd 100644 --- a/src/orchestrators/refresh.ts +++ b/src/orchestrators/refresh.ts @@ -37,6 +37,10 @@ export class RefreshOrchestrator { reporter.displayImportResult(importResult, false); + + // Special handling for remote-file resources. Offer to save them remotely if any changes are detected on import. + await ImportOrchestrator.handleCodifyRemoteFiles(reporter, importResult); + const resourceInfoList = await pluginManager.getMultipleResourceInfo( project.resourceConfigs.map((r) => r.type), ); diff --git a/src/plugins/plugin-process.ts b/src/plugins/plugin-process.ts index ba983da7..8a997d26 100644 --- a/src/plugins/plugin-process.ts +++ b/src/plugins/plugin-process.ts @@ -64,7 +64,7 @@ export class PluginProcess { throw new Error(`Plugin ${this.name} exited with code ${code}`); } }) - PluginProcess.handleSudoRequests(_process, name); + PluginProcess.handleRequests(_process, name); return new PluginProcess(_process); } @@ -73,7 +73,7 @@ export class PluginProcess { this.process = process; } - private static handleSudoRequests(process: ChildProcess, pluginName: string) { + private static handleRequests(process: ChildProcess, pluginName: string) { // Listen for incoming sudo incoming sudo requests process.on('message', (message) => { if (!PluginProcess.isIpcMessage(message)) { @@ -119,6 +119,28 @@ export class PluginProcess { return ctx.pressToContinueRequested(pluginName, data as unknown as PressKeyToContinueRequestData); } + + + if (message.cmd === MessageCmd.CODIFY_CREDENTIALS_REQUEST) { + if (pluginName !== 'default') { + throw new Error(`Only the default Codify plugin is able to request Codify credentials. ${pluginName}`); + } + + const { requestId } = message; + + // Send out credentials granted events + ctx.once(Event.CODIFY_LOGIN_CREDENTIALS_COMPLETED, (_pluginName, credentials) => { + if (_pluginName === pluginName) { + process.send({ + cmd: returnMessageCmd(MessageCmd.CODIFY_CREDENTIALS_REQUEST), + requestId, + data: credentials, + }) + } + }) + + return ctx.codifyLoginRequested(pluginName); + } }) } diff --git a/src/plugins/resolver.ts b/src/plugins/resolver.ts index 921848c8..f87c3ec6 100644 --- a/src/plugins/resolver.ts +++ b/src/plugins/resolver.ts @@ -4,8 +4,8 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import path from 'node:path'; -import { ApiClient } from '../api/index.js'; -import { PluginInfo } from '../api/types.js'; +import { PluginInfo } from '../api//backend/types.js'; +import { ApiClient } from '../api/backend/index.js'; import { ctx } from '../events/context.js'; import { Plugin } from './plugin.js'; @@ -32,9 +32,9 @@ export class PluginResolver { // Fetch the latest plugin info from the server const latestPluginInfo = await ApiClient .searchPlugins(networkPluginDefs.map(([name, version]) => ({ name, version }))) - .catch((e: Error) => { + .catch((error: Error) => { console.warn('Unable to fetch latest plugin info'); - ctx.debug(`Unable to fetch latest plugin info:\n${e.message}`); + ctx.debug(`Unable to fetch latest plugin info:\n${error.message}`); }) ?? undefined; const networkPlugins = await Promise.all(networkPluginDefs.map(([name, version]) => @@ -52,7 +52,7 @@ export class PluginResolver { let stats: fsSync.Stats; try { stats = await fs.stat(path.resolve(filePath)); - } catch (e) { + } catch { throw new Error(`Unable to find plugin file path ${filePath}`) } @@ -130,7 +130,7 @@ export class PluginResolver { const pluginPath = path.join(PLUGIN_CACHE_DIR, name); const versions = await fs.readdir(pluginPath); return latestSemver(versions); - } catch (e) { + } catch { return undefined; } } @@ -140,7 +140,7 @@ export class PluginResolver { try { const fileStats = await fs.stat(pluginPath) return fileStats.isFile(); - } catch (e) { + } catch { return false; } } From d8733a95d7464d697c318195268b3cf3e20292b4 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Mon, 27 Oct 2025 00:02:52 -0400 Subject: [PATCH 35/67] fix: Bug fix for files that don't currently exist. Import confirmation prompt --- src/orchestrators/import.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index 7897e13b..5a582000 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -286,7 +286,17 @@ export class ImportOrchestrator { continue; } - const hash = await ApiClient.getRemoteFileHash(file.parameters.remote as string, credentials); + let hash: string + try { + hash = await ApiClient.getRemoteFileHash(file.parameters.remote as string, credentials); + } catch { + hash = ''; + } + + if (hash && hash !== '' && file.parameters.onlyCreate) { + continue; + } + if (hash !== file.parameters.hash) { filesToUpdate.push(file); } @@ -296,9 +306,9 @@ export class ImportOrchestrator { return; } - const fileNames = filesToUpdate.map((f) => `'${f.parameters.path}'`).join(', ') + const fileNames = filesToUpdate.map((f, idx) => `${idx + 1}. ${f.parameters.path} -> ${f.parameters.remote}`).join(',\n'); const shouldUpdate = await reporter.promptConfirmation( - `The following files have been updated: [${fileNames}].\nDo you want to upload the changes to Codify cloud? ${chalk.bold('(Warning this will override any existing data!)')}`, + `The following files have been updated:\n${fileNames}\n\nDo you want to upload the changes to Codify cloud? ${chalk.bold('(Warning this will override any existing data!)')}`, ); if (!shouldUpdate) { From a45e0f22b9c032075a1a27e651c2da416b34065f Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sat, 15 Nov 2025 22:07:06 -0500 Subject: [PATCH 36/67] fix: Bug fix for MacOS oclif installer bug. It doesn't clear the oclif update plugin directory so older versions are still usable after re-installing --- scripts/pkg.ts | 25 ++++++++++++++++++- .../http-routes/handlers/refresh-handler.ts | 2 +- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/scripts/pkg.ts b/scripts/pkg.ts index 74ca03ff..557fbea0 100644 --- a/scripts/pkg.ts +++ b/scripts/pkg.ts @@ -1,6 +1,7 @@ import chalk from 'chalk' import { execSync } from 'node:child_process' import fs from 'node:fs/promises' +import path from 'node:path'; console.log(chalk.magenta('Removing everything in ./.build except tmp')) await fs.readdir('./.build') @@ -28,7 +29,8 @@ console.log(chalk.magenta('Install production dependencies')) execSync('npm install --production', { cwd: './.build', shell: 'zsh' }) console.log(chalk.magenta('Running oclif pkg macos')) -execSync('oclif pack macos -r .', { cwd: './.build', shell: 'zsh' }) +execSync('oclif pack macos -r .', { cwd: './.build', shell: 'zsh' }); +await patchMacOsInstallers() console.log(chalk.magenta('Running oclif pkg tarballs')) execSync('oclif pack tarballs -r . -t darwin-arm64,darwin-x64', { cwd: './.build', shell: 'zsh' }) @@ -44,3 +46,24 @@ async function ignoreError(fn: () => Promise | any): Promise { } catch (e) { } } + +// Oclif has a bug where the installer doesn't clear out the auto-updater location. This causes older versions +// to be re-used even with a clean install +async function patchMacOsInstallers() { + console.log(chalk.magenta('Patching MacOS installers with bug fix')) + + const pkgFolder = './.build/dist/macos'; + const files = await fs.readdir(pkgFolder) + const pkgFiles = files.filter((name) => name.endsWith('.pkg')) + + for (const pkgFile of pkgFiles) { + const pkgPath = path.join(pkgFolder, pkgFile); + const tmpPath = path.join(pkgFolder, 'tmp'); + + execSync(`pkgutil --expand ${pkgPath} ${tmpPath}`) + await fs.appendFile(path.join(tmpPath, 'Scripts', 'preinstall'), '\nsudo rm -rf ~/.local/share/codify', 'utf8'); + execSync(`pkgutil --flatten ${tmpPath} ${pkgPath} `) + execSync(`rm -rf ${tmpPath}`); + console.log(chalk.magenta(`Done patching installer ${pkgFile}`)) + } +} diff --git a/src/connect/http-routes/handlers/refresh-handler.ts b/src/connect/http-routes/handlers/refresh-handler.ts index e986873c..35e6b043 100644 --- a/src/connect/http-routes/handlers/refresh-handler.ts +++ b/src/connect/http-routes/handlers/refresh-handler.ts @@ -30,7 +30,7 @@ export function refreshHandler() { } if (type === RefreshType.REFRESH_SPECIFIC && (!resourceTypes || !Array.isArray(resourceTypes))) { - throw new Error('For refresh specific, a list of resource types must be provided'); + throw new Error(`For refresh specific, a list of resource types must be provided, received: ${resourceTypes}`); } if (!validator(codifyConfig)) { From 18f09c393be6445c4581c4ce7eaeddc720454993 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sun, 16 Nov 2025 22:55:38 -0500 Subject: [PATCH 37/67] feat: Added auto complete, additional version flags, additional help flags and set the default command to edit --- package.json | 6 +++++- scripts/pkg.ts | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 8e2020d0..f60396ec 100644 --- a/package.json +++ b/package.json @@ -106,9 +106,13 @@ "bin": "codify", "dirname": "codify", "commands": "./dist/commands", + "additionalVersionFlags": ["-v"], + "additionalHelpFlags": ["-h"], + "defaultCommand": "edit", "plugins": [ "@oclif/plugin-help", - "@oclif/plugin-update" + "@oclif/plugin-update", + "@oclif/plugin-autocomplete" ], "topicSeparator": " ", "topics": {}, diff --git a/scripts/pkg.ts b/scripts/pkg.ts index 557fbea0..98bf61fc 100644 --- a/scripts/pkg.ts +++ b/scripts/pkg.ts @@ -3,6 +3,11 @@ import { execSync } from 'node:child_process' import fs from 'node:fs/promises' import path from 'node:path'; +// Create .build folder if it does not exist +try { + await fs.mkdir('./.build') +} catch (err) {} + console.log(chalk.magenta('Removing everything in ./.build except tmp')) await fs.readdir('./.build') .then((files) => From 1eee7d36e0206160391fbccf9fbad75ad1c6ae01 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Thu, 20 Nov 2025 09:01:43 -0500 Subject: [PATCH 38/67] fix: Added init command --- README.md | 2 +- src/commands/init.ts | 2 +- .../{handlers => }/create-command.ts | 2 +- .../http-routes/handlers/apply-handler.ts | 2 +- .../http-routes/handlers/import-handler.ts | 2 +- .../http-routes/handlers/init-handler.ts | 57 +++++++++++++++++++ .../http-routes/handlers/plan-handler.ts | 2 +- .../http-routes/handlers/refresh-handler.ts | 2 +- .../http-routes/handlers/terminal-handler.ts | 2 +- src/connect/http-routes/router.ts | 2 + src/orchestrators/init.ts | 3 +- 11 files changed, 69 insertions(+), 9 deletions(-) rename src/connect/http-routes/{handlers => }/create-command.ts (97%) create mode 100644 src/connect/http-routes/handlers/init-handler.ts diff --git a/README.md b/README.md index 157f4d6a..69dff13e 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ oclif example Hello World CLI $ npm install -g codify $ codify COMMAND running command... -$ codify (--version) +$ codify (--version|-v) codify/1.0.0 darwin-arm64 node-v22.19.0 $ codify --help [COMMAND] USAGE diff --git a/src/commands/init.ts b/src/commands/init.ts index 26d01e22..2881e141 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -17,7 +17,6 @@ For more information, visit: https://docs.codifycli.com/commands/init` static baseFlags= { ...BaseCommand.baseFlags, - path: { hidden: true } as any, } static override examples = [ @@ -29,6 +28,7 @@ For more information, visit: https://docs.codifycli.com/commands/init` await InitializeOrchestrator.run({ verbosityLevel: flags.debug ? 3 : 0, + path: flags.path, },this.reporter); process.exit(0) diff --git a/src/connect/http-routes/handlers/create-command.ts b/src/connect/http-routes/create-command.ts similarity index 97% rename from src/connect/http-routes/handlers/create-command.ts rename to src/connect/http-routes/create-command.ts index cbb1f1d1..f8d7004c 100644 --- a/src/connect/http-routes/handlers/create-command.ts +++ b/src/connect/http-routes/create-command.ts @@ -3,7 +3,7 @@ import chalk from 'chalk'; import { Router } from 'express'; import WebSocket from 'ws'; -import { Session, SocketServer } from '../../socket-server.js'; +import { Session, SocketServer } from '../socket-server.js'; export enum ConnectCommand { TERMINAL = 'terminal', diff --git a/src/connect/http-routes/handlers/apply-handler.ts b/src/connect/http-routes/handlers/apply-handler.ts index 96ab646f..dd27a7aa 100644 --- a/src/connect/http-routes/handlers/apply-handler.ts +++ b/src/connect/http-routes/handlers/apply-handler.ts @@ -8,7 +8,7 @@ import { WebSocket } from 'ws'; import { ConnectOrchestrator } from '../../../orchestrators/connect.js'; import { ajv } from '../../../utils/ajv.js'; import { Session } from '../../socket-server.js'; -import { ConnectCommand, createCommandHandler } from './create-command.js'; +import { ConnectCommand, createCommandHandler } from '../create-command.js'; const validator = ajv.compile(ConfigFileSchema); diff --git a/src/connect/http-routes/handlers/import-handler.ts b/src/connect/http-routes/handlers/import-handler.ts index 19fcb7d3..09e70788 100644 --- a/src/connect/http-routes/handlers/import-handler.ts +++ b/src/connect/http-routes/handlers/import-handler.ts @@ -9,7 +9,7 @@ import { WebSocket } from 'ws'; import { ConnectOrchestrator } from '../../../orchestrators/connect.js'; import { ajv } from '../../../utils/ajv.js'; import { Session, SocketServer } from '../../socket-server.js'; -import { ConnectCommand, createCommandHandler } from './create-command.js'; +import { ConnectCommand, createCommandHandler } from '../create-command.js'; enum ImportType { IMPORT = 'import', diff --git a/src/connect/http-routes/handlers/init-handler.ts b/src/connect/http-routes/handlers/init-handler.ts new file mode 100644 index 00000000..128640d7 --- /dev/null +++ b/src/connect/http-routes/handlers/init-handler.ts @@ -0,0 +1,57 @@ +import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; +import { diffChars } from 'diff'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { WebSocket } from 'ws'; + +import { ConnectOrchestrator } from '../../../orchestrators/connect.js'; +import { Session, SocketServer } from '../../socket-server.js'; +import { ConnectCommand, createCommandHandler } from '../create-command.js'; + +export function initHandler() { + const spawnCommand = async (body: Record, ws: WebSocket, session: Session) => { + const tmpDir = await fs.mkdtemp(os.tmpdir()); + const filePath = path.join(tmpDir, 'codify.jsonc'); + await fs.writeFile(filePath, '[]'); + session.additionalData.filePath = filePath; + session.additionalData.existingFile = '[]'; + + return spawn('zsh', ['-c', `${ConnectOrchestrator.nodeBinary} ${ConnectOrchestrator.rootCommand} init -p ${filePath}`], { + name: 'xterm-color', + cols: 80, + rows: 30, + cwd: process.env.HOME, + env: process.env + }); + } + + const onExit = async (exitCode: number, ws: WebSocket, session: Session) => { + if (session.additionalData.filePath) { + const updatedFile = await fs.readFile(session.additionalData.filePath as string, 'utf8') + + // Changes were found + if (diffChars(updatedFile, session.additionalData.existingFile as string).length > 0) { + console.log('Writing imported changes to Codify dashboard'); + + const ws = SocketServer.get().getMainConnection(session.clientId); + if (!ws) { + throw new Error(`Unable to find client for clientId ${session.clientId}`); + } + + ws.send(JSON.stringify({ key: 'new_init', data: { + updated: updatedFile, + } })) + } + + + await fs.rm(session.additionalData.filePath as string, { recursive: true, force: true }); + } + } + + return createCommandHandler({ + name: ConnectCommand.IMPORT, + spawnCommand, + onExit + }); +} diff --git a/src/connect/http-routes/handlers/plan-handler.ts b/src/connect/http-routes/handlers/plan-handler.ts index 0066de4d..7eefc2b5 100644 --- a/src/connect/http-routes/handlers/plan-handler.ts +++ b/src/connect/http-routes/handlers/plan-handler.ts @@ -8,7 +8,7 @@ import { WebSocket } from 'ws'; import { ConnectOrchestrator } from '../../../orchestrators/connect.js'; import { ajv } from '../../../utils/ajv.js'; import { Session } from '../../socket-server.js'; -import { ConnectCommand, createCommandHandler } from './create-command.js'; +import { ConnectCommand, createCommandHandler } from '../create-command.js'; const validator = ajv.compile(ConfigFileSchema); diff --git a/src/connect/http-routes/handlers/refresh-handler.ts b/src/connect/http-routes/handlers/refresh-handler.ts index 35e6b043..403f8536 100644 --- a/src/connect/http-routes/handlers/refresh-handler.ts +++ b/src/connect/http-routes/handlers/refresh-handler.ts @@ -9,7 +9,7 @@ import { WebSocket } from 'ws'; import { ConnectOrchestrator } from '../../../orchestrators/connect.js'; import { ajv } from '../../../utils/ajv.js'; import { Session, SocketServer } from '../../socket-server.js'; -import { ConnectCommand, createCommandHandler } from './create-command.js'; +import { ConnectCommand, createCommandHandler } from '../create-command.js'; enum RefreshType { REFRESH = 'refresh', diff --git a/src/connect/http-routes/handlers/terminal-handler.ts b/src/connect/http-routes/handlers/terminal-handler.ts index 52d6eb3c..3751d96b 100644 --- a/src/connect/http-routes/handlers/terminal-handler.ts +++ b/src/connect/http-routes/handlers/terminal-handler.ts @@ -1,4 +1,4 @@ -import { ConnectCommand, createCommandHandler } from './create-command.js'; +import { ConnectCommand, createCommandHandler } from '../create-command.js'; export function terminalHandler() { return createCommandHandler({ diff --git a/src/connect/http-routes/router.ts b/src/connect/http-routes/router.ts index b4b20e4c..82736575 100644 --- a/src/connect/http-routes/router.ts +++ b/src/connect/http-routes/router.ts @@ -3,6 +3,7 @@ import { Router } from 'express'; import { applyHandler } from './handlers/apply-handler.js'; import { importHandler } from './handlers/import-handler.js'; import defaultHandler from './handlers/index.js'; +import { initHandler } from './handlers/init-handler.js'; import { planHandler } from './handlers/plan-handler.js'; import { refreshHandler } from './handlers/refresh-handler.js'; import { terminalHandler } from './handlers/terminal-handler.js'; @@ -15,5 +16,6 @@ router.use('/plan', planHandler()) router.use('/import', importHandler()); router.use('/refresh', refreshHandler()); router.use('/terminal', terminalHandler()); +router.use('/init', initHandler()); export default router; diff --git a/src/orchestrators/init.ts b/src/orchestrators/init.ts index 0e9eb8c6..fc98650f 100644 --- a/src/orchestrators/init.ts +++ b/src/orchestrators/init.ts @@ -9,6 +9,7 @@ import { FileUtils } from '../utils/file.js'; import { resolvePathWithVariables, untildify } from '../utils/index.js'; export interface InitArgs { + path?: string; verbosityLevel?: number; } @@ -41,7 +42,7 @@ export const InitializeOrchestrator = { const userSelectedTypes = await reporter.promptInitResultSelection([...new Set(flattenedResults.map((r) => r!.core.type))]) ctx.log('Resource types were chosen to be imported.') - const locationToSave = await this.promptSaveLocation(reporter); + const locationToSave = args.path ?? await this.promptSaveLocation(reporter); ctx.log(`Save results to ${locationToSave}`) await reporter.hide(); From aea15cb11c82b97f111bb0a7cf74334f8d49fc02 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Thu, 20 Nov 2025 23:38:17 -0500 Subject: [PATCH 39/67] Added new finish event to unify sending the remote result back to the dashboard --- src/connect/http-routes/create-command.ts | 10 +++++++--- src/connect/http-routes/handlers/import-handler.ts | 6 +----- src/connect/http-routes/handlers/init-handler.ts | 8 ++------ src/connect/http-routes/handlers/refresh-handler.ts | 13 ++----------- 4 files changed, 12 insertions(+), 25 deletions(-) diff --git a/src/connect/http-routes/create-command.ts b/src/connect/http-routes/create-command.ts index f8d7004c..e41fe445 100644 --- a/src/connect/http-routes/create-command.ts +++ b/src/connect/http-routes/create-command.ts @@ -17,7 +17,7 @@ interface Params { name: ConnectCommand; command?: string[]; spawnCommand?: (body: Record, ws: WebSocket, session: Session) => IPty | Promise; - onExit?: (exitCode: number, ws: WebSocket, session: Session) => Promise | void; + onExit?: (exitCode: number, ws: WebSocket, session: Session) => Promise | undefined | void>; } export function createCommandHandler({ name, command, spawnCommand, onExit }: Params): Router { @@ -47,7 +47,7 @@ export function createCommandHandler({ name, command, spawnCommand, onExit }: Pa return res.status(400).json({ error: 'SessionId does not exist' }); } - const { ws, server } = session; + const { ws, server, clientId } = session; if (!ws) { return res.status(400).json({ error: 'SessionId not open' }); } @@ -80,7 +80,11 @@ export function createCommandHandler({ name, command, spawnCommand, onExit }: Pa console.log(`Command ${name} exited with exit code`, exitCode); ws.send(Buffer.from(chalk.blue(`Session ended exit code ${exitCode}`), 'utf8')) - await onExit?.(exitCode, ws, session) + const mainWs = SocketServer.get().getMainConnection(clientId); + if (mainWs) { + const data = await onExit?.(exitCode, ws, session) ?? {} + mainWs.send(JSON.stringify({ key: `finish:${sessionId}`, success: exitCode === 0, data })) // Send finish command only if client connection is still open + } ws.terminate(); server.close(); diff --git a/src/connect/http-routes/handlers/import-handler.ts b/src/connect/http-routes/handlers/import-handler.ts index 09e70788..3323f03e 100644 --- a/src/connect/http-routes/handlers/import-handler.ts +++ b/src/connect/http-routes/handlers/import-handler.ts @@ -76,14 +76,10 @@ export function importHandler() { if (!ws) { throw new Error(`Unable to find client for clientId ${session.clientId}`); } - - ws.send(JSON.stringify({ key: 'new_import', data: { - updated: updatedFile, - } })) } - await fs.rm(session.additionalData.filePath as string, { recursive: true, force: true }); + return { updated: updatedFile } } } diff --git a/src/connect/http-routes/handlers/init-handler.ts b/src/connect/http-routes/handlers/init-handler.ts index 128640d7..dd8101ad 100644 --- a/src/connect/http-routes/handlers/init-handler.ts +++ b/src/connect/http-routes/handlers/init-handler.ts @@ -39,13 +39,9 @@ export function initHandler() { throw new Error(`Unable to find client for clientId ${session.clientId}`); } - ws.send(JSON.stringify({ key: 'new_init', data: { - updated: updatedFile, - } })) + await fs.rm(session.additionalData.filePath as string, { recursive: true, force: true }); + return { updated: updatedFile }; } - - - await fs.rm(session.additionalData.filePath as string, { recursive: true, force: true }); } } diff --git a/src/connect/http-routes/handlers/refresh-handler.ts b/src/connect/http-routes/handlers/refresh-handler.ts index 403f8536..3091e144 100644 --- a/src/connect/http-routes/handlers/refresh-handler.ts +++ b/src/connect/http-routes/handlers/refresh-handler.ts @@ -72,18 +72,9 @@ export function refreshHandler() { if (diffChars(updatedFile, session.additionalData.existingFile as string).length > 0) { console.log('Writing imported changes to Codify dashboard'); - const ws = SocketServer.get().getMainConnection(session.clientId); - if (!ws) { - throw new Error(`Unable to find client for clientId ${session.clientId}`); - } - - ws.send(JSON.stringify({ key: 'new_import', data: { - updated: updatedFile, - } })) + await fs.rm(session.additionalData.filePath as string, { recursive: true, force: true }); + return { updated: updatedFile }; } - - - await fs.rm(session.additionalData.filePath as string, { recursive: true, force: true }); } } From f45f0eacb6b88cd3b01d08cfb13956281e870979 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Thu, 20 Nov 2025 23:38:40 -0500 Subject: [PATCH 40/67] Added the useful ability to kill the previous codify connect so you don't have to go find it --- package-lock.json | 41 +++++++++++++++++++++ package.json | 10 ++++-- src/commands/connect.ts | 2 +- src/commands/edit.ts | 2 +- src/events/context.ts | 1 + src/orchestrators/connect.ts | 51 ++++++++++++++++++++------- src/orchestrators/edit.ts | 7 ++-- src/ui/reporters/default-reporter.tsx | 1 + tsconfig.json | 1 + 9 files changed, 97 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index a99892c9..3fa952e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "@types/jju": "^1.4.5", "@types/js-yaml": "^4.0.9", "@types/json5": "^2.2.0", + "@types/kill-port": "^2.0.3", "@types/mocha": "^10.0.10", "@types/node": "^20", "@types/react": "^18.3.1", @@ -80,6 +81,7 @@ "eslint-config-oclif-typescript": "^3.1.13", "eslint-config-prettier": "^9.0.0", "ink-testing-library": "^4.0.0", + "kill-port": "^2.0.1", "memfs": "^4.14.0", "mocha": "^10", "oclif": "^4.15.29", @@ -4457,6 +4459,17 @@ "json5": "*" } }, + "node_modules/@types/kill-port": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/kill-port/-/kill-port-2.0.3.tgz", + "integrity": "sha512-ZHs59e5FBjDLQLOxM48+814LSyNf5sgpi0odtJ0FH6xrIAZXb4yksYG+4mZCbidX3fBOfHytAKAVMgkWvv/Piw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "shell-exec": "^1" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -8947,6 +8960,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-them-args": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/get-them-args/-/get-them-args-1.3.2.tgz", + "integrity": "sha512-LRn8Jlk+DwZE4GTlDbT3Hikd1wSHgLMme/+7ddlqKd7ldwR6LjJgTVWzBnR01wnYGe4KgrXjg287RaI22UHmAw==", + "dev": true, + "license": "MIT" + }, "node_modules/get-tsconfig": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.8.1.tgz", @@ -10673,6 +10693,20 @@ "json-buffer": "3.0.1" } }, + "node_modules/kill-port": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kill-port/-/kill-port-2.0.1.tgz", + "integrity": "sha512-e0SVOV5jFo0mx8r7bS29maVWp17qGqLBZ5ricNSajON6//kmb7qqqNnml4twNE8Dtj97UQD+gNFOaipS/q1zzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-them-args": "1.3.2", + "shell-exec": "1.0.2" + }, + "bin": { + "kill-port": "cli.js" + } + }, "node_modules/kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -12995,6 +13029,13 @@ "node": ">=8" } }, + "node_modules/shell-exec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/shell-exec/-/shell-exec-1.0.2.tgz", + "integrity": "sha512-jyVd+kU2X+mWKMmGhx4fpWbPsjvD53k9ivqetutVW/BQ+WIZoDoP4d8vUMGezV6saZsiNoW2f9GIhg9Dondohg==", + "dev": true, + "license": "MIT" + }, "node_modules/shell-quote": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", diff --git a/package.json b/package.json index f60396ec..baf43b0f 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@types/jju": "^1.4.5", "@types/js-yaml": "^4.0.9", "@types/json5": "^2.2.0", + "@types/kill-port": "^2.0.3", "@types/mocha": "^10.0.10", "@types/node": "^20", "@types/react": "^18.3.1", @@ -73,6 +74,7 @@ "eslint-config-oclif-typescript": "^3.1.13", "eslint-config-prettier": "^9.0.0", "ink-testing-library": "^4.0.0", + "kill-port": "^2.0.1", "memfs": "^4.14.0", "mocha": "^10", "oclif": "^4.15.29", @@ -106,8 +108,12 @@ "bin": "codify", "dirname": "codify", "commands": "./dist/commands", - "additionalVersionFlags": ["-v"], - "additionalHelpFlags": ["-h"], + "additionalVersionFlags": [ + "-v" + ], + "additionalHelpFlags": [ + "-h" + ], "defaultCommand": "edit", "plugins": [ "@oclif/plugin-help", diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 83712bfa..b0c8d86a 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -19,6 +19,6 @@ For more information, visit: https://docs.codifycli.com/commands/validate const { flags } = await this.parse(Connect) const config = this.config; - await ConnectOrchestrator.run(config); + await ConnectOrchestrator.run(config, this.reporter); } } diff --git a/src/commands/edit.ts b/src/commands/edit.ts index 70d71e8d..aa70e30f 100644 --- a/src/commands/edit.ts +++ b/src/commands/edit.ts @@ -21,7 +21,7 @@ For more information, visit: https://docs.codifycli.com/commands/validate const { flags } = await this.parse(Edit); const config = this.config; - await EditOrchestrator.run(config); + await EditOrchestrator.run(config, this.reporter); } } diff --git a/src/events/context.ts b/src/events/context.ts index d2835833..a59055ba 100644 --- a/src/events/context.ts +++ b/src/events/context.ts @@ -28,6 +28,7 @@ export enum ProcessName { IMPORT = 'import', REFRESH = 'refresh', INIT = 'init', + TERMINATE = 'terminate', } export enum SubProcessName { diff --git a/src/orchestrators/connect.ts b/src/orchestrators/connect.ts index e58aed7a..556f40aa 100644 --- a/src/orchestrators/connect.ts +++ b/src/orchestrators/connect.ts @@ -1,23 +1,27 @@ import { Config } from '@oclif/core'; import cors from 'cors'; import express, { json } from 'express'; +import killPort from 'kill-port'; import { randomBytes } from 'node:crypto'; +import { Server } from 'node:http'; import open from 'open'; import { config } from '../config.js'; import router from '../connect/http-routes/router.js'; import { LoginHelper } from '../connect/login-helper.js'; import { SocketServer } from '../connect/socket-server.js'; +import { ProcessName, ctx } from '../events/context.js'; +import { Reporter } from '../ui/reporters/reporter.js'; import { LoginOrchestrator } from './login.js'; export class ConnectOrchestrator { static rootCommand: string; static nodeBinary: string; - static async run(oclifConfig: Config, openBrowser = true, onOpen?: (connectionCode: string) => void) { + static async run(oclifConfig: Config, reporter: Reporter, openBrowser = true, onOpen?: (connectionCode: string) => void) { const login = LoginHelper.get()?.isLoggedIn; if (!login) { - console.log('User is not logged in. Attempting to log in...') + ctx.log('User is not logged in. Attempting to log in...') await LoginOrchestrator.run(); } @@ -31,16 +35,7 @@ export class ConnectOrchestrator { app.use(json()) app.use(router); - const server = app.listen(config.connectServerPort, (error) => { - if (error) { - if (error.message.includes('EADDRINUSE')) { - console.error('An instance of \'codify connect\' is already running.\n\nExiting...') - return; - } - - throw error; - } - + const server = await ConnectOrchestrator.listen(app, reporter, () => { if (openBrowser) { open(`${config.dashboardUrl}/connection/success?code=${connectionSecret}`) console.log(`Open browser window to store code. @@ -55,6 +50,38 @@ ${connectionSecret}`) SocketServer.init(server, connectionSecret); } + private static listen(app: express.Application, reporter: Reporter, onOpen: () => void): Promise { + return new Promise((resolve) => { + const server = app.listen(config.connectServerPort, async (error) => { + if (error) { + if (error.message.includes('EADDRINUSE')) { + const ifTerminate = await reporter.promptConfirmation('An instance of \'codify connect\' is already running. Do you want to terminate the existing instance and continue?'); + + if (!ifTerminate) { + console.error('\n\nExiting...') + process.exit(1); + } + + ctx.processStarted(ProcessName.TERMINATE) + await reporter.displayProgress(); + await killPort(config.connectServerPort); + ctx.processFinished(ProcessName.TERMINATE); + await reporter.hide(); + + setTimeout(() => { + ctx.log('Retrying connection...') + ConnectOrchestrator.listen(app, reporter, onOpen).then((server) => resolve(server)); + }, 300); + + } + } else { + resolve(server); + onOpen(); + } + }); + }); + } + private static tokenGenerate(bytes = 4): string { return Buffer.from(randomBytes(bytes)).toString('hex') } diff --git a/src/orchestrators/edit.ts b/src/orchestrators/edit.ts index cf5dd599..a74f764e 100644 --- a/src/orchestrators/edit.ts +++ b/src/orchestrators/edit.ts @@ -4,12 +4,13 @@ import open from 'open'; import { DashboardApiClient } from '../api/dashboard/index.js'; import { config } from '../config.js'; import { LoginHelper } from '../connect/login-helper.js'; +import { Reporter } from '../ui/reporters/reporter.js'; import { ConnectOrchestrator } from './connect.js'; import { LoginOrchestrator } from './login.js'; export class EditOrchestrator { - static async run(oclifConfig: Config) { + static async run(oclifConfig: Config, reporter: Reporter) { const login = LoginHelper.get()?.isLoggedIn; if (!login) { console.log('User is not logged in. Attempting to log in...') @@ -24,10 +25,10 @@ export class EditOrchestrator { } const url = defaultDocumentId - ? `${config.dashboardUrl}/file/${defaultDocumentId}` + ? `${config.dashboardUrl}/document/${defaultDocumentId}` : config.dashboardUrl; - await ConnectOrchestrator.run(oclifConfig, false, (code) => { + await ConnectOrchestrator.run(oclifConfig, reporter, false, (code) => { open(`${url}?connection_code=${code}`); console.log( `Opening default Codify file: diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index b60658fe..6bf6768f 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -25,6 +25,7 @@ const ProgressLabelMapping = { [ProcessName.REFRESH]: 'Codify refresh', [ProcessName.IMPORT]: 'Codify import', [ProcessName.INIT]: 'Codify init', + [ProcessName.TERMINATE]: 'Attempting to terminate existing instance', [SubProcessName.APPLYING_RESOURCE]: 'Applying resource', [SubProcessName.GENERATE_PLAN]: 'Refresh states and generating plan', [SubProcessName.INITIALIZE_PLUGINS]: 'Initializing plugins', diff --git a/tsconfig.json b/tsconfig.json index e8c6dd8d..12e5702f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "moduleResolution": "NodeNext", "esModuleInterop": true, "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, "jsx": "react", "outDir": "dist", "rootDir": "src", From 82637e0945195c5d715c266c339795d28defe425 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sat, 22 Nov 2025 15:42:24 -0500 Subject: [PATCH 41/67] fix: Moved kill port to dependencies --- package-lock.json | 5 +---- package.json | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3fa952e4..fc2bd20e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "js-yaml-source-map": "^0.2.2", "json-source-map": "^0.6.1", "json5": "^2.2.3", + "kill-port": "^2.0.1", "latest-semver": "^4.0.0", "nanoid": "^5.0.9", "open": "^10.1.2", @@ -81,7 +82,6 @@ "eslint-config-oclif-typescript": "^3.1.13", "eslint-config-prettier": "^9.0.0", "ink-testing-library": "^4.0.0", - "kill-port": "^2.0.1", "memfs": "^4.14.0", "mocha": "^10", "oclif": "^4.15.29", @@ -8964,7 +8964,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/get-them-args/-/get-them-args-1.3.2.tgz", "integrity": "sha512-LRn8Jlk+DwZE4GTlDbT3Hikd1wSHgLMme/+7ddlqKd7ldwR6LjJgTVWzBnR01wnYGe4KgrXjg287RaI22UHmAw==", - "dev": true, "license": "MIT" }, "node_modules/get-tsconfig": { @@ -10697,7 +10696,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/kill-port/-/kill-port-2.0.1.tgz", "integrity": "sha512-e0SVOV5jFo0mx8r7bS29maVWp17qGqLBZ5ricNSajON6//kmb7qqqNnml4twNE8Dtj97UQD+gNFOaipS/q1zzQ==", - "dev": true, "license": "MIT", "dependencies": { "get-them-args": "1.3.2", @@ -13033,7 +13031,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/shell-exec/-/shell-exec-1.0.2.tgz", "integrity": "sha512-jyVd+kU2X+mWKMmGhx4fpWbPsjvD53k9ivqetutVW/BQ+WIZoDoP4d8vUMGezV6saZsiNoW2f9GIhg9Dondohg==", - "dev": true, "license": "MIT" }, "node_modules/shell-quote": { diff --git a/package.json b/package.json index baf43b0f..4ec509ce 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "js-yaml-source-map": "^0.2.2", "json-source-map": "^0.6.1", "json5": "^2.2.3", + "kill-port": "^2.0.1", "latest-semver": "^4.0.0", "nanoid": "^5.0.9", "open": "^10.1.2", @@ -74,7 +75,6 @@ "eslint-config-oclif-typescript": "^3.1.13", "eslint-config-prettier": "^9.0.0", "ink-testing-library": "^4.0.0", - "kill-port": "^2.0.1", "memfs": "^4.14.0", "mocha": "^10", "oclif": "^4.15.29", From a4f3bd83a94132450433a7aea207bc0a3880bac7 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Tue, 2 Dec 2025 23:12:35 -0500 Subject: [PATCH 42/67] feat: Added connection start time and connection termination --- src/connect/socket-server.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/connect/socket-server.ts b/src/connect/socket-server.ts index 1eb30840..ee725680 100644 --- a/src/connect/socket-server.ts +++ b/src/connect/socket-server.ts @@ -25,6 +25,7 @@ export class SocketServer { private server: HttpServer; private connectionSecret: string; + private startTimestamp = new Date(); private mainConnections = new Map(); // These are per webpage private sessions = new Map(); @@ -124,7 +125,14 @@ export class SocketServer { private handleClientConnected = (ws: WebSocket) => { const clientId = uuid(); this.mainConnections.set(clientId, ws); - ws.send(JSON.stringify({ key: 'opened', data: { clientId } })) + ws.send(JSON.stringify({ key: 'opened', data: { clientId, startTimestamp: this.startTimestamp.toISOString() } })); + + ws.on('message', (message) => { + const data = JSON.parse(message.toString('utf8')); + if (data.key === 'terminate') { + process.exit(0); + } + }); ws.on('close', () => { this.mainConnections.delete(clientId); From 317dfe35f9a13cf83db0f5bbf65e4d7321effe3e Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sat, 13 Dec 2025 14:03:37 -0500 Subject: [PATCH 43/67] feat: Refactored the initialization process to return a resource definition. We can put info in here in the future if needed. Right now return if a resource has any sensitive parameters --- package-lock.json | 8 +++---- package.json | 2 +- src/common/initialize-plugins.ts | 15 ++++++------- src/entities/project.ts | 14 ++++++------ src/orchestrators/destroy.ts | 22 +++++++++---------- src/orchestrators/import.ts | 18 +++++++-------- src/orchestrators/init.ts | 4 ++-- src/orchestrators/plan.ts | 4 ++-- src/orchestrators/validate.ts | 4 ++-- src/plugins/plugin-manager.ts | 18 +++++++-------- src/plugins/plugin.ts | 5 ----- .../initialize/initialize.test.ts | 4 ++-- 12 files changed, 55 insertions(+), 63 deletions(-) diff --git a/package-lock.json b/package-lock.json index fc2bd20e..6834e26c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "chalk": "^5.3.0", - "codify-schemas": "^1.0.77", + "codify-schemas": "^1.0.81", "cors": "^2.8.5", "debug": "^4.3.4", "detect-indent": "^7.0.1", @@ -6375,9 +6375,9 @@ } }, "node_modules/codify-schemas": { - "version": "1.0.77", - "resolved": "https://registry.npmjs.org/codify-schemas/-/codify-schemas-1.0.77.tgz", - "integrity": "sha512-Xv4M/2k9e6pIbrI6NGDeUYsRjBHET45x0ygBxhpgLcIgGGeNnWVi5/Mh1mTn+TOwGmrAw3bTRsQCpDLmtfJeGA==", + "version": "1.0.81", + "resolved": "https://registry.npmjs.org/codify-schemas/-/codify-schemas-1.0.81.tgz", + "integrity": "sha512-7O7PoAVAaObzXDs96UULE6c9wuznOfddIzFU9MpKFbHB9DGFe4zDNGH+yFQ/VrGqv/tfK55c7m6u+hAfd0bong==", "license": "ISC", "dependencies": { "ajv": "^8.12.0" diff --git a/package.json b/package.json index 4ec509ce..19e975a3 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "chalk": "^5.3.0", - "codify-schemas": "^1.0.77", + "codify-schemas": "^1.0.81", "cors": "^2.8.5", "debug": "^4.3.4", "detect-indent": "^7.0.1", diff --git a/src/common/initialize-plugins.ts b/src/common/initialize-plugins.ts index c4bb8912..1af0efcf 100644 --- a/src/common/initialize-plugins.ts +++ b/src/common/initialize-plugins.ts @@ -1,16 +1,15 @@ import * as fs from 'node:fs/promises' -import * as os from 'node:os' import * as path from 'node:path' +import { validate } from 'uuid'; +import { DashboardApiClient } from '../api/dashboard/index.js'; +import { LoginHelper } from '../connect/login-helper.js'; import { Project } from '../entities/project.js'; import { SubProcessName, ctx } from '../events/context.js'; import { CODIFY_FILE_REGEX, CodifyParser } from '../parser/index.js'; -import { DependencyMap, PluginManager } from '../plugins/plugin-manager.js'; +import { PluginManager, ResourceDefinitionMap } from '../plugins/plugin-manager.js'; import { Reporter } from '../ui/reporters/reporter.js'; -import { LoginHelper } from '../connect/login-helper.js'; import { FileUtils } from '../utils/file.js'; -import { validate } from 'uuid'; -import { DashboardApiClient } from '../api/dashboard/index.js'; export interface InitializeArgs { path?: string; @@ -21,7 +20,7 @@ export interface InitializeArgs { } export interface InitializationResult { - typeIdsToDependenciesMap: DependencyMap + resourceDefinitions: ResourceDefinitionMap pluginManager: PluginManager, project: Project, } @@ -42,10 +41,10 @@ export class PluginInitOrchestrator { ctx.subprocessStarted(SubProcessName.INITIALIZE_PLUGINS) const pluginManager = new PluginManager(); - const typeIdsToDependenciesMap = await pluginManager.initialize(project, args.secure, args.verbosityLevel); + const resourceDefinitions = await pluginManager.initialize(project, args.secure, args.verbosityLevel); ctx.subprocessFinished(SubProcessName.INITIALIZE_PLUGINS) - return { typeIdsToDependenciesMap, pluginManager, project }; + return { resourceDefinitions, pluginManager, project }; } private static async parse( diff --git a/src/entities/project.ts b/src/entities/project.ts index 81bff0a4..7217cae9 100644 --- a/src/entities/project.ts +++ b/src/entities/project.ts @@ -4,7 +4,7 @@ import { validate } from 'uuid' import { PluginValidationError, PluginValidationErrorParams, TypeNotFoundError } from '../common/errors.js'; import { ctx } from '../events/context.js'; import { SourceMapCache } from '../parser/source-maps.js'; -import { DependencyMap } from '../plugins/plugin-manager.js'; +import { ResourceDefinitionMap } from '../plugins/plugin-manager.js'; import { DependencyGraphResolver } from '../utils/dependency-graph-resolver.js'; import { groupBy } from '../utils/index.js'; import { ConfigBlock, ConfigType } from './config.js'; @@ -155,16 +155,16 @@ ${JSON.stringify(projectConfigs, null, 2)}`); } } - validateTypeIds(resourceMap: Map) { - const invalidConfigs = this.resourceConfigs.filter((c) => !resourceMap.get(c.type)); + validateTypeIds(resourceDefinitions: ResourceDefinitionMap) { + const invalidConfigs = this.resourceConfigs.filter((c) => !resourceDefinitions.has(c.type)); if (invalidConfigs.length > 0) { throw new TypeNotFoundError(invalidConfigs, this.sourceMaps); } } - resolveDependenciesAndCalculateEvalOrder(dependencyMap?: DependencyMap) { - this.resolveResourceDependencies(dependencyMap); + resolveDependenciesAndCalculateEvalOrder(resourceDefinitions?: ResourceDefinitionMap) { + this.resolveResourceDependencies(resourceDefinitions); this.calculateEvaluationOrder(); } @@ -189,7 +189,7 @@ ${JSON.stringify(projectConfigs, null, 2)}`); ) ?? null; } - private resolveResourceDependencies(dependencyMap?: DependencyMap) { + private resolveResourceDependencies(resourceDefinitions?: ResourceDefinitionMap) { const resourceMap = new Map(this.resourceConfigs.map((r) => [r.id, r] as const)); for (const r of this.resourceConfigs) { @@ -198,7 +198,7 @@ ${JSON.stringify(projectConfigs, null, 2)}`); r.addDependenciesBasedOnParameters((id) => resourceMap.has(id)); // Plugin dependencies are soft dependencies. They only activate if the dependent resource is present. - r.addDependencies(dependencyMap?.get(r.type) + r.addDependencies(resourceDefinitions?.get(r.type)?.dependencies ?.filter((type) => [...resourceMap.values()].some((r) => r.type === type)) ?.flatMap((type) => [...resourceMap.values()].filter((r) => r.type === type).map((r) => r.id)) ?? [] ); diff --git a/src/orchestrators/destroy.ts b/src/orchestrators/destroy.ts index a00a1212..5dc12c85 100644 --- a/src/orchestrators/destroy.ts +++ b/src/orchestrators/destroy.ts @@ -4,7 +4,7 @@ import { Project } from '../entities/project.js'; import { ResourceConfig } from '../entities/resource-config.js'; import { ResourceInfo } from '../entities/resource-info.js'; import { ProcessName, SubProcessName, ctx } from '../events/context.js'; -import { DependencyMap, PluginManager } from '../plugins/plugin-manager.js'; +import { PluginManager, ResourceDefinitionMap } from '../plugins/plugin-manager.js'; import { PromptType, Reporter } from '../ui/reporters/reporter.js'; import { wildCardMatch } from '../utils/wild-card-match.js'; @@ -68,11 +68,11 @@ Open a new terminal or source '.zshrc' for the new changes to be reflected`); reporter: Reporter, initializeResult: InitializationResult ): Promise<{ plan: Plan, destroyProject: Project }> { - const { project, pluginManager, typeIdsToDependenciesMap } = initializeResult; + const { project, pluginManager, resourceDefinitions } = initializeResult; // TODO: In the future if a user supplies resourceId.name (naming a specific resource) destroy that resource instead of stripping the name out. - const matchedTypes = this.matchTypeIds(typeIds.map((id) => id.split('.').at(0) ?? ''), [...typeIdsToDependenciesMap.keys()]) - await DestroyOrchestrator.validateTypeIds(matchedTypes, project, pluginManager, typeIdsToDependenciesMap); + const matchedTypes = this.matchTypeIds(typeIds.map((id) => id.split('.').at(0) ?? ''), [...resourceDefinitions.keys()]) + await DestroyOrchestrator.validateTypeIds(matchedTypes, project, pluginManager, resourceDefinitions); const resourceInfoList = (await pluginManager.getMultipleResourceInfo(matchedTypes)); const resourcesToDestroy = await DestroyOrchestrator.getDestroyParameters(reporter, project, resourceInfoList); @@ -83,7 +83,7 @@ Open a new terminal or source '.zshrc' for the new changes to be reflected`); project.codifyFiles ).toDestroyProject(); - destroyProject.resolveDependenciesAndCalculateEvalOrder(typeIdsToDependenciesMap); + destroyProject.resolveDependenciesAndCalculateEvalOrder(resourceDefinitions); const plan = await ctx.subprocess(ProcessName.PLAN, () => pluginManager.plan(destroyProject) ) @@ -96,16 +96,16 @@ Open a new terminal or source '.zshrc' for the new changes to be reflected`); reporter: Reporter, initializeResult: InitializationResult ): Promise<{ plan: Plan, destroyProject: Project }> { - const { pluginManager, project, typeIdsToDependenciesMap } = initializeResult; + const { pluginManager, project, resourceDefinitions } = initializeResult; await ctx.subprocess(SubProcessName.VALIDATE, async () => { - project.validateTypeIds(typeIdsToDependenciesMap); + project.validateTypeIds(resourceDefinitions); const validationResults = await pluginManager.validate(project); project.handlePluginResourceValidationResults(validationResults); }) const destroyProject = project.toDestroyProject(); - destroyProject.resolveDependenciesAndCalculateEvalOrder(typeIdsToDependenciesMap); + destroyProject.resolveDependenciesAndCalculateEvalOrder(resourceDefinitions); const plan = await ctx.subprocess(ProcessName.PLAN, () => pluginManager.plan(destroyProject) @@ -147,10 +147,10 @@ ${JSON.stringify(unsupportedTypeIds)}`); return result; } - private static async validateTypeIds(typeIds: string[], project: Project, pluginManager: PluginManager, dependencyMap: DependencyMap): Promise { - project.validateTypeIds(dependencyMap); + private static async validateTypeIds(typeIds: string[], project: Project, pluginManager: PluginManager, resourceDefinitions: ResourceDefinitionMap): Promise { + project.validateTypeIds(resourceDefinitions); - const unsupportedTypeIds = typeIds.filter((type) => !dependencyMap.has(type)); + const unsupportedTypeIds = typeIds.filter((type) => !resourceDefinitions.has(type)); if (unsupportedTypeIds.length > 0) { throw new Error(`The following resources cannot be destroyed. No plugins found that support the following types: ${JSON.stringify(unsupportedTypeIds)}`); diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index 5a582000..9fb46e1e 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -13,7 +13,7 @@ import { FileModificationCalculator } from '../generators/file-modification-calc import { ModificationType } from '../generators/index.js'; import { FileUpdater } from '../generators/writer.js'; import { CodifyParser } from '../parser/index.js'; -import { DependencyMap, PluginManager } from '../plugins/plugin-manager.js'; +import { PluginManager, ResourceDefinitionMap } from '../plugins/plugin-manager.js'; import { prettyFormatFileDiff } from '../ui/file-diff-pretty-printer.js'; import { PromptType, Reporter } from '../ui/reporters/reporter.js'; import { FileUtils } from '../utils/file.js'; @@ -56,10 +56,10 @@ export class ImportOrchestrator { } static async autoImportAll(reporter: Reporter, initializeResult: InitializationResult, args: ImportArgs) { - const { project, pluginManager, typeIdsToDependenciesMap } = initializeResult; + const { project, pluginManager, resourceDefinitions } = initializeResult; ctx.subprocessStarted(SubProcessName.IMPORT_RESOURCE) - const importResults = await Promise.all([...typeIdsToDependenciesMap.keys()].map(async (typeId) => { + const importResults = await Promise.all([...resourceDefinitions.keys()].map(async (typeId) => { try { return await pluginManager.importResource({ core: { type: typeId }, @@ -98,10 +98,10 @@ export class ImportOrchestrator { /** Import new resources. Type ids supplied. This will ask for any required parameters */ static async runNewImport(typeIds: string[], reporter: Reporter, initializeResult: InitializationResult, args: ImportArgs): Promise { - const { project, pluginManager, typeIdsToDependenciesMap } = initializeResult; + const { project, pluginManager, resourceDefinitions } = initializeResult; - const matchedTypes = this.matchTypeIds(typeIds, [...typeIdsToDependenciesMap.keys()]) - await ImportOrchestrator.validate(matchedTypes, project, pluginManager, typeIdsToDependenciesMap); + const matchedTypes = this.matchTypeIds(typeIds, [...resourceDefinitions.keys()]) + await ImportOrchestrator.validate(matchedTypes, project, pluginManager, resourceDefinitions); const resourceInfoList = (await pluginManager.getMultipleResourceInfo(matchedTypes)) .filter((info) => info.canImport) @@ -364,10 +364,10 @@ ${JSON.stringify(unsupportedTypeIds)}`); return result; } - private static async validate(typeIds: string[], project: Project, pluginManager: PluginManager, dependencyMap: DependencyMap): Promise { - project.validateTypeIds(dependencyMap); + private static async validate(typeIds: string[], project: Project, pluginManager: PluginManager, resourceDefinitions: ResourceDefinitionMap): Promise { + project.validateTypeIds(resourceDefinitions); - const unsupportedTypeIds = typeIds.filter((type) => !dependencyMap.has(type)); + const unsupportedTypeIds = typeIds.filter((type) => !resourceDefinitions.has(type)); if (unsupportedTypeIds.length > 0) { throw new Error(`The following resources cannot be imported. No plugins found that support the following types: ${JSON.stringify(unsupportedTypeIds)}`); diff --git a/src/orchestrators/init.ts b/src/orchestrators/init.ts index fc98650f..90f45b9b 100644 --- a/src/orchestrators/init.ts +++ b/src/orchestrators/init.ts @@ -22,10 +22,10 @@ export const InitializeOrchestrator = { await reporter.displayProgress(); - const { pluginManager, typeIdsToDependenciesMap } = await PluginInitOrchestrator.run(args, reporter); + const { pluginManager, resourceDefinitions } = await PluginInitOrchestrator.run(args, reporter); ctx.subprocessStarted(SubProcessName.IMPORT_RESOURCE) - const importResults = await Promise.all([...typeIdsToDependenciesMap.keys()].map(async (typeId) => { + const importResults = await Promise.all([...resourceDefinitions.keys()].map(async (typeId) => { try { return await pluginManager.importResource({ core: { type: typeId }, diff --git a/src/orchestrators/plan.ts b/src/orchestrators/plan.ts index c23b4ce7..0823679c 100644 --- a/src/orchestrators/plan.ts +++ b/src/orchestrators/plan.ts @@ -27,12 +27,12 @@ export class PlanOrchestrator { const initializationResult = await PluginInitOrchestrator.run({ ...args, }, reporter); - const { typeIdsToDependenciesMap, pluginManager, project } = initializationResult; + const { resourceDefinitions, pluginManager, project } = initializationResult; await createStartupShellScriptsIfNotExists(); await ValidateOrchestrator.run({ existing: initializationResult }, reporter); - project.resolveDependenciesAndCalculateEvalOrder(typeIdsToDependenciesMap); + project.resolveDependenciesAndCalculateEvalOrder(resourceDefinitions); project.addXCodeToolsConfig(); // We have to add xcode-tools config always since almost every resource depends on it const plan = await PlanOrchestrator.plan(project, pluginManager); diff --git a/src/orchestrators/validate.ts b/src/orchestrators/validate.ts index c500ac2b..97991b4a 100644 --- a/src/orchestrators/validate.ts +++ b/src/orchestrators/validate.ts @@ -16,7 +16,7 @@ export const ValidateOrchestrator = { ): Promise { const { project, - typeIdsToDependenciesMap: dependencyMap, + resourceDefinitions, pluginManager, } = args.existing ?? await PluginInitOrchestrator.run(args, reporter) @@ -26,7 +26,7 @@ export const ValidateOrchestrator = { ctx.processStarted(SubProcessName.VALIDATE) } - project.validateTypeIds(dependencyMap); + project.validateTypeIds(resourceDefinitions); const validationResults = await pluginManager.validate(project); project.handlePluginResourceValidationResults(validationResults); diff --git a/src/plugins/plugin-manager.ts b/src/plugins/plugin-manager.ts index 9889183d..be9993d1 100644 --- a/src/plugins/plugin-manager.ts +++ b/src/plugins/plugin-manager.ts @@ -1,5 +1,5 @@ -import { - ImportResponseData, +import { + ImportResponseData, ResourceDefinition, ResourceJson, ValidateResponseData, } from 'codify-schemas'; @@ -16,7 +16,7 @@ import { PluginResolver } from './resolver.js'; type PluginName = string; type ResourceTypeId = string; -export type DependencyMap = Map; +export type ResourceDefinitionMap = Map; const DEFAULT_PLUGINS = { 'default': 'latest', @@ -28,7 +28,7 @@ export class PluginManager { private resourceToPluginMapping = new Map() private pluginToResourceMapping = new Map() - async initialize(project: Project | null, secureMode = false, verbosityLevel = 0): Promise { + async initialize(project: Project | null, secureMode = false, verbosityLevel = 0): Promise { const plugins = await this.resolvePlugins(project); for (const plugin of plugins) { @@ -36,9 +36,7 @@ export class PluginManager { } this.registerKillListeners(plugins) - - const dependencyMap = await this.initializePlugins(plugins, secureMode, verbosityLevel); - return dependencyMap; + return this.initializePlugins(plugins, secureMode, verbosityLevel); } async validate(project: Project): Promise { @@ -158,7 +156,7 @@ export class PluginManager { return PluginResolver.resolveAll(pluginDefinitions); } - private async initializePlugins(plugins: Plugin[], secureMode: boolean, verbosityLevel: number): Promise> { + private async initializePlugins(plugins: Plugin[], secureMode: boolean, verbosityLevel: number): Promise> { const responses = await Promise.all( plugins.map(async (p) => { const initializeResult = await p.initialize(secureMode, verbosityLevel); @@ -166,7 +164,7 @@ export class PluginManager { }) ); - const resourceMap = new Map; + const resourceMap = new Map(); for (const [pluginName, definitions] of responses) { for (const definition of definitions) { @@ -189,7 +187,7 @@ export class PluginManager { throw new Error(`Duplicated types between plugins ${this.resourceToPluginMapping.get(definition.type)} and ${pluginName}`); } - resourceMap.set(definition.type, definition.dependencies) + resourceMap.set(definition.type, definition) } } diff --git a/src/plugins/plugin.ts b/src/plugins/plugin.ts index c6521c45..a2b6610f 100644 --- a/src/plugins/plugin.ts +++ b/src/plugins/plugin.ts @@ -45,7 +45,6 @@ export class Plugin implements IPlugin { name: string; version: string; path: string; - resourceDependenciesMap = new Map() constructor(name: string, version: string, path: string) { this.name = name; @@ -62,10 +61,6 @@ export class Plugin implements IPlugin { throw new Error(`Invalid initialize response from plugin: ${this.name}`); } - for (const d of initializeResponse.data.resourceDefinitions) { - this.resourceDependenciesMap.set(d.type, d.dependencies) - } - return initializeResponse.data; } diff --git a/test/orchestrator/initialize/initialize.test.ts b/test/orchestrator/initialize/initialize.test.ts index 4afd2383..f97f1160 100644 --- a/test/orchestrator/initialize/initialize.test.ts +++ b/test/orchestrator/initialize/initialize.test.ts @@ -64,7 +64,7 @@ describe('Parser integration tests', () => { const cwdSpy = vi.spyOn(process, 'cwd'); cwdSpy.mockReturnValue(folder); - const { project, pluginManager, typeIdsToDependenciesMap } = await PluginInitOrchestrator.run({}, reporter); + const { project, pluginManager, resourceDefinitions } = await PluginInitOrchestrator.run({}, reporter); console.log(project); expect(project).toMatchObject({ @@ -109,7 +109,7 @@ describe('Parser integration tests', () => { const cwdSpy = vi.spyOn(process, 'cwd'); cwdSpy.mockReturnValue(innerFolder); - const { project, pluginManager, typeIdsToDependenciesMap } = await PluginInitOrchestrator.run({}, reporter); + const { project, pluginManager, resourceDefinitions } = await PluginInitOrchestrator.run({}, reporter); console.log(project); expect(project).toMatchObject({ From 46b031b7b34aa55531b2392c08365539a305b48b Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sat, 13 Dec 2025 15:04:59 -0500 Subject: [PATCH 44/67] feat: Added sensitive parameter filtering to import and init. Added sensitive parameter hiding to displaying plans --- package-lock.json | 8 ++++---- package.json | 2 +- src/commands/import.ts | 5 ++++- src/commands/init.ts | 5 +++++ src/entities/plan.ts | 1 + src/orchestrators/import.ts | 9 +++++++-- src/orchestrators/init.ts | 8 +++++++- src/ui/plan-pretty-printer.ts | 37 +++++++++++++++++++++++------------ 8 files changed, 53 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6834e26c..e1af0257 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "chalk": "^5.3.0", - "codify-schemas": "^1.0.81", + "codify-schemas": "^1.0.83", "cors": "^2.8.5", "debug": "^4.3.4", "detect-indent": "^7.0.1", @@ -6375,9 +6375,9 @@ } }, "node_modules/codify-schemas": { - "version": "1.0.81", - "resolved": "https://registry.npmjs.org/codify-schemas/-/codify-schemas-1.0.81.tgz", - "integrity": "sha512-7O7PoAVAaObzXDs96UULE6c9wuznOfddIzFU9MpKFbHB9DGFe4zDNGH+yFQ/VrGqv/tfK55c7m6u+hAfd0bong==", + "version": "1.0.83", + "resolved": "https://registry.npmjs.org/codify-schemas/-/codify-schemas-1.0.83.tgz", + "integrity": "sha512-QNMYhOt/V78Z+F1eLsSodNcmn8dLhLGlWa3rEzrBl7Ah8E2hylmsK7dEjkBWytoqEB2DISt8RuULG/FmL32/0g==", "license": "ISC", "dependencies": { "ajv": "^8.12.0" diff --git a/package.json b/package.json index 19e975a3..1daa341c 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "chalk": "^5.3.0", - "codify-schemas": "^1.0.81", + "codify-schemas": "^1.0.83", "cors": "^2.8.5", "debug": "^4.3.4", "detect-indent": "^7.0.1", diff --git a/src/commands/import.ts b/src/commands/import.ts index 0b4f253e..b183da1e 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -49,6 +49,9 @@ For more information, visit: https://docs.codifycli.com/commands/import` 'updateExisting': Flags.boolean({ description: 'Force the CLI to try to update an existing file instead of prompting the user with the option of creating a new file', }), + 'includeSensitive': Flags.boolean({ + description: 'Allow the import of resources with sensitive parameters. This only applies when a resource id hasn\'t been explicitly included. Otherwise, this parameter will be ignored.', + }), } public async run(): Promise { @@ -70,8 +73,8 @@ For more information, visit: https://docs.codifycli.com/commands/import` verbosityLevel: flags.debug ? 3 : 0, typeIds: cleanedArgs, path: resolvedPath, - secureMode: flags.secure, updateExisting: flags.updateExisting, + includeSensitive: flags.includeSensitive, }, this.reporter) process.exit(0) diff --git a/src/commands/init.ts b/src/commands/init.ts index 2881e141..50f1e6f5 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -2,6 +2,7 @@ import chalk from 'chalk'; import { BaseCommand } from '../common/base-command.js'; import { InitializeOrchestrator } from '../orchestrators/init.js'; +import { Flags } from '@oclif/core'; export default class Init extends BaseCommand { static strict = false; @@ -17,6 +18,9 @@ For more information, visit: https://docs.codifycli.com/commands/init` static baseFlags= { ...BaseCommand.baseFlags, + includeSensitive: Flags.boolean({ + description: 'Include sensitive resources in the generated configs.', + }), } static override examples = [ @@ -29,6 +33,7 @@ For more information, visit: https://docs.codifycli.com/commands/init` await InitializeOrchestrator.run({ verbosityLevel: flags.debug ? 3 : 0, path: flags.path, + includeSensitive: flags.includeSensitive, },this.reporter); process.exit(0) diff --git a/src/entities/plan.ts b/src/entities/plan.ts index b8a38533..387c0226 100644 --- a/src/entities/plan.ts +++ b/src/entities/plan.ts @@ -73,6 +73,7 @@ export class ResourcePlan { newValue: null | unknown; operation: ParameterOperation; previousValue: null | unknown; + isSensitive?: boolean; }> constructor(json: PlanResponseData) { diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index 9fb46e1e..65309a92 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -27,7 +27,7 @@ export interface ImportArgs { typeIds?: string[]; path: string; updateExisting?: boolean; - secureMode?: boolean; + includeSensitive?: boolean; verbosityLevel?: number; } @@ -59,7 +59,12 @@ export class ImportOrchestrator { const { project, pluginManager, resourceDefinitions } = initializeResult; ctx.subprocessStarted(SubProcessName.IMPORT_RESOURCE) - const importResults = await Promise.all([...resourceDefinitions.keys()].map(async (typeId) => { + + // Omit sensitive resources if not included + const typeIdsToImport = [...resourceDefinitions.keys()] + .filter((typeId) => args.includeSensitive || (!args.includeSensitive && (resourceDefinitions.get(typeId)?.sensitiveParameters ?? []).length === 0)) + + const importResults = await Promise.all(typeIdsToImport.map(async (typeId) => { try { return await pluginManager.importResource({ core: { type: typeId }, diff --git a/src/orchestrators/init.ts b/src/orchestrators/init.ts index 90f45b9b..e88b1860 100644 --- a/src/orchestrators/init.ts +++ b/src/orchestrators/init.ts @@ -11,6 +11,7 @@ import { resolvePathWithVariables, untildify } from '../utils/index.js'; export interface InitArgs { path?: string; verbosityLevel?: number; + includeSensitive?: boolean; } export const InitializeOrchestrator = { @@ -25,7 +26,12 @@ export const InitializeOrchestrator = { const { pluginManager, resourceDefinitions } = await PluginInitOrchestrator.run(args, reporter); ctx.subprocessStarted(SubProcessName.IMPORT_RESOURCE) - const importResults = await Promise.all([...resourceDefinitions.keys()].map(async (typeId) => { + + // Omit sensitive resources if not included + const typeIdsToImport = [...resourceDefinitions.keys()] + .filter((typeId) => args.includeSensitive || (!args.includeSensitive && (resourceDefinitions.get(typeId)?.sensitiveParameters ?? []).length === 0)) + + const importResults = await Promise.all(typeIdsToImport.map(async (typeId) => { try { return await pluginManager.importResource({ core: { type: typeId }, diff --git a/src/ui/plan-pretty-printer.ts b/src/ui/plan-pretty-printer.ts index 1c33b172..6377a75e 100644 --- a/src/ui/plan-pretty-printer.ts +++ b/src/ui/plan-pretty-printer.ts @@ -48,9 +48,10 @@ function prettyFormatCreatePlan(plan: ResourcePlan): string { return result; } + const value = parameter.isSensitive ? '[Sensitive]' : parameter.newValue; result[parameter.name] = typeof parameter.newValue === 'string' - ? escapeNewlines(parameter.newValue) - : parameter.newValue; + ? escapeNewlines(value as string) + : value; return result; }, {} as Record) @@ -69,9 +70,10 @@ function prettyFormatDestroyPlan(plan: ResourcePlan): string { return result; } + const value = parameter.isSensitive ? '[Sensitive]' : parameter.previousValue; result[parameter.name] = typeof parameter.previousValue === 'string' - ? escapeNewlines(parameter.previousValue) - : parameter.previousValue; + ? escapeNewlines(value as string) + : value; return result; }, {} as Record) @@ -89,11 +91,11 @@ function prettyFormatModifyPlan(plan: ResourcePlan): string { ]; for (const parameter of plan.parameters) { - // TODO: Add support for object types as well in the future if ((Array.isArray(parameter.previousValue) || parameter.previousValue === null) && (Array.isArray(parameter.newValue) || parameter.newValue === null) && !(parameter.previousValue === null && parameter.newValue === null) + && !parameter.isSensitive ) { const line = formatArray(parameter); builder.push(line); @@ -121,27 +123,36 @@ function escapeNewlines(str: string): string { function formatParameter(parameter: PlanResponseData['parameters'][0]): string { switch (parameter.operation) { case ParameterOperation.NOOP: { + const value = parameter.isSensitive ? '[Sensitive]' : parameter.newValue; + return typeof parameter.newValue === 'string' - ? `"${parameter.name}": "${escapeNewlines(parameter.newValue)}",` - : `"${parameter.name}": ${parameter.newValue},` + ? `"${parameter.name}": "${escapeNewlines(value as string)}",` + : `"${parameter.name}": ${value},` } case ParameterOperation.ADD: { + const value = parameter.isSensitive ? '[Sensitive]' : parameter.newValue; + return typeof parameter.newValue === 'string' - ? chalk.green(`"${parameter.name}": "${escapeNewlines(parameter.newValue)}",`) - : chalk.green(`"${parameter.name}": ${parameter.newValue},`) + ? chalk.green(`"${parameter.name}": "${escapeNewlines(value as string)}",`) + : chalk.green(`"${parameter.name}": ${value},`) } case ParameterOperation.REMOVE: { + const value = parameter.isSensitive ? '[Sensitive]' : parameter.previousValue; + return typeof parameter.previousValue === 'string' - ? chalk.red(`"${parameter.name}": "${escapeNewlines(parameter.previousValue)}",`) - : chalk.red(`"${parameter.name}": ${parameter.previousValue},`) + ? chalk.red(`"${parameter.name}": "${escapeNewlines(value as string)}",`) + : chalk.red(`"${parameter.name}": ${value},`) } case ParameterOperation.MODIFY: { + const newValue = parameter.isSensitive ? '[Sensitive]' : parameter.newValue; + const previousValue = parameter.isSensitive ? '[Sensitive]' : parameter.previousValue; + return typeof parameter.newValue === 'string' && typeof parameter.previousValue === 'string' - ? `"${parameter.name}": "${escapeNewlines(parameter.previousValue)}" -> "${escapeNewlines(parameter.newValue)}",` - : `"${parameter.name}": ${parameter.previousValue} -> ${parameter.newValue},` + ? `"${parameter.name}": "${escapeNewlines(previousValue as string)}" -> "${escapeNewlines(newValue as string)}",` + : `"${parameter.name}": ${previousValue} -> ${newValue},` } } } From eafd8dfdfc6390ffc0bc55265e76a8b998d68a89 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sat, 13 Dec 2025 15:23:19 -0500 Subject: [PATCH 45/67] feat: Added improvements to import and init saving of results --- src/orchestrators/import.ts | 8 ++------ src/orchestrators/init.ts | 8 +++++--- src/ui/components/default-component.tsx | 2 +- src/ui/reporters/default-reporter.tsx | 4 ++-- src/ui/reporters/plain-reporter.ts | 2 +- src/ui/reporters/reporter.ts | 2 +- test/orchestrator/mocks/reporter.ts | 10 +++++----- 7 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index 65309a92..a1e6c15d 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -470,11 +470,7 @@ ${JSON.stringify(unsupportedTypeIds)}`); private static async generateNewImportFileName(): Promise { const cwd = process.cwd(); - // Save codify to a new folder so it doesn't interfere with the current project - const folderPath = path.join(cwd, 'codify-imports') - await FileUtils.createFolder(folderPath) - - let fileName = path.join(folderPath, 'import.codify.jsonc') + let fileName = path.join(cwd, 'import.codify.jsonc') let counter = 1; while (true) { @@ -482,7 +478,7 @@ ${JSON.stringify(unsupportedTypeIds)}`); return fileName; } - fileName = path.join(folderPath, `import-${counter}.codify.jsonc`); + fileName = path.join(cwd, `import-${counter}.codify.jsonc`); counter++; } } diff --git a/src/orchestrators/init.ts b/src/orchestrators/init.ts index e88b1860..6f95ed26 100644 --- a/src/orchestrators/init.ts +++ b/src/orchestrators/init.ts @@ -1,4 +1,5 @@ import chalk from 'chalk'; +import { tildify } from 'codify-plugin-lib'; import path from 'node:path'; import { PluginInitOrchestrator } from '../common/initialize-plugins.js'; @@ -82,10 +83,11 @@ Enjoy! while (!isValidSaveLocation) { input = (await reporter.promptInput( - `Where to save the new Codify configs? ${chalk.grey.dim('(leave blank for ~/codify.jsonc)')}`, - error ? `Invalid location: ${input} already exists` : undefined) + `Where to save the new Codify configs? ${chalk.grey.dim(`(leave blank for ${tildify(process.cwd())}/codify.jsonc)`)}`, + error ? `Invalid location: ${input} already exists` : undefined, + `${tildify(process.cwd())}/codify.jsonc`) ) - input = input ? input : '~/codify.jsonc'; + input = input ?? `${process.cwd()}/codify.jsonc`; locationToSave = path.resolve(untildify(resolvePathWithVariables(input))); diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 8b21dbd2..4ac98766 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -158,7 +158,7 @@ export function DefaultComponent(props: { {(renderData as any).prompt} { (renderData as any).error && ({(renderData as any).error}) } - emitter.emit(RenderEvent.PROMPT_RESULT, result)} placeholder='~/codify.jsonc' /> + emitter.emit(RenderEvent.PROMPT_RESULT, result)} placeholder={(renderData as any).placeholder} /> ) } diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index 6bf6768f..72cfdb10 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -69,9 +69,9 @@ export class DefaultReporter implements Reporter { ) } - async promptInput(prompt: string, error?: string, validation?: () => Promise, autoComplete?: (input: string) => string[]): Promise { + async promptInput(prompt: string, error?: string, placeholder?: string): Promise { return this.updateStateAndAwaitEvent( - () => this.updateRenderState(RenderStatus.PROMPT_INPUT, { prompt, error }), + () => this.updateRenderState(RenderStatus.PROMPT_INPUT, { prompt, error, placeholder }), RenderEvent.PROMPT_RESULT, ) } diff --git a/src/ui/reporters/plain-reporter.ts b/src/ui/reporters/plain-reporter.ts index 9fde8656..91569552 100644 --- a/src/ui/reporters/plain-reporter.ts +++ b/src/ui/reporters/plain-reporter.ts @@ -107,7 +107,7 @@ export class PlainReporter implements Reporter { async displayProgress(): Promise {} - async promptInput(prompt: string, error?: string, validation?: () => Promise, autoComplete?: (input: string) => string[]): Promise { + async promptInput(prompt: string, error?: string): Promise { return new Promise((resolve) => { this.rl.question(prompt + (error ? chalk.red(`\n${error} `) : ''), (answer) => resolve(answer)); }); diff --git a/src/ui/reporters/reporter.ts b/src/ui/reporters/reporter.ts index 26916fb7..9e3e2933 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -53,7 +53,7 @@ export interface Reporter { promptInitResultSelection(availableTypes: string[]): Promise; - promptInput(prompt: string, error?: string, validation?: () => Promise, autoComplete?: (input: string) => string[]): Promise; + promptInput(prompt: string, error?: string, placeholder?: string): Promise; promptConfirmation(message: string): Promise diff --git a/test/orchestrator/mocks/reporter.ts b/test/orchestrator/mocks/reporter.ts index 91cdb848..59e29ded 100644 --- a/test/orchestrator/mocks/reporter.ts +++ b/test/orchestrator/mocks/reporter.ts @@ -1,12 +1,12 @@ -import { SpawnStatus, SudoRequestData, SudoRequestResponseData } from 'codify-schemas'; +import { SudoRequestData } from 'codify-schemas'; import { Plan } from '../../../src/entities/plan.js'; import { ResourceConfig } from '../../../src/entities/resource-config.js'; import { ResourceInfo } from '../../../src/entities/resource-info.js'; +import { FileModificationResult } from '../../../src/generators/index.js'; import { ImportResult } from '../../../src/orchestrators/import.js'; import { prettyFormatPlan } from '../../../src/ui/plan-pretty-printer.js'; import { PromptType, Reporter } from '../../../src/ui/reporters/reporter.js'; -import { FileModificationResult } from '../../../src/utils/file-modification-calculator.js'; export interface MockReporterConfig { validatePlan?: (plan: Plan) => Promise | void; @@ -15,7 +15,7 @@ export interface MockReporterConfig { promptConfirmation?: () => boolean; promptOptions?: (message: string, options: string[]) => number; promptUserForValues?: (resourceInfo: ResourceInfo[]) => Promise | ResourceConfig[]; - promptInput?: (prompt: string, error?: string | undefined, validation?: (() => Promise) | undefined, autoComplete?: ((input: string) => string[]) | undefined) => Promise + promptInput?: (prompt: string, error?: string | undefined) => Promise promptInitResultSelection?: (availableTypes: string[]) => Promise | string[]; hide?: () => void; displayImportResult?: (importResult: ImportResult, showConfigs: boolean) => Promise | void; @@ -48,8 +48,8 @@ export class MockReporter implements Reporter { return (await this.config?.promptInitResultSelection?.(availableTypes)) ?? []; } - async promptInput(prompt: string, error?: string | undefined, validation?: (() => Promise) | undefined, autoComplete?: ((input: string) => string[]) | undefined): Promise { - return (await this.config?.promptInput?.(prompt, error, validation)) ?? ''; + async promptInput(prompt: string, error?: string | undefined): Promise { + return (await this.config?.promptInput?.(prompt, error)) ?? ''; } async promptPressKeyToContinue(message?: string | undefined): Promise {} From b5389125ab15495c761777827ba948adf4d8d073 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sat, 13 Dec 2025 16:02:54 -0500 Subject: [PATCH 46/67] feat: Improved init experience (always new project for init) --- src/common/initialize-plugins.ts | 31 +++++++++++++++++++------------ src/orchestrators/init.ts | 5 ++++- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/common/initialize-plugins.ts b/src/common/initialize-plugins.ts index 1af0efcf..3b2360f1 100644 --- a/src/common/initialize-plugins.ts +++ b/src/common/initialize-plugins.ts @@ -17,6 +17,7 @@ export interface InitializeArgs { verbosityLevel?: number; transformProject?: (project: Project) => Project | Promise; allowEmptyProject?: boolean; + forceEmptyProject?: boolean; } export interface InitializationResult { @@ -30,14 +31,10 @@ export class PluginInitOrchestrator { args: InitializeArgs, reporter: Reporter, ): Promise { - const codifyPath = await PluginInitOrchestrator.resolveCodifyRootPath(args, reporter); - - let project = await PluginInitOrchestrator.parse( - codifyPath, + const project = await PluginInitOrchestrator.parseProject( + args, + reporter ); - if (args.transformProject) { - project = await args.transformProject(project); - } ctx.subprocessStarted(SubProcessName.INITIALIZE_PLUGINS) const pluginManager = new PluginManager(); @@ -47,18 +44,28 @@ export class PluginInitOrchestrator { return { resourceDefinitions, pluginManager, project }; } - private static async parse( - fileOrDir: string | undefined, + private static async parseProject( + args: InitializeArgs, + reporter: Reporter, ): Promise { + if (args.forceEmptyProject) { + return Project.empty(); + } + + const codifyPath = await PluginInitOrchestrator.resolveCodifyRootPath(args, reporter); ctx.subprocessStarted(SubProcessName.PARSE); - const project = fileOrDir - ? await CodifyParser.parse(fileOrDir) + const project = codifyPath + ? await CodifyParser.parse(codifyPath) : Project.empty() ctx.subprocessFinished(SubProcessName.PARSE); - return project + if (args.transformProject) { + return args.transformProject(project); + } + + return project; } /** Resolve the root codify file to run. diff --git a/src/orchestrators/init.ts b/src/orchestrators/init.ts index 6f95ed26..9487de10 100644 --- a/src/orchestrators/init.ts +++ b/src/orchestrators/init.ts @@ -24,7 +24,10 @@ export const InitializeOrchestrator = { await reporter.displayProgress(); - const { pluginManager, resourceDefinitions } = await PluginInitOrchestrator.run(args, reporter); + const { pluginManager, resourceDefinitions } = await PluginInitOrchestrator.run({ + ...args, + forceEmptyProject: true, + }, reporter); ctx.subprocessStarted(SubProcessName.IMPORT_RESOURCE) From bfb2e4c3370fa6814b5adcabab5f05e887234212 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sat, 13 Dec 2025 17:30:57 -0500 Subject: [PATCH 47/67] feat: Fixed build issues and improved help --- package-lock.json | 12 ---- package.json | 4 +- src/api/backend/index.ts | 2 +- src/commands/connect.ts | 5 +- src/commands/edit.ts | 6 +- src/commands/import.ts | 18 ++--- src/commands/login.ts | 2 +- src/commands/logout.ts | 2 +- src/commands/refresh.ts | 28 ++------ src/help.ts | 69 +++++++++++++++++++ src/orchestrators/connect.ts | 1 + src/ui/components/default-component.tsx | 4 +- ...Modification.tsx => file-modification.tsx} | 2 +- src/ui/reporters/default-reporter.tsx | 2 +- src/ui/reporters/plain-reporter.ts | 2 +- src/ui/reporters/reporter.ts | 10 +-- 16 files changed, 100 insertions(+), 69 deletions(-) create mode 100644 src/help.ts rename src/ui/components/file-modification/{FileModification.tsx => file-modification.tsx} (88%) diff --git a/package-lock.json b/package-lock.json index e1af0257..44a23dd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,6 @@ "@types/jju": "^1.4.5", "@types/js-yaml": "^4.0.9", "@types/json5": "^2.2.0", - "@types/kill-port": "^2.0.3", "@types/mocha": "^10.0.10", "@types/node": "^20", "@types/react": "^18.3.1", @@ -4459,17 +4458,6 @@ "json5": "*" } }, - "node_modules/@types/kill-port": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/kill-port/-/kill-port-2.0.3.tgz", - "integrity": "sha512-ZHs59e5FBjDLQLOxM48+814LSyNf5sgpi0odtJ0FH6xrIAZXb4yksYG+4mZCbidX3fBOfHytAKAVMgkWvv/Piw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "shell-exec": "^1" - } - }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", diff --git a/package.json b/package.json index 1daa341c..a56351f8 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "uuid": "^10.0.0", "ws": "^8.18.3" }, - "description": "Codify allows users to configure settings, install new packages, and automate their systems using code instead of the GUI. Get set up on a new laptop in one click, maintain a Codify file within your project so anyone can get started and never lose your cool apps or favourite settings again.", + "description": "Codify allows users to configure settings, install new packages, and automate their systems using code instead of the GUI. Check out https://dashboard.codifycli.com for an online editor.", "devDependencies": { "@memlab/core": "^1.1.39", "@oclif/prettier-config": "^0.2.1", @@ -58,7 +58,6 @@ "@types/jju": "^1.4.5", "@types/js-yaml": "^4.0.9", "@types/json5": "^2.2.0", - "@types/kill-port": "^2.0.3", "@types/mocha": "^10.0.10", "@types/node": "^20", "@types/react": "^18.3.1", @@ -108,6 +107,7 @@ "bin": "codify", "dirname": "codify", "commands": "./dist/commands", + "helpClass": "./dist/help", "additionalVersionFlags": [ "-v" ], diff --git a/src/api/backend/index.ts b/src/api/backend/index.ts index 8407ce8f..3272bcd1 100644 --- a/src/api/backend/index.ts +++ b/src/api/backend/index.ts @@ -56,7 +56,7 @@ export const ApiClient = { return data.hash; }, - async updateRemoteFile(filePath: string, content: Blob, credentials: string): Promise { + async updateRemoteFile(filePath: string, content: Blob, credentials: string): Promise { const { documentId, fileId } = this.extractCodifyFileInfo(filePath); const formData = new FormData(); diff --git a/src/commands/connect.ts b/src/commands/connect.ts index b0c8d86a..cd40815b 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -3,9 +3,10 @@ import { ConnectOrchestrator } from '../orchestrators/connect.js'; export default class Connect extends BaseCommand { static description = - `Validate a codify.jsonc/codify.json/codify.yaml file. + `Open a connection to the Codify dashboard. This command will host a local server to receive commands (e.g. apply, destroy, etc.) +from the Codify dashboard. -For more information, visit: https://docs.codifycli.com/commands/validate +For more information, visit: https://docs.codifycli.com/commands/connect ` static flags = {} diff --git a/src/commands/edit.ts b/src/commands/edit.ts index aa70e30f..84802e1d 100644 --- a/src/commands/edit.ts +++ b/src/commands/edit.ts @@ -1,13 +1,11 @@ import { BaseCommand } from '../common/base-command.js'; -import { ConnectOrchestrator } from '../orchestrators/connect.js'; import { EditOrchestrator } from '../orchestrators/edit.js'; -import { LoginHelper } from '../connect/login-helper.js'; export default class Edit extends BaseCommand { static description = - `Edit a codify.jsonc/codify.json/codify.yaml file. + `Short cut for opening your default Codify file in the Codify dashboard. -For more information, visit: https://docs.codifycli.com/commands/validate +For more information, visit: https://docs.codifycli.com/commands/edit ` static flags = {} diff --git a/src/commands/import.ts b/src/commands/import.ts index b183da1e..a251e86d 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -9,25 +9,19 @@ import { ShellUtils } from '../utils/shell.js'; export default class Import extends BaseCommand { static strict = false; static override description = -`Generate Codify configurations from already installed packages. - -Use a space-separated list of arguments to specify the resource types to import. -If a codify.jsonc file already exists, omit arguments to update the file to match the system. - -${chalk.bold('Modes:')} -1. ${chalk.bold('No args:')} If no args are specified and an *.codify.jsonc already exists, Codify -will update the existing file with new changes on the system. - -${chalk.underline('Command:')} -codify import +`Generate Codify configurations from already installed programs / applications. +Two modes are available: +1. ${chalk.bold('No args:')} If no args are specified will attempt to import all supported resources. 2. ${chalk.bold('With args:')} Specify specific resources to import using arguments. Wild card matching is supported using '*' and '?' (${chalk.italic('Note: in zsh * expands to the current dir and needs to be escaped using \\* or \'*\'')}). A prompt will be shown if more information is required to complete the import. +Use a space-separated list of arguments to specify the resource types to import. + ${chalk.underline('Examples:')} codify import nvm asdf* -codify import \\* (for importing all supported resources) +codify import The results can be saved in one of three ways: a. To an existing *.codify.jsonc file diff --git a/src/commands/login.ts b/src/commands/login.ts index 21bd96c2..360dd4ab 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -5,7 +5,7 @@ import { LoginOrchestrator } from '../orchestrators/login.js'; export default class Login extends BaseCommand { static description = - `Logins to codify cloud account + `Logins to Codify cloud account For more information, visit: https://docs.codifycli.com/commands/login ` diff --git a/src/commands/logout.ts b/src/commands/logout.ts index 3eb9f488..2fee01fa 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -6,7 +6,7 @@ import { LoginHelper } from '../connect/login-helper.js'; export default class Login extends BaseCommand { static description = - `Logout of codify cloud account + `Logout of Codify cloud account For more information, visit: https://docs.codifycli.com/commands/logout ` diff --git a/src/commands/refresh.ts b/src/commands/refresh.ts index 7c361a49..3101f5a6 100644 --- a/src/commands/refresh.ts +++ b/src/commands/refresh.ts @@ -10,34 +10,14 @@ import { RefreshOrchestrator } from '../orchestrators/refresh.js'; export default class Refresh extends BaseCommand { static strict = false; static override description = -`Generate Codify configurations from already installed packages. +`Refreshes existing Codify configurations to have the latest changes on the system. -Use a space-separated list of arguments to specify the resource types to import. -If a codify.jsonc file already exists, omit arguments to update the file to match the system. +Use a space-separated list of arguments to specify specific resource types to refresh. +Leave empty to refresh all resources. -${chalk.bold('Modes:')} -1. ${chalk.bold('No args:')} If no args are specified and an *.codify.jsonc already exists, Codify -will update the existing file with new changes on the system. - -${chalk.underline('Command:')} -codify import - -2. ${chalk.bold('With args:')} Specify specific resources to import using arguments. Wild card matching is supported -using '*' and '?' (${chalk.italic('Note: in zsh * expands to the current dir and needs to be escaped using \\* or \'*\'')}). -A prompt will be shown if more information is required to complete the import. - -${chalk.underline('Examples:')} -codify import nvm asdf* -codify import \\* (for importing all supported resources) - -The results can be saved in one of three ways: - a. To an existing *.codify.jsonc file - b. To a new file - c. Printed to the console only - Codify will attempt to smartly insert new configurations while preserving existing spacing and formatting. -For more information, visit: https://docs.codifycli.com/commands/import` +For more information, visit: https://docs.codifycli.com/commands/refresh` static override examples = [ '<%= config.bin %> <%= command.id %> homebrew nvm asdf', diff --git a/src/help.ts b/src/help.ts new file mode 100644 index 00000000..3c6d3df2 --- /dev/null +++ b/src/help.ts @@ -0,0 +1,69 @@ +import { Command, Help, HelpBase } from '@oclif/core'; +import { colorize } from '@oclif/core/ux'; +import chalk from 'chalk'; +import stripAnsi from 'strip-ansi'; + +enum HelpSection { + GET_STARTED = 'GETTING STARTED', + CORE = 'CORE', + IMPORT = 'IMPORT', + CLOUD = 'CLOUD', +} + +const HelpOrganization = { + [HelpSection.GET_STARTED]: [ + 'init', + ], + [HelpSection.CORE]: [ + 'plan', + 'apply', + 'destroy', + 'validate' + ], + [HelpSection.IMPORT]: [ + 'import', + 'refresh', + ], + [HelpSection.CLOUD]: [ + 'login', + 'logout', + 'edit', + 'connect', + ] +} + +export default class CustomHelp extends Help { + formatCommands(commands: Command.Loadable[]): string { + if (commands.length === 0) return '' + + const gettingStarted = this.formatSection(HelpSection.GET_STARTED, commands) + const core = this.formatSection(HelpSection.CORE, commands) + const importSection = this.formatSection(HelpSection.IMPORT, commands) + const cloud = this.formatSection(HelpSection.CLOUD, commands) + + return this.section('COMMANDS', `${gettingStarted}\n\n${core}\n\n${importSection}\n\n${cloud}`) + } + + formatSection(section: HelpSection, commands: Command.Loadable[]): string { + const body = this.renderList( + commands + .filter((c) => HelpOrganization[section].includes(c.id)) + .filter((c) => (this.opts.hideAliasesFromRoot ? !c.aliases?.includes(c.id) : true)) + .map((c) => { + if (this.config.topicSeparator !== ':') c.id = c.id.replaceAll(':', this.config.topicSeparator) + const summary = this.summary(c) + return [ + colorize(this.config?.theme?.command, c.id), + summary && colorize(this.config?.theme?.sectionDescription, stripAnsi(summary)), + ] + }), + { + indentation: 2, + spacer: '\n', + stripAnsi: this.opts.stripAnsi, + }, + ) + + return `${chalk.underline(section)}\n${body}` + } +} diff --git a/src/orchestrators/connect.ts b/src/orchestrators/connect.ts index 556f40aa..8f7d6e74 100644 --- a/src/orchestrators/connect.ts +++ b/src/orchestrators/connect.ts @@ -1,6 +1,7 @@ import { Config } from '@oclif/core'; import cors from 'cors'; import express, { json } from 'express'; +// @ts-ignore import killPort from 'kill-port'; import { randomBytes } from 'node:crypto'; import { Server } from 'node:http'; diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 4ac98766..9d36a658 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -9,11 +9,11 @@ import { EventEmitter } from 'node:events'; import React, { useLayoutEffect, useState } from 'react'; import { Plan } from '../../entities/plan.js'; +import { FileModificationResult } from '../../generators/index.js'; import { ImportResult } from '../../orchestrators/import.js'; -import { FileModificationResult } from '../../utils/file-modification-calculator.js'; import { RenderEvent } from '../reporters/reporter.js'; import { RenderStatus, store } from '../store/index.js'; -import { FileModificationDisplay } from './file-modification/FileModification.js'; +import { FileModificationDisplay } from './file-modification/file-modification.js'; import { ImportResultComponent } from './import/import-result.js'; import { ImportWarning } from './import/import-warning.js'; import { InitBanner } from './init/InitBanner.js'; diff --git a/src/ui/components/file-modification/FileModification.tsx b/src/ui/components/file-modification/file-modification.tsx similarity index 88% rename from src/ui/components/file-modification/FileModification.tsx rename to src/ui/components/file-modification/file-modification.tsx index 2a0c6769..6f96d1a2 100644 --- a/src/ui/components/file-modification/FileModification.tsx +++ b/src/ui/components/file-modification/file-modification.tsx @@ -1,7 +1,7 @@ import { Box, Text } from 'ink'; import React from 'react'; -import { FileModificationResult } from '../../../utils/file-modification-calculator.js'; +import { FileModificationResult } from '../../../generators/index.js'; export function FileModificationDisplay(props: { data: Array<{ file: string; modification: FileModificationResult }>, diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index 72cfdb10..5309b98f 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -9,8 +9,8 @@ import { Plan } from '../../entities/plan.js'; import { ResourceConfig } from '../../entities/resource-config.js'; import { ResourceInfo } from '../../entities/resource-info.js'; import { Event, ProcessName, SubProcessName, ctx } from '../../events/context.js'; +import { FileModificationResult } from '../../generators/index.js'; import { ImportResult } from '../../orchestrators/import.js'; -import { FileModificationResult } from '../../utils/file-modification-calculator.js'; import { sleep } from '../../utils/index.js'; import { SudoUtils } from '../../utils/sudo.js'; import { DefaultComponent } from '../components/default-component.js'; diff --git a/src/ui/reporters/plain-reporter.ts b/src/ui/reporters/plain-reporter.ts index 91569552..70f5f7a1 100644 --- a/src/ui/reporters/plain-reporter.ts +++ b/src/ui/reporters/plain-reporter.ts @@ -6,8 +6,8 @@ import { Plan } from '../../entities/plan.js'; import { ResourceConfig } from '../../entities/resource-config.js'; import { ResourceInfo } from '../../entities/resource-info.js'; import { Event, ctx } from '../../events/context.js'; +import { FileModificationResult } from '../../generators/index.js'; import { ImportResult } from '../../orchestrators/import.js'; -import { FileModificationResult } from '../../utils/file-modification-calculator.js'; import { prettyFormatPlan } from '../plan-pretty-printer.js'; import { PromptType, Reporter } from './reporter.js'; diff --git a/src/ui/reporters/reporter.ts b/src/ui/reporters/reporter.ts index 9e3e2933..437b527b 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -1,14 +1,14 @@ import { SudoRequestData , SudoRequestResponseData } from 'codify-schemas'; import { Plan } from '../../entities/plan.js'; -import { ImportResult } from '../../orchestrators/import.js'; -import { DefaultReporter } from './default-reporter.js'; -import { ResourceInfo } from '../../entities/resource-info.js'; import { ResourceConfig } from '../../entities/resource-config.js'; -import { FileModificationResult } from '../../utils/file-modification-calculator.js'; -import { PlainReporter } from './plain-reporter.js'; +import { ResourceInfo } from '../../entities/resource-info.js'; +import { FileModificationResult } from '../../generators/index.js'; +import { ImportResult } from '../../orchestrators/import.js'; import { DebugReporter } from './debug-reporter.js'; +import { DefaultReporter } from './default-reporter.js'; import { JsonReporter } from './json-reporter.js'; +import { PlainReporter } from './plain-reporter.js'; export enum RenderEvent { LOG = 'log', From ae9800f685829cf9ce78091bc4938c592ed4d58b Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sat, 13 Dec 2025 20:51:34 -0500 Subject: [PATCH 48/67] fix: Fixed existing tests --- src/commands/connect.ts | 4 +- src/commands/edit.ts | 4 +- src/orchestrators/connect.ts | 4 +- src/orchestrators/edit.ts | 4 +- src/parser/index.ts | 2 +- src/parser/yaml/yaml-parser.test.ts | 2 +- test/orchestrator/import/import.test.ts | 500 +++++++++--------- .../initialize/initialize.test.ts | 53 +- .../utils}/default-component.test.tsx | 6 +- .../utils/dependency-graph-resolver.test.ts | 2 +- .../file-modification-calculator.test.ts | 10 +- .../utils}/plan-pretty-printer.test.ts | 9 +- 12 files changed, 279 insertions(+), 321 deletions(-) rename {src/ui/components => test/utils}/default-component.test.tsx (91%) rename {src => test}/utils/dependency-graph-resolver.test.ts (92%) rename {src => test}/utils/file-modification-calculator.test.ts (97%) rename {src/ui => test/utils}/plan-pretty-printer.test.ts (91%) diff --git a/src/commands/connect.ts b/src/commands/connect.ts index cd40815b..ba71b88d 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -18,8 +18,8 @@ For more information, visit: https://docs.codifycli.com/commands/connect public async run(): Promise { const { flags } = await this.parse(Connect) - const config = this.config; + const rootCommand = this.config.options.root; - await ConnectOrchestrator.run(config, this.reporter); + await ConnectOrchestrator.run(rootCommand, this.reporter); } } diff --git a/src/commands/edit.ts b/src/commands/edit.ts index 84802e1d..81f4ebad 100644 --- a/src/commands/edit.ts +++ b/src/commands/edit.ts @@ -17,9 +17,9 @@ For more information, visit: https://docs.codifycli.com/commands/edit public async run(): Promise { const { flags } = await this.parse(Edit); - const config = this.config; + const rootCommand = this.config.options.root; - await EditOrchestrator.run(config, this.reporter); + await EditOrchestrator.run(rootCommand, this.reporter); } } diff --git a/src/orchestrators/connect.ts b/src/orchestrators/connect.ts index 8f7d6e74..747cb44e 100644 --- a/src/orchestrators/connect.ts +++ b/src/orchestrators/connect.ts @@ -19,14 +19,14 @@ export class ConnectOrchestrator { static rootCommand: string; static nodeBinary: string; - static async run(oclifConfig: Config, reporter: Reporter, openBrowser = true, onOpen?: (connectionCode: string) => void) { + static async run(rootCommand: string, reporter: Reporter, openBrowser = true, onOpen?: (connectionCode: string) => void) { const login = LoginHelper.get()?.isLoggedIn; if (!login) { ctx.log('User is not logged in. Attempting to log in...') await LoginOrchestrator.run(); } - this.rootCommand = oclifConfig.options.root; + this.rootCommand = rootCommand; this.nodeBinary = process.execPath; const connectionSecret = ConnectOrchestrator.tokenGenerate() diff --git a/src/orchestrators/edit.ts b/src/orchestrators/edit.ts index a74f764e..f182528b 100644 --- a/src/orchestrators/edit.ts +++ b/src/orchestrators/edit.ts @@ -10,7 +10,7 @@ import { LoginOrchestrator } from './login.js'; export class EditOrchestrator { - static async run(oclifConfig: Config, reporter: Reporter) { + static async run(rootCommand: string, reporter: Reporter) { const login = LoginHelper.get()?.isLoggedIn; if (!login) { console.log('User is not logged in. Attempting to log in...') @@ -28,7 +28,7 @@ export class EditOrchestrator { ? `${config.dashboardUrl}/document/${defaultDocumentId}` : config.dashboardUrl; - await ConnectOrchestrator.run(oclifConfig, reporter, false, (code) => { + await ConnectOrchestrator.run(rootCommand, reporter, false, (code) => { open(`${url}?connection_code=${code}`); console.log( `Opening default Codify file: diff --git a/src/parser/index.ts b/src/parser/index.ts index 7f4c2ab3..5450280d 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -17,7 +17,7 @@ import { FileReader } from './reader/file-reader.js'; import { SourceMapCache } from './source-maps.js'; import { YamlParser } from './yaml/yaml-parser.js'; -export const CODIFY_FILE_REGEX = /^(.*)?codify(.json|.yaml|.json5|.jsonc)$/; +export const CODIFY_FILE_REGEX = /^(.*)?codify(.*)?(.json|.yaml|.json5|.jsonc)$/; class Parser { private readonly languageSpecificParsers= { diff --git a/src/parser/yaml/yaml-parser.test.ts b/src/parser/yaml/yaml-parser.test.ts index 7c473946..db51fd61 100644 --- a/src/parser/yaml/yaml-parser.test.ts +++ b/src/parser/yaml/yaml-parser.test.ts @@ -32,7 +32,7 @@ describe('YamlParser tests', () => { expect(result.length).to.eq(4); expect(result[0]).toMatchObject({ - contents: { type: 'project', plugins: { default: '../homebrew-plugin/src/router.ts' }}, + contents: { type: 'project', plugins: { default: '../homebrew-plugin/src/index.ts' }}, }) }) diff --git a/test/orchestrator/import/import.test.ts b/test/orchestrator/import/import.test.ts index c6c5bcf1..3765ae73 100644 --- a/test/orchestrator/import/import.test.ts +++ b/test/orchestrator/import/import.test.ts @@ -196,7 +196,7 @@ describe('Import orchestrator tests', () => { expect(displayFileModifications).toHaveBeenCalledOnce(); expect(promptConfirmationSpy).toHaveBeenCalledOnce(); - const fileWritten = fs.readFileSync('/codify-imports/import.codify.jsonc', 'utf8') as string; + const fileWritten = fs.readFileSync('/import.codify.jsonc', 'utf8') as string; console.log(fileWritten); expect(JSON.parse(fileWritten)).toMatchObject([ @@ -331,253 +331,255 @@ describe('Import orchestrator tests', () => { ]) }); - it('Can import a resource and save it into an existing project (multiple codify files)', async () => { - const processSpy = vi.spyOn(process, 'cwd'); - processSpy.mockReturnValue('/'); - - fs.writeFileSync('/codify.json', - `[ - { - "type": "jenv", - "add": [ - "system", - "11", - "11.0" - ], - "global": "17", - "requiredProp": "this-jenv" - } -]`, - { encoding: 'utf-8' }); - - fs.writeFileSync('/other.codify.json', - `[ - { "type": "alias", "alias": "gcdsdd", "value": "git clone" }, - { - "type": "alias", - "alias": "gcc", - "value": "git commit -v" - } -]`, - { encoding: 'utf-8' }); - - const reporter = new MockReporter({ - promptUserForValues: (resourceInfoList): ResourceConfig[] => { - expect(resourceInfoList.length).to.eq(2); - expect(resourceInfoList[0].type).to.eq('jenv'); - expect(resourceInfoList[1].type).to.eq('alias'); - - return [new ResourceConfig({ - type: 'jenv', - requiredProp: true, - }), new ResourceConfig({ - type: 'alias', - alias: 'gc-new' - })] - }, - displayImportResult: (importResult) => { - expect(importResult.errors.length).to.eq(0); - expect(importResult.result.length).to.eq(2); - }, - // Option 0 is write to a new file (no current project exists) - promptOptions: (message, options) => { - if (message.includes('save the results?')) { - expect(options[0]).toContain('Update existing'); - return 0; - } else if (message.includes('where to write')) { - expect(options).toMatchObject([ - '/codify.json', - '/other.codify.json' - ]) - return 1; - } - }, - displayFileModifications: (diff: Array<{ file: string, modification: FileModificationResult }>) => { - expect(diff[0].file).to.eq('/codify.json') - expect(diff[1].file).to.eq('/other.codify.json') - }, - }); - - const askRequiredParametersSpy = vi.spyOn(reporter, 'promptUserForValues'); - const displayImportResultSpy = vi.spyOn(reporter, 'displayImportResult'); - const displayFileModifications = vi.spyOn(reporter, 'displayFileModifications'); - const promptConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); - - MockOs.create('jenv', { - 'add': [ - 'system', - '11', - '11.0', - '11.0.24', - '17', - '17.0.12', - 'openjdk64-11.0.24', - 'openjdk64-17.0.12' - ], - 'global': '17', - 'requiredProp': 'this-jenv' - }) - - MockOs.create('alias', { - 'alias': 'gc-new', - 'value': 'gc-new-value', - }) - - await ImportOrchestrator.run( - { - typeIds: ['jenv', 'alias'], - path: '/' - }, - reporter, - ); - - expect(askRequiredParametersSpy).toHaveBeenCalledOnce(); - expect(displayImportResultSpy).toHaveBeenCalledOnce() - expect(displayFileModifications).toHaveBeenCalledOnce(); - expect(promptConfirmationSpy).toHaveBeenCalledOnce(); - - const otherCodifyFile = fs.readFileSync('/other.codify.json', 'utf8') as string; - console.log(otherCodifyFile); - expect(JSON.parse(otherCodifyFile)).toMatchObject([ - { 'type': 'alias', 'alias': 'gcdsdd', 'value': 'git clone' }, - { - 'type': 'alias', - 'alias': 'gcc', - 'value': 'git commit -v' - }, - { - 'type': 'alias', - 'alias': 'gc-new', - 'value': 'gc-new-value', - } - ]) - - const codifyFile = fs.readFileSync('/codify.json', 'utf8') as string; - console.log(codifyFile); - - expect(JSON.parse(codifyFile)).toMatchObject([ - { - 'type': 'jenv', - 'add': [ - 'system', - '11', - '11.0', - '11.0.24', - '17', - '17.0.12', - 'openjdk64-11.0.24', - 'openjdk64-17.0.12' - ], - 'global': '17', - 'requiredProp': 'this-jenv' - } - ]) - }); - - it('Can import and update an existing project (without prompting the user)(this is the no args version)', async () => { - const processSpy = vi.spyOn(process, 'cwd'); - processSpy.mockReturnValue('/'); - - fs.writeFileSync('/codify.json', - `[ - { - "type": "jenv", - "add": [ - "system", - "11", - "11.0" - ], - "global": "17", - "requiredProp": "this-jenv" - } -]`, - { encoding: 'utf-8' }); - - const reporter = new MockReporter({ - displayImportResult: (importResult) => { - console.log(JSON.stringify(importResult, null, 2)); - expect(importResult.errors.length).to.eq(0); - expect(importResult.result.length).to.eq(1); - expect(importResult.result[0].type).to.eq('jenv'); - expect(importResult.result[0].parameters).toMatchObject({ // Make sure the system values are returned here - 'add': [ - 'system', - '11', - '11.0', - '11.0.24', - '17', - '17.0.12', - 'openjdk64-11.0.24', - 'openjdk64-17.0.12' - ], - 'global': '17', - 'requiredProp': 'this-jenv' - }) - }, - // Option 0 is write to a new file (no current project exists) - promptOptions: (message, options) => { - expect(options[0]).toContain('Update existing'); - return 0; - }, - displayFileModifications: (diff: Array<{ file: string, modification: FileModificationResult }>) => { - expect(diff[0].file).to.eq('/codify.json') - console.log(diff[0].file); - }, - }); - - const askRequiredParametersSpy = vi.spyOn(reporter, 'promptUserForValues'); - const displayImportResultSpy = vi.spyOn(reporter, 'displayImportResult'); - const displayFileModifications = vi.spyOn(reporter, 'displayFileModifications'); - const promptConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); - - MockOs.create('jenv', { - 'add': [ - 'system', - '11', - '11.0', - '11.0.24', - '17', - '17.0.12', - 'openjdk64-11.0.24', - 'openjdk64-17.0.12' - ], - 'global': '17', - 'requiredProp': 'this-jenv' - }) - - await ImportOrchestrator.run( - { - path: '/' - }, - reporter, - ); - - expect(askRequiredParametersSpy).toHaveBeenCalledTimes(0); - expect(displayImportResultSpy).toHaveBeenCalledOnce(); - expect(displayFileModifications).toHaveBeenCalledOnce(); - expect(promptConfirmationSpy).toHaveBeenCalledOnce(); - - const fileWritten = fs.readFileSync('/codify.json', 'utf8') as string; - console.log(fileWritten); - - expect(JSON.parse(fileWritten)).toMatchObject([ - { - 'type': 'jenv', - 'add': [ - 'system', - '11', - '11.0', - '11.0.24', - '17', - '17.0.12', - 'openjdk64-11.0.24', - 'openjdk64-17.0.12' - ], - 'global': '17', - 'requiredProp': 'this-jenv' - } - ]) - }); + // Multiple codify files are no longer supporter +// it('Can import a resource and save it into an existing project (multiple codify files)', async () => { +// const processSpy = vi.spyOn(process, 'cwd'); +// processSpy.mockReturnValue('/'); +// +// fs.writeFileSync('/codify.json', +// `[ +// { +// "type": "jenv", +// "add": [ +// "system", +// "11", +// "11.0" +// ], +// "global": "17", +// "requiredProp": "this-jenv" +// } +// ]`, +// { encoding: 'utf-8' }); +// +// fs.writeFileSync('/other.codify.json', +// `[ +// { "type": "alias", "alias": "gcdsdd", "value": "git clone" }, +// { +// "type": "alias", +// "alias": "gcc", +// "value": "git commit -v" +// } +// ]`, +// { encoding: 'utf-8' }); +// +// const reporter = new MockReporter({ +// promptUserForValues: (resourceInfoList): ResourceConfig[] => { +// expect(resourceInfoList.length).to.eq(2); +// expect(resourceInfoList[0].type).to.eq('jenv'); +// expect(resourceInfoList[1].type).to.eq('alias'); +// +// return [new ResourceConfig({ +// type: 'jenv', +// requiredProp: true, +// }), new ResourceConfig({ +// type: 'alias', +// alias: 'gc-new' +// })] +// }, +// displayImportResult: (importResult) => { +// expect(importResult.errors.length).to.eq(0); +// expect(importResult.result.length).to.eq(2); +// }, +// // Option 0 is write to a new file (no current project exists) +// promptOptions: (message, options) => { +// if (message.includes('save the results?')) { +// expect(options[0]).toContain('Update existing'); +// return 0; +// } else if (message.includes('where to write')) { +// expect(options).toMatchObject([ +// '/codify.json', +// '/other.codify.json' +// ]) +// return 1; +// } +// }, +// displayFileModifications: (diff: Array<{ file: string, modification: FileModificationResult }>) => { +// expect(diff[0].file).to.eq('/codify.json') +// expect(diff[1].file).to.eq('/other.codify.json') +// }, +// }); +// +// const askRequiredParametersSpy = vi.spyOn(reporter, 'promptUserForValues'); +// const displayImportResultSpy = vi.spyOn(reporter, 'displayImportResult'); +// const displayFileModifications = vi.spyOn(reporter, 'displayFileModifications'); +// const promptConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); +// +// MockOs.create('jenv', { +// 'add': [ +// 'system', +// '11', +// '11.0', +// '11.0.24', +// '17', +// '17.0.12', +// 'openjdk64-11.0.24', +// 'openjdk64-17.0.12' +// ], +// 'global': '17', +// 'requiredProp': 'this-jenv' +// }) +// +// MockOs.create('alias', { +// 'alias': 'gc-new', +// 'value': 'gc-new-value', +// }) +// +// await ImportOrchestrator.run( +// { +// typeIds: ['jenv', 'alias'], +// path: '/' +// }, +// reporter, +// ); +// +// expect(askRequiredParametersSpy).toHaveBeenCalledOnce(); +// expect(displayImportResultSpy).toHaveBeenCalledOnce() +// expect(displayFileModifications).toHaveBeenCalledOnce(); +// expect(promptConfirmationSpy).toHaveBeenCalledOnce(); +// +// const otherCodifyFile = fs.readFileSync('/other.codify.json', 'utf8') as string; +// console.log(otherCodifyFile); +// expect(JSON.parse(otherCodifyFile)).toMatchObject([ +// { 'type': 'alias', 'alias': 'gcdsdd', 'value': 'git clone' }, +// { +// 'type': 'alias', +// 'alias': 'gcc', +// 'value': 'git commit -v' +// }, +// { +// 'type': 'alias', +// 'alias': 'gc-new', +// 'value': 'gc-new-value', +// } +// ]) +// +// const codifyFile = fs.readFileSync('/codify.json', 'utf8') as string; +// console.log(codifyFile); +// +// expect(JSON.parse(codifyFile)).toMatchObject([ +// { +// 'type': 'jenv', +// 'add': [ +// 'system', +// '11', +// '11.0', +// '11.0.24', +// '17', +// '17.0.12', +// 'openjdk64-11.0.24', +// 'openjdk64-17.0.12' +// ], +// 'global': '17', +// 'requiredProp': 'this-jenv' +// } +// ]) +// }); + + // Move to the refresh tests +// it('Can import and update an existing project (without prompting the user)(this is the no args version)', async () => { +// const processSpy = vi.spyOn(process, 'cwd'); +// processSpy.mockReturnValue('/'); +// +// fs.writeFileSync('/codify.json', +// `[ +// { +// "type": "jenv", +// "add": [ +// "system", +// "11", +// "11.0" +// ], +// "global": "17", +// "requiredProp": "this-jenv" +// } +// ]`, +// { encoding: 'utf-8' }); +// +// const reporter = new MockReporter({ +// displayImportResult: (importResult) => { +// console.log(JSON.stringify(importResult, null, 2)); +// expect(importResult.errors.length).to.eq(0); +// expect(importResult.result.length).to.eq(1); +// expect(importResult.result[0].type).to.eq('jenv'); +// expect(importResult.result[0].parameters).toMatchObject({ // Make sure the system values are returned here +// 'add': [ +// 'system', +// '11', +// '11.0', +// '11.0.24', +// '17', +// '17.0.12', +// 'openjdk64-11.0.24', +// 'openjdk64-17.0.12' +// ], +// 'global': '17', +// 'requiredProp': 'this-jenv' +// }) +// }, +// // Option 0 is write to a new file (no current project exists) +// promptOptions: (message, options) => { +// expect(options[0]).toContain('Update existing'); +// return 0; +// }, +// displayFileModifications: (diff: Array<{ file: string, modification: FileModificationResult }>) => { +// expect(diff[0].file).to.eq('/codify.json') +// console.log(diff[0].file); +// }, +// }); +// +// const askRequiredParametersSpy = vi.spyOn(reporter, 'promptUserForValues'); +// const displayImportResultSpy = vi.spyOn(reporter, 'displayImportResult'); +// const displayFileModifications = vi.spyOn(reporter, 'displayFileModifications'); +// const promptConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); +// +// MockOs.create('jenv', { +// 'add': [ +// 'system', +// '11', +// '11.0', +// '11.0.24', +// '17', +// '17.0.12', +// 'openjdk64-11.0.24', +// 'openjdk64-17.0.12' +// ], +// 'global': '17', +// 'requiredProp': 'this-jenv' +// }) +// +// await ImportOrchestrator.run( +// { +// path: '/' +// }, +// reporter, +// ); +// +// expect(askRequiredParametersSpy).toHaveBeenCalledTimes(0); +// expect(displayImportResultSpy).toHaveBeenCalledOnce(); +// expect(displayFileModifications).toHaveBeenCalledOnce(); +// expect(promptConfirmationSpy).toHaveBeenCalledOnce(); +// +// const fileWritten = fs.readFileSync('/codify.json', 'utf8') as string; +// console.log(fileWritten); +// +// expect(JSON.parse(fileWritten)).toMatchObject([ +// { +// 'type': 'jenv', +// 'add': [ +// 'system', +// '11', +// '11.0', +// '11.0.24', +// '17', +// '17.0.12', +// 'openjdk64-11.0.24', +// 'openjdk64-17.0.12' +// ], +// 'global': '17', +// 'requiredProp': 'this-jenv' +// } +// ]) +// }); it('Can import a resource and only display it to the user', async () => { const processSpy = vi.spyOn(process, 'cwd'); @@ -757,7 +759,7 @@ describe('Import orchestrator tests', () => { expect(displayFileModifications).toHaveBeenCalledOnce(); expect(promptConfirmationSpy).toHaveBeenCalledOnce(); - const fileWritten = fs.readFileSync('/codify-imports/import.codify.jsonc', 'utf8') as string; + const fileWritten = fs.readFileSync('/import.codify.jsonc', 'utf8') as string; console.log(fileWritten); expect(JSON.parse(fileWritten)).toMatchObject([ diff --git a/test/orchestrator/initialize/initialize.test.ts b/test/orchestrator/initialize/initialize.test.ts index f97f1160..878e38c7 100644 --- a/test/orchestrator/initialize/initialize.test.ts +++ b/test/orchestrator/initialize/initialize.test.ts @@ -58,7 +58,7 @@ describe('Parser integration tests', () => { fs.writeFileSync(path.resolve(folder, 'home-2.codify.json'), file2Contents); const reporter = new MockReporter({ - + promptOptions: (message, options) => options.indexOf('home-2.codify.json'), }); const cwdSpy = vi.spyOn(process, 'cwd'); @@ -69,13 +69,9 @@ describe('Parser integration tests', () => { console.log(project); expect(project).toMatchObject({ codifyFiles: expect.arrayContaining([ - path.resolve(folder, 'home.codify.json'), - path.resolve(folder, 'home-2.codify.json') + path.resolve(folder, 'home-2.codify.json'), ]), resourceConfigs: expect.arrayContaining([ - expect.objectContaining({ - type: 'customType1', - }), expect.objectContaining({ type: 'customType2', }) @@ -83,50 +79,7 @@ describe('Parser integration tests', () => { }) }) - it('Finds codify.json files in a previous dir', async () => { - const file1Contents = - `[ - { "type": "customType1" } -]` - - const file2Contents = - `[ - { "type": "customType2" } -]` - const folder = path.resolve(os.homedir(), 'Downloads', 'untitled folder') - const innerFolder = path.resolve(folder, 'inner folder') - - fs.mkdirSync(folder, { recursive: true }); - fs.mkdirSync(innerFolder, { recursive: true }); - - fs.writeFileSync(path.resolve(folder, 'home.codify.json'), file1Contents); - fs.writeFileSync(path.resolve(folder, 'home-2.codify.json'), file2Contents); - - const reporter = new MockReporter({ - - }); - - const cwdSpy = vi.spyOn(process, 'cwd'); - cwdSpy.mockReturnValue(innerFolder); - - const { project, pluginManager, resourceDefinitions } = await PluginInitOrchestrator.run({}, reporter); - - console.log(project); - expect(project).toMatchObject({ - codifyFiles: expect.arrayContaining([ - path.resolve(folder, 'home.codify.json'), - path.resolve(folder, 'home-2.codify.json') - ]), - resourceConfigs: expect.arrayContaining([ - expect.objectContaining({ - type: 'customType1', - }), - expect.objectContaining({ - type: 'customType2', - }) - ]) - }) - }) + // Write test for cloud files here? afterEach(() => { vi.resetAllMocks(); diff --git a/src/ui/components/default-component.test.tsx b/test/utils/default-component.test.tsx similarity index 91% rename from src/ui/components/default-component.test.tsx rename to test/utils/default-component.test.tsx index 37092214..c05c6f41 100644 --- a/src/ui/components/default-component.test.tsx +++ b/test/utils/default-component.test.tsx @@ -4,9 +4,9 @@ import { EventEmitter } from 'node:events'; import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DefaultReporter } from '../reporters/default-reporter.js'; -import { RenderStatus, store } from '../store/index.js'; -import { DefaultComponent } from './default-component.js'; +import { DefaultComponent } from '../../src/ui/components/default-component.js'; +import { DefaultReporter } from '../../src/ui/reporters/default-reporter.js'; +import { RenderStatus, store } from '../../src/ui/store/index.js'; // Mock dependent components // vi.mock('./progress/progress-display', () => ({ diff --git a/src/utils/dependency-graph-resolver.test.ts b/test/utils/dependency-graph-resolver.test.ts similarity index 92% rename from src/utils/dependency-graph-resolver.test.ts rename to test/utils/dependency-graph-resolver.test.ts index fc696432..5a776be7 100644 --- a/src/utils/dependency-graph-resolver.test.ts +++ b/test/utils/dependency-graph-resolver.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { DependencyGraphResolver } from './dependency-graph-resolver.js'; +import { DependencyGraphResolver } from '../../src/utils/dependency-graph-resolver.js'; describe('Dependency graph resolver tests', () => { it('Returns resource configs in the correct order', () => { diff --git a/src/utils/file-modification-calculator.test.ts b/test/utils/file-modification-calculator.test.ts similarity index 97% rename from src/utils/file-modification-calculator.test.ts rename to test/utils/file-modification-calculator.test.ts index ad8aab53..9de57ce4 100644 --- a/src/utils/file-modification-calculator.test.ts +++ b/test/utils/file-modification-calculator.test.ts @@ -3,11 +3,11 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { describe, it, vi, afterEach, expect } from 'vitest'; -import { FileModificationCalculator, ModificationType } from './file-modification-calculator.js'; -import { ResourceConfig } from '../entities/resource-config.js'; -import { ResourceInfo } from '../entities/resource-info.js'; -import { CodifyParser } from '../parser/index.js'; -import { Project } from '../entities/project'; +import { CodifyParser } from '../../src/parser/index.js'; +import { ResourceConfig } from '../../src/entities/resource-config.js'; +import { FileModificationCalculator } from '../../src/generators/file-modification-calculator.js'; +import { ModificationType } from '../../src/generators/index.js'; +import { ResourceInfo } from '../../src/entities/resource-info.js'; vi.mock('node:fs', async () => { const { fs } = await import('memfs'); diff --git a/src/ui/plan-pretty-printer.test.ts b/test/utils/plan-pretty-printer.test.ts similarity index 91% rename from src/ui/plan-pretty-printer.test.ts rename to test/utils/plan-pretty-printer.test.ts index 5f8444c1..9f714a2d 100644 --- a/src/ui/plan-pretty-printer.test.ts +++ b/test/utils/plan-pretty-printer.test.ts @@ -1,7 +1,7 @@ -import { describe, it } from 'vitest'; -import { prettyFormatResourcePlan } from './plan-pretty-printer.js'; +import { describe, it } from 'vitest'; import { ParameterOperation, PlanResponseData, ResourceOperation } from 'codify-schemas'; -import { ResourcePlan } from '../entities/plan.js'; +import { prettyFormatResourcePlan } from '../../src/ui/plan-pretty-printer.js'; +import { ResourcePlan } from '../../src/entities/plan.js'; describe('Plan pretty printer', () => { it('Can print create plans', () => { @@ -9,6 +9,7 @@ describe('Plan pretty printer', () => { planId: 'id', resourceType: 'type', operation: ResourceOperation.CREATE, + isStateful: false, parameters: [ { name: 'propC', previousValue: null, newValue: 'yui', operation: ParameterOperation.ADD }, { name: 'propD', previousValue: null, newValue: 'qwe', operation: ParameterOperation.ADD }, @@ -30,6 +31,7 @@ describe('Plan pretty printer', () => { planId: 'id', resourceType: 'type', operation: ResourceOperation.DESTROY, + isStateful: false, parameters: [ { name: 'propC', previousValue: 'yui', newValue: null, operation: ParameterOperation.REMOVE }, { name: 'propD', previousValue: 'qwe', newValue: null, operation: ParameterOperation.REMOVE }, @@ -51,6 +53,7 @@ describe('Plan pretty printer', () => { planId: 'id', resourceType: 'type', operation: ResourceOperation.RECREATE, + isStateful: true, parameters: [ { name: 'propA', previousValue: 'abc', newValue: 'def', operation: ParameterOperation.MODIFY }, { From 3adb3a8e7af7e03a790fff3a69801b96ed65bce8 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sat, 13 Dec 2025 22:12:34 -0500 Subject: [PATCH 49/67] feat: Added CLI logins without the browser --- src/api/dashboard/index.ts | 32 ++++++++++++++++++++++++++++---- src/commands/login.ts | 15 +++++++++++---- src/orchestrators/login.ts | 35 +++++++++++++++++++++++++++++++++-- 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/src/api/dashboard/index.ts b/src/api/dashboard/index.ts index 6489cbee..3c9cc787 100644 --- a/src/api/dashboard/index.ts +++ b/src/api/dashboard/index.ts @@ -12,7 +12,10 @@ export const DashboardApiClient = { const res = await fetch( `${config.dashboardUrl}/api/v1/documents/${id}`, - { method: 'GET', headers: { 'Content-Type': 'application/json', 'authorization': `Bearer ${login.accessToken}` } }, + { + method: 'GET', + headers: { 'Content-Type': 'application/json', 'authorization': `Bearer ${login.accessToken}` } + }, ); if (!res.ok) { @@ -32,7 +35,10 @@ export const DashboardApiClient = { const res = await fetch( `${config.dashboardUrl}/api/v1/documents/default/id`, - { method: 'GET', headers: { 'Content-Type': 'application/json', 'authorization': `Bearer ${login.accessToken}` } }, + { + method: 'GET', + headers: { 'Content-Type': 'application/json', 'authorization': `Bearer ${login.accessToken}` } + }, ); if (!res.ok) { @@ -50,12 +56,30 @@ export const DashboardApiClient = { return json.defaultDocumentId; }, + async login(email: string, password: string): Promise { + const res = await fetch( + `${config.dashboardUrl}/api/v1/auth/cli`, + { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + email, + password, + }) + }, + ); + + if (!res.ok) { + const message = await res.text(); + throw new Error(message); + } + + const json = await res.json(); + return json.accessToken; + }, + async saveDocumentUpdate(id: string, contents: string): Promise { const login = LoginHelper.get()?.credentials; if (!login) { throw new Error('Not logged in'); } - - } } diff --git a/src/commands/login.ts b/src/commands/login.ts index 360dd4ab..4d20f87a 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,3 +1,4 @@ +import { Flags } from '@oclif/core'; import chalk from 'chalk'; import { BaseCommand } from '../common/base-command.js'; @@ -5,22 +6,28 @@ import { LoginOrchestrator } from '../orchestrators/login.js'; export default class Login extends BaseCommand { static description = - `Logins to Codify cloud account + `Logins to Codify cloud account. + +By default opens a browser window to login. If username and password are provided, it will attempt to login via CLI. For more information, visit: https://docs.codifycli.com/commands/login ` - static flags = {} + static baseFlags = { + username: Flags.string({ char: 'u', description: 'Username to login with.' }), + password: Flags.string({ char: 'p', description: 'Password to login with.' }), + } static examples = [ '<%= config.bin %> <%= command.id %>', - '<%= config.bin %> <%= command.id %> --path=../../import.codify.jsonc', + '<%= config.bin %> <%= command.id %> --username=user@example.com --password=secret', + '<%= config.bin %> <%= command.id %> -p user@example.com -p secret', ] public async run(): Promise { const { flags } = await this.parse(Login) - await LoginOrchestrator.run(); + await LoginOrchestrator.run({ username: flags.username, password: flags.password }); console.log(chalk.green('\nSuccessfully logged in!')) process.exit(0); diff --git a/src/orchestrators/login.ts b/src/orchestrators/login.ts index 805b7273..0c49cd1b 100644 --- a/src/orchestrators/login.ts +++ b/src/orchestrators/login.ts @@ -3,6 +3,7 @@ import cors from 'cors'; import express, { json } from 'express'; import open from 'open'; +import { DashboardApiClient } from '../api/dashboard/index.js'; import { config } from '../config.js'; import { LoginHelper } from '../connect/login-helper.js'; import { ajv } from '../utils/ajv.js'; @@ -25,8 +26,38 @@ interface Credentials { expiry: string; } +export interface LoginArgs { + username?: string; + password?: string; +} + + export class LoginOrchestrator { - static async run() { + static async run(args?: LoginArgs) { + if (args?.username && !args?.password) { + console.error(chalk.red('Password is required when providing a username')); + process.exit(1); + } + + if (args?.password && !args?.username) { + console.error(chalk.red('Username is required when providing a password')); + process.exit(1); + } + + if (args?.username && args?.password) { + return this.loginWithCredentials(args.username, args.password); + } + + return this.loginViaBrowser(); + } + + private static async loginWithCredentials(username: string, password: string) { + const accessToken = await DashboardApiClient.login(username, password); + await LoginHelper.save(accessToken); + } + + private static async loginViaBrowser() { + const app = express(); app.use(cors({ origin: config.corsAllowedOrigins })) @@ -39,7 +70,7 @@ export class LoginOrchestrator { } console.log( -`Opening CLI auth page... + `Opening CLI auth page... Manually open it here: ${config.dashboardUrl}/auth/cli` ) open(`${config.dashboardUrl}/auth/cli`); From ec8197e9327ab52ff4b73a2d854415c4ff4d4e7e Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sun, 14 Dec 2025 09:14:49 -0500 Subject: [PATCH 50/67] feat: Added test for connect + improvements and fixes --- src/connect/login-helper.ts | 3 + src/orchestrators/connect.ts | 13 +- src/orchestrators/login.ts | 3 + src/plugins/plugin-manager.ts | 46 +------ src/utils/register-kill-listeners.ts | 35 ++++++ .../connect/connect-command.test.ts | 116 ++++++++++++++++++ test/orchestrator/mocks/mock-login.ts | 21 ++++ 7 files changed, 192 insertions(+), 45 deletions(-) create mode 100644 src/utils/register-kill-listeners.ts create mode 100644 test/orchestrator/connect/connect-command.test.ts create mode 100644 test/orchestrator/mocks/mock-login.ts diff --git a/src/connect/login-helper.ts b/src/connect/login-helper.ts index 5de0be29..afebb8ee 100644 --- a/src/connect/login-helper.ts +++ b/src/connect/login-helper.ts @@ -65,6 +65,9 @@ export class LoginHelper { const credentialsPath = path.join(os.homedir(), '.codify', 'credentials.json'); await fs.rm(credentialsPath); } catch {} + + this.instance.isLoggedIn = false; + this.instance.credentials = undefined; } private static async read(): Promise { diff --git a/src/orchestrators/connect.ts b/src/orchestrators/connect.ts index 747cb44e..b8ceb5ea 100644 --- a/src/orchestrators/connect.ts +++ b/src/orchestrators/connect.ts @@ -14,12 +14,13 @@ import { SocketServer } from '../connect/socket-server.js'; import { ProcessName, ctx } from '../events/context.js'; import { Reporter } from '../ui/reporters/reporter.js'; import { LoginOrchestrator } from './login.js'; +import { registerKillListeners } from '../utils/register-kill-listeners.js'; export class ConnectOrchestrator { static rootCommand: string; static nodeBinary: string; - static async run(rootCommand: string, reporter: Reporter, openBrowser = true, onOpen?: (connectionCode: string) => void) { + static async run(rootCommand: string, reporter: Reporter, openBrowser = true, onOpen?: (connectionCode: string, server: Server) => void) { const login = LoginHelper.get()?.isLoggedIn; if (!login) { ctx.log('User is not logged in. Attempting to log in...') @@ -36,7 +37,7 @@ export class ConnectOrchestrator { app.use(json()) app.use(router); - const server = await ConnectOrchestrator.listen(app, reporter, () => { + const server = await ConnectOrchestrator.listen(app, reporter, (server) => { if (openBrowser) { open(`${config.dashboardUrl}/connection/success?code=${connectionSecret}`) console.log(`Open browser window to store code. @@ -45,13 +46,13 @@ If unsuccessful manually enter the code: ${connectionSecret}`) } - onOpen?.(connectionSecret); + onOpen?.(connectionSecret, server); }); SocketServer.init(server, connectionSecret); } - private static listen(app: express.Application, reporter: Reporter, onOpen: () => void): Promise { + private static listen(app: express.Application, reporter: Reporter, onOpen: (server: Server) => void): Promise { return new Promise((resolve) => { const server = app.listen(config.connectServerPort, async (error) => { if (error) { @@ -77,9 +78,11 @@ ${connectionSecret}`) } } else { resolve(server); - onOpen(); + onOpen(server); } }); + + registerKillListeners(() => server.close()); }); } diff --git a/src/orchestrators/login.ts b/src/orchestrators/login.ts index 0c49cd1b..4c12d7aa 100644 --- a/src/orchestrators/login.ts +++ b/src/orchestrators/login.ts @@ -7,6 +7,7 @@ import { DashboardApiClient } from '../api/dashboard/index.js'; import { config } from '../config.js'; import { LoginHelper } from '../connect/login-helper.js'; import { ajv } from '../utils/ajv.js'; +import { registerKillListeners } from '../utils/register-kill-listeners.js'; const schema = { type: 'object', @@ -76,6 +77,8 @@ Manually open it here: ${config.dashboardUrl}/auth/cli` open(`${config.dashboardUrl}/auth/cli`); }) + registerKillListeners(() => server.close()); + await new Promise((resolve, reject) => { app.post('/', async (req, res) => { try { diff --git a/src/plugins/plugin-manager.ts b/src/plugins/plugin-manager.ts index be9993d1..8fd11707 100644 --- a/src/plugins/plugin-manager.ts +++ b/src/plugins/plugin-manager.ts @@ -11,6 +11,7 @@ import { ResourceConfig } from '../entities/resource-config.js'; import { ResourceInfo } from '../entities/resource-info.js'; import { SubProcessName, ctx } from '../events/context.js'; import { groupBy } from '../utils/index.js'; +import { registerKillListeners } from '../utils/register-kill-listeners.js'; import { Plugin } from './plugin.js'; import { PluginResolver } from './resolver.js'; @@ -35,7 +36,11 @@ export class PluginManager { this.plugins.set(plugin.name, plugin) } - this.registerKillListeners(plugins) + registerKillListeners(() => { + for (const plugin of plugins) { + plugin.kill() + } + }); return this.initializePlugins(plugins, secureMode, verbosityLevel); } @@ -193,43 +198,4 @@ export class PluginManager { return resourceMap; } - - /** Clean up any stranglers and child processes if the CLI is killed */ - private registerKillListeners(plugins: Plugin[]) { - const kill = (code: number | string) => { - plugins.forEach((p) => { - p.kill() - }) - - let exitCode = 0; - switch (code) { - case 'SIGTERM': { - exitCode = 143; - break; - } - - case 'SIGHUP': { - exitCode = 129; - break; - } - - case 'SIGINT': { - exitCode = 130; - break; - } - } - - const parsedCode = typeof code === 'string' ? Number.parseInt(code, 10) : code; - if (Number.isInteger(parsedCode)) { - exitCode = parsedCode; - } - - process.exit(exitCode); - } - - process.on('exit', kill) - process.on('SIGINT', kill) - process.on('SIGTERM', kill) - process.on('SIGHUP', kill) - } } diff --git a/src/utils/register-kill-listeners.ts b/src/utils/register-kill-listeners.ts new file mode 100644 index 00000000..7d54eee6 --- /dev/null +++ b/src/utils/register-kill-listeners.ts @@ -0,0 +1,35 @@ +export function registerKillListeners(kill: (code: number | string) => void) { + const killHandler = (code: number | string) => { + kill(code); + + let exitCode = 0; + switch (code) { + case 'SIGTERM': { + exitCode = 143; + break; + } + + case 'SIGHUP': { + exitCode = 129; + break; + } + + case 'SIGINT': { + exitCode = 130; + break; + } + } + + const parsedCode = typeof code === 'string' ? Number.parseInt(code, 10) : code; + if (Number.isInteger(parsedCode)) { + exitCode = parsedCode; + } + + process.exit(exitCode); + } + + process.on('exit', killHandler) + process.on('SIGINT', killHandler) + process.on('SIGTERM', killHandler) + process.on('SIGHUP', killHandler) +} diff --git a/test/orchestrator/connect/connect-command.test.ts b/test/orchestrator/connect/connect-command.test.ts new file mode 100644 index 00000000..7481c7bd --- /dev/null +++ b/test/orchestrator/connect/connect-command.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MockOs } from '../mocks/system.js'; +import { MockReporter } from '../mocks/reporter.js'; +import { ConnectOrchestrator } from '../../../src/orchestrators/connect'; +import * as net from 'node:net'; +import { config } from '../../../src/config.js'; +import { fakeLogin, fakeLogout } from '../mocks/mock-login'; +import * as open from 'open' +import { LoginOrchestrator } from '../../../src/orchestrators/login'; +import { Server } from 'node:http'; +import { vol } from 'memfs'; + +vi.mock(import('../../../src/orchestrators/login'), async () => { + return { + LoginOrchestrator: { + run: async () => {} + }, + } +}) + +vi.mock('node:fs', async () => { + const { fs } = await import('memfs'); + return fs +}) + +vi.mock('node:fs/promises', async () => { + const { fs } = await import('memfs'); + return fs.promises; +}) + +vi.mock(import('open'), async () => { + return { + default: vi.fn() + } +}) + +// The apply orchestrator directly calls plan so this will test both +describe('Connect orchestrator tests', () => { + beforeEach(() => { + vol.reset(); + }) + + it('It will start a local server on config.connectServerPort', async () => { + const reporter = new MockReporter(); + await fakeLogin(); + + const openSpy = vi.spyOn(open, 'default'); + + await new Promise((done) => { + ConnectOrchestrator.run('codify', reporter, false, async (connectionCode: string , server: Server) => { + expect(connectionCode).to.be.a('string'); + + const portInUse = await checkPortStatus(config.connectServerPort); + expect(portInUse).to.be.true; + + server.close(); + done(); + }) + }) + }); + + it('It will ask for a login if the user is not logged in', async () => { + const reporter = new MockReporter({}); + await fakeLogout(); + + const loginRunSpy = vi.spyOn(LoginOrchestrator, 'run'); + const openSpy = vi.spyOn(open, 'default'); + + await new Promise((done) => { + ConnectOrchestrator.run('codify', reporter, false, async (connectionCode: string, server: Server) => { + expect(connectionCode).to.be.a('string'); + + const portInUse = await checkPortStatus(config.connectServerPort); + expect(portInUse).to.be.true; + expect(loginRunSpy).toHaveBeenCalledOnce(); + + done(); + server.close(); + }) + + }); + }); + + const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + afterEach(() => { + vi.resetAllMocks(); + MockOs.reset(); + }) + + function checkPortStatus(port: number, host = '127.0.0.1') { + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + + socket.once('connect', () => { + // If 'connect' event fires, the port is open and listening + socket.destroy(); + resolve(true); // Port is in use + }); + + socket.once('error', (err) => { + // Any error typically means the port is not listening + // EADDRNOTAVAIL, ECONNREFUSED, etc. + reject(err); // Port is likely free or unreachable + }); + + socket.once('timeout', () => { + socket.destroy(); + reject(new Error('Connection attempt timed out')); + }); + + socket.connect(port, host); + }); + } + +}) diff --git a/test/orchestrator/mocks/mock-login.ts b/test/orchestrator/mocks/mock-login.ts new file mode 100644 index 00000000..588542d8 --- /dev/null +++ b/test/orchestrator/mocks/mock-login.ts @@ -0,0 +1,21 @@ +import { vi } from 'vitest'; + +import { LoginHelper } from '../../../src/connect/login-helper.js'; + +/** + * Must mock node:fs/promises before calling this function + */ +export async function fakeLogin(): Promise { + await LoginHelper.load(); + const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30' + await LoginHelper.save(token); + return token; +} + +/** + * Must mock node:fs/promises before calling this function + */ +export async function fakeLogout() { + await LoginHelper.load(); + await LoginHelper.logout(); +} From f7887d7fcda041d1f53b9d4f2028935a205e6d22 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sun, 14 Dec 2025 11:03:01 -0500 Subject: [PATCH 51/67] feat: Added test for connect commands (apply, plan, import, etc) + improvements and fixes --- src/connect/http-routes/create-command.ts | 3 +- .../http-routes/handlers/apply-handler.ts | 2 +- .../http-routes/handlers/import-handler.ts | 2 +- src/connect/http-routes/handlers/index.ts | 2 - .../http-routes/handlers/init-handler.ts | 4 +- .../http-routes/handlers/plan-handler.ts | 6 +- .../http-routes/handlers/refresh-handler.ts | 4 +- src/connect/http-server.ts | 91 ++++ src/connect/socket-server.ts | 7 +- src/orchestrators/connect.ts | 63 +-- .../connect/connect-command.test.ts | 3 +- .../connect/socket-server.test.ts | 412 ++++++++++++++++++ 12 files changed, 519 insertions(+), 80 deletions(-) create mode 100644 src/connect/http-server.ts create mode 100644 test/orchestrator/connect/socket-server.test.ts diff --git a/src/connect/http-routes/create-command.ts b/src/connect/http-routes/create-command.ts index e41fe445..50a740af 100644 --- a/src/connect/http-routes/create-command.ts +++ b/src/connect/http-routes/create-command.ts @@ -6,11 +6,12 @@ import WebSocket from 'ws'; import { Session, SocketServer } from '../socket-server.js'; export enum ConnectCommand { - TERMINAL = 'terminal', + TERMINAL = 'start terminal', APPLY = 'apply', PLAN = 'plan', IMPORT = 'import', REFRESH = 'refresh', + INIT = 'init', } interface Params { diff --git a/src/connect/http-routes/handlers/apply-handler.ts b/src/connect/http-routes/handlers/apply-handler.ts index dd27a7aa..b01cdaf9 100644 --- a/src/connect/http-routes/handlers/apply-handler.ts +++ b/src/connect/http-routes/handlers/apply-handler.ts @@ -1,6 +1,6 @@ import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; import { ConfigFileSchema } from 'codify-schemas'; -import fs from 'node:fs/promises'; +import * as fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { WebSocket } from 'ws'; diff --git a/src/connect/http-routes/handlers/import-handler.ts b/src/connect/http-routes/handlers/import-handler.ts index 3323f03e..3a45dc75 100644 --- a/src/connect/http-routes/handlers/import-handler.ts +++ b/src/connect/http-routes/handlers/import-handler.ts @@ -1,7 +1,7 @@ import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; import { ConfigFileSchema } from 'codify-schemas'; import { diffChars } from 'diff'; -import fs from 'node:fs/promises'; +import * as fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { WebSocket } from 'ws'; diff --git a/src/connect/http-routes/handlers/index.ts b/src/connect/http-routes/handlers/index.ts index a33e29ce..3be112ef 100644 --- a/src/connect/http-routes/handlers/index.ts +++ b/src/connect/http-routes/handlers/index.ts @@ -18,8 +18,6 @@ router.post('/session', (req, res) => { } const sessionId = socketServer.createSession(clientId); - console.log('Terminal session created!', sessionId) - return res.status(200).json({ sessionId }); }) diff --git a/src/connect/http-routes/handlers/init-handler.ts b/src/connect/http-routes/handlers/init-handler.ts index dd8101ad..7e7a6bcf 100644 --- a/src/connect/http-routes/handlers/init-handler.ts +++ b/src/connect/http-routes/handlers/init-handler.ts @@ -1,6 +1,6 @@ import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; import { diffChars } from 'diff'; -import fs from 'node:fs/promises'; +import * as fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { WebSocket } from 'ws'; @@ -46,7 +46,7 @@ export function initHandler() { } return createCommandHandler({ - name: ConnectCommand.IMPORT, + name: ConnectCommand.INIT, spawnCommand, onExit }); diff --git a/src/connect/http-routes/handlers/plan-handler.ts b/src/connect/http-routes/handlers/plan-handler.ts index 7eefc2b5..532a0d74 100644 --- a/src/connect/http-routes/handlers/plan-handler.ts +++ b/src/connect/http-routes/handlers/plan-handler.ts @@ -1,7 +1,7 @@ import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; import { ConfigFileSchema } from 'codify-schemas'; -import fs from 'node:fs/promises'; -import os from 'node:os'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; import path from 'node:path'; import { WebSocket } from 'ws'; @@ -25,7 +25,7 @@ export function planHandler() { const tmpDir = await fs.mkdtemp(os.tmpdir()); const filePath = path.join(tmpDir, 'codify.jsonc'); - await fs.writeFile(filePath, JSON.stringify(codifyConfig, null, 2)); + await fs.writeFile(filePath, JSON.stringify(codifyConfig, null, 2), { }); session.additionalData.filePath = filePath; diff --git a/src/connect/http-routes/handlers/refresh-handler.ts b/src/connect/http-routes/handlers/refresh-handler.ts index 3091e144..1ab184d1 100644 --- a/src/connect/http-routes/handlers/refresh-handler.ts +++ b/src/connect/http-routes/handlers/refresh-handler.ts @@ -1,7 +1,7 @@ import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; import { ConfigFileSchema } from 'codify-schemas'; import { diffChars } from 'diff'; -import fs from 'node:fs/promises'; +import * as fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { WebSocket } from 'ws'; @@ -26,7 +26,7 @@ export function refreshHandler() { } if (!type || !Object.values(RefreshType).includes(type as RefreshType)) { - throw new Error('Unable to parse import type'); + throw new Error('Unable to parse refresh type'); } if (type === RefreshType.REFRESH_SPECIFIC && (!resourceTypes || !Array.isArray(resourceTypes))) { diff --git a/src/connect/http-server.ts b/src/connect/http-server.ts new file mode 100644 index 00000000..524d320e --- /dev/null +++ b/src/connect/http-server.ts @@ -0,0 +1,91 @@ +import cors from 'cors'; +import express, { json } from 'express'; +// @ts-ignore +import killPort from 'kill-port' +import { Server } from 'node:http'; +import open from 'open'; + +import { config } from '../config.js'; +import { ProcessName, ctx } from '../events/context.js'; +import { Reporter } from '../ui/reporters/reporter.js'; +import { registerKillListeners } from '../utils/register-kill-listeners.js'; +import router from './http-routes/router.js'; + +export async function createHttpServer( + connectionSecret: string, + reporter: Reporter, + openBrowser = true, + onOpen?: (connectionCode: string, server: Server) => void +): Promise { + const app = express(); + + app.use(cors({ origin: config.corsAllowedOrigins })) + app.use(json()) + app.use(createAuthHandler(connectionSecret)) + app.use(router); + app.use(errorHandler) + + return listen(app, reporter, (server) => { + if (openBrowser) { + open(`${config.dashboardUrl}/connection/success?code=${connectionSecret}`) + console.log(`Open browser window to store code. + +If unsuccessful manually enter the code: +${connectionSecret}`) + } + + onOpen?.(connectionSecret, server); + }); + +} + +function listen(app: express.Application, reporter: Reporter, onOpen: (server: Server) => void): Promise { + return new Promise((resolve) => { + const server = app.listen(config.connectServerPort, async (error) => { + if (error) { + + // This whole below allows the user to terminate the existing instance and continue + // Use kill-port to terminate the existing instance + if (error.message.includes('EADDRINUSE')) { + const ifTerminate = await reporter.promptConfirmation('An instance of \'codify connect\' is already running. Do you want to terminate the existing instance and continue?'); + + if (!ifTerminate) { + console.error('\n\nExiting...') + process.exit(1); + } + + ctx.processStarted(ProcessName.TERMINATE) + await reporter.displayProgress(); + await killPort(config.connectServerPort); + ctx.processFinished(ProcessName.TERMINATE); + await reporter.hide(); + + setTimeout(() => { + ctx.log('Retrying connection...') + listen(app, reporter, onOpen).then((server) => resolve(server)); + }, 300); + } + } else { + resolve(server); + onOpen(server); + } + }); + + registerKillListeners(() => server.close()); + }); +} + +function createAuthHandler(connectionCode: string) { + return (req, res, next) => { + if (req.header('Authorization') !== connectionCode) { + return res.status(400).json({ error: 'Invalid authorization' }) + } + + next(); + } +} + +function errorHandler(err, req, res, next) { + console.log(err.message); + res.status(500).json({ error: err.message }); +} diff --git a/src/connect/socket-server.ts b/src/connect/socket-server.ts index ee725680..86a377e5 100644 --- a/src/connect/socket-server.ts +++ b/src/connect/socket-server.ts @@ -76,12 +76,7 @@ export class SocketServer { private onUpgrade = (request: IncomingMessage, socket: Duplex, head: Buffer): void => { const { pathname } = new URL(request.url!, 'ws://localhost:51040') - // Ignore all socket io so it does not interfere - if (pathname.includes('socket.io')) { - return; - } - - if (/*! this.validateOrigin(request.headers.origin ?? request.headers.referer ?? '') || */ !this.validateConnectionSecret(request)) { + if (!this.validateConnectionSecret(request)) { console.error('Unauthorized request. Connection code:', request.headers['sec-websocket-protocol']); socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') socket.destroy(); diff --git a/src/orchestrators/connect.ts b/src/orchestrators/connect.ts index b8ceb5ea..0fb06c92 100644 --- a/src/orchestrators/connect.ts +++ b/src/orchestrators/connect.ts @@ -1,20 +1,12 @@ -import { Config } from '@oclif/core'; -import cors from 'cors'; -import express, { json } from 'express'; -// @ts-ignore -import killPort from 'kill-port'; import { randomBytes } from 'node:crypto'; import { Server } from 'node:http'; -import open from 'open'; -import { config } from '../config.js'; -import router from '../connect/http-routes/router.js'; +import { createHttpServer } from '../connect/http-server.js'; import { LoginHelper } from '../connect/login-helper.js'; import { SocketServer } from '../connect/socket-server.js'; -import { ProcessName, ctx } from '../events/context.js'; +import { ctx } from '../events/context.js'; import { Reporter } from '../ui/reporters/reporter.js'; import { LoginOrchestrator } from './login.js'; -import { registerKillListeners } from '../utils/register-kill-listeners.js'; export class ConnectOrchestrator { static rootCommand: string; @@ -31,60 +23,11 @@ export class ConnectOrchestrator { this.nodeBinary = process.execPath; const connectionSecret = ConnectOrchestrator.tokenGenerate() - const app = express(); - - app.use(cors({ origin: config.corsAllowedOrigins })) - app.use(json()) - app.use(router); - - const server = await ConnectOrchestrator.listen(app, reporter, (server) => { - if (openBrowser) { - open(`${config.dashboardUrl}/connection/success?code=${connectionSecret}`) - console.log(`Open browser window to store code. - -If unsuccessful manually enter the code: -${connectionSecret}`) - } - - onOpen?.(connectionSecret, server); - }); + const server = await createHttpServer(connectionSecret, reporter, openBrowser, onOpen); SocketServer.init(server, connectionSecret); } - private static listen(app: express.Application, reporter: Reporter, onOpen: (server: Server) => void): Promise { - return new Promise((resolve) => { - const server = app.listen(config.connectServerPort, async (error) => { - if (error) { - if (error.message.includes('EADDRINUSE')) { - const ifTerminate = await reporter.promptConfirmation('An instance of \'codify connect\' is already running. Do you want to terminate the existing instance and continue?'); - - if (!ifTerminate) { - console.error('\n\nExiting...') - process.exit(1); - } - - ctx.processStarted(ProcessName.TERMINATE) - await reporter.displayProgress(); - await killPort(config.connectServerPort); - ctx.processFinished(ProcessName.TERMINATE); - await reporter.hide(); - - setTimeout(() => { - ctx.log('Retrying connection...') - ConnectOrchestrator.listen(app, reporter, onOpen).then((server) => resolve(server)); - }, 300); - - } - } else { - resolve(server); - onOpen(server); - } - }); - - registerKillListeners(() => server.close()); - }); - } private static tokenGenerate(bytes = 4): string { return Buffer.from(randomBytes(bytes)).toString('hex') diff --git a/test/orchestrator/connect/connect-command.test.ts b/test/orchestrator/connect/connect-command.test.ts index 7481c7bd..283d3392 100644 --- a/test/orchestrator/connect/connect-command.test.ts +++ b/test/orchestrator/connect/connect-command.test.ts @@ -74,10 +74,9 @@ describe('Connect orchestrator tests', () => { expect(portInUse).to.be.true; expect(loginRunSpy).toHaveBeenCalledOnce(); - done(); server.close(); + done(); }) - }); }); diff --git a/test/orchestrator/connect/socket-server.test.ts b/test/orchestrator/connect/socket-server.test.ts new file mode 100644 index 00000000..49d6464b --- /dev/null +++ b/test/orchestrator/connect/socket-server.test.ts @@ -0,0 +1,412 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MockOs } from '../mocks/system.js'; +import { MockReporter } from '../mocks/reporter.js'; +import { ConnectOrchestrator } from '../../../src/orchestrators/connect'; +import * as net from 'node:net'; +import { config } from '../../../src/config.js'; +import { fakeLogin, fakeLogout } from '../mocks/mock-login'; +import * as open from 'open' +import { LoginOrchestrator } from '../../../src/orchestrators/login'; +import { Server } from 'node:http'; +import { vol } from 'memfs'; +import { Reporter } from '../../../src/ui/reporters/reporter.js'; +import { mkdir } from 'node:fs/promises'; +import os from 'node:os'; + +vi.mock(import('../../../src/orchestrators/login'), async () => { + return { + LoginOrchestrator: { + run: async () => { + } + }, + } +}) + +vi.mock('node:fs', async () => { + const { fs } = await import('memfs'); + return fs +}) + +vi.mock('node:fs/promises', async () => { + const { fs } = await import('memfs'); + return fs.promises; +}) + +vi.mock(import('open'), async () => { + return { + default: vi.fn() + } +}) + +// The apply orchestrator directly calls plan so this will test both +describe('Connect orchestrator tests', () => { + beforeEach(() => { + vol.reset(); + }) + + it('Multiple clients can connect to the WebSocket server', async () => { + const reporter = new MockReporter(); + await fakeLogin(); + + await new Promise((done) => { + startServer(reporter, async (connectionCode, clientId, server) => { + const socket = new WebSocket(`ws://localhost:${config.connectServerPort}/ws`, [connectionCode]); + socket.onopen = () => { + console.log('Connected 2'); + + server.close() + done(); + } + }); + }); + }); + + it('Will not create initial connection on the wrong connection code', async () => { + const reporter = new MockReporter(); + await fakeLogin(); + + await new Promise((done) => { + ConnectOrchestrator.run('codify', reporter, false, async (connectionCode: string, server: Server) => { + expect(connectionCode).to.be.a('string'); + + try { + const socket = new WebSocket(`ws://localhost:${config.connectServerPort}/ws`, ['random code']); + } catch(e) { + expect(e.message).to.contain('Invalid Sec-WebSocket-Protocol value') + server.close(); + done(); + } + }); + }); + }); + + it('Will not allow a new session on the wrong code', async () => { + const reporter = new MockReporter(); + await fakeLogin(); + + await new Promise((done) => { + startServer(reporter, async (connectionCode, clientId, server) => { + const sessionResponse = await fetch(`http://localhost:${config.connectServerPort}/session`, { + method: 'POST', + headers: { 'Authorization': 'random-code', 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId }) + }); + + expect(sessionResponse.ok).to.be.false; + + server.close(); + done(); + }); + }); + }); + + it('Will not allow a new command on the wrong code', async () => { + const reporter = new MockReporter(); + await fakeLogin(); + + await new Promise((done) => { + startSession(reporter, async (connectionCode, clientId, server, socket, sessionId) => { + const commandResponse = await fetch(`http://localhost:${config.connectServerPort}/plan/${sessionId}/start`, { + method: 'POST', + headers: { 'Authorization': 'random-code', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + config: [ + { + type: 'homebrew', + formulae: ['zsh'] + } + ] + }) + }); + + expect(commandResponse.ok).to.be.false; + + server.close(); + done(); + }); + }); + }); + + it('Can handle a new action session (terminal)', async () => { + const reporter = new MockReporter(); + await fakeLogin(); + + await new Promise((done) => { + startServer(reporter, async (connectionCode, clientId, server) => { + const sessionResponse = await fetch(`http://localhost:${config.connectServerPort}/session`, { + method: 'POST', + headers: { 'Authorization': `${connectionCode}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId }) + }); + + expect(sessionResponse.ok).to.be.true; + const { sessionId } = await sessionResponse.json(); + expect(sessionId).to.be.a('string'); + + const socket = new WebSocket(`ws://localhost:${config.connectServerPort}/ws/session/${sessionId}`, [connectionCode]); + + socket.onmessage = (message) => { + expect(message).to.not.be.null; + } + + const commandResponse = await fetch(`http://localhost:${config.connectServerPort}/terminal/${sessionId}/start`, { + method: 'POST', + headers: { 'Authorization': `${connectionCode}`, 'Content-Type': 'application/json' }, + }); + + expect(commandResponse.ok).to.be.true; + server.close(); + done(); + }); + }); + }); + + it('Can handle a new action session (plan)', async () => { + const reporter = new MockReporter(); + await fakeLogin(); + await mkdir(os.tmpdir(), { recursive: true }); + + await new Promise((done) => { + startSession(reporter, async (connectionCode, clientId, server, socket, sessionId) => { + const commandResponse = await fetch(`http://localhost:${config.connectServerPort}/plan/${sessionId}/start`, { + method: 'POST', + headers: { 'Authorization': `${connectionCode}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + config: [ + { + type: 'homebrew', + formulae: ['zsh'] + } + ] + }) + }); + + expect(commandResponse.ok).to.be.true; + + server.close(); + done(); + }); + }); + }); + + it('Can handle a new action session (apply)', async () => { + const reporter = new MockReporter(); + await fakeLogin(); + await mkdir(os.tmpdir(), { recursive: true }); + + await new Promise((done) => { + startSession(reporter, async (connectionCode, clientId, server, socket, sessionId) => { + const commandResponse = await fetch(`http://localhost:${config.connectServerPort}/apply/${sessionId}/start`, { + method: 'POST', + headers: { 'Authorization': `${connectionCode}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + config: [ + { + type: 'homebrew', + formulae: ['zsh'] + } + ] + }) + }); + + expect(commandResponse.ok).to.be.true; + + server.close(); + done(); + }); + }); + }); + + it('Can handle a new action session (import specific)', async () => { + const reporter = new MockReporter(); + await fakeLogin(); + await mkdir(os.tmpdir(), { recursive: true }); + + await new Promise((done) => { + startSession(reporter, async (connectionCode, clientId, server, socket, sessionId) => { + const commandResponse = await fetch(`http://localhost:${config.connectServerPort}/import/${sessionId}/start`, { + method: 'POST', + headers: { 'Authorization': `${connectionCode}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'import_specific', + resourceTypes: ['pyenv'], + config: [ + { + type: 'homebrew', + formulae: ['zsh'] + } + ] + }) + }); + + expect(commandResponse.ok).to.be.true; + + server.close(); + done(); + }); + }); + }); + + it('Can handle a new action session (import all)', async () => { + const reporter = new MockReporter(); + await fakeLogin(); + await mkdir(os.tmpdir(), { recursive: true }); + + await new Promise((done) => { + startSession(reporter, async (connectionCode, clientId, server, socket, sessionId) => { + const commandResponse = await fetch(`http://localhost:${config.connectServerPort}/import/${sessionId}/start`, { + method: 'POST', + headers: { 'Authorization': `${connectionCode}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'import', + config: [ + { + type: 'homebrew', + formulae: ['zsh'] + } + ] + }) + }); + + expect(commandResponse.ok).to.be.true; + + server.close(); + done(); + }); + }); + }); + + it('Can handle a new action session (refresh specific)', async () => { + const reporter = new MockReporter(); + await fakeLogin(); + await mkdir(os.tmpdir(), { recursive: true }); + + await new Promise((done) => { + startSession(reporter, async (connectionCode, clientId, server, socket, sessionId) => { + const commandResponse = await fetch(`http://localhost:${config.connectServerPort}/refresh/${sessionId}/start`, { + method: 'POST', + headers: { 'Authorization': `${connectionCode}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'refresh_specific', + resourceTypes: ['homebrew'], + config: [ + { + type: 'homebrew', + formulae: ['zsh'] + } + ] + }) + }); + + expect(commandResponse.ok).to.be.true; + + server.close(); + done(); + }); + }); + }); + + it('Can handle a new action session (refresh all)', async () => { + const reporter = new MockReporter(); + await fakeLogin(); + await mkdir(os.tmpdir(), { recursive: true }); + + await new Promise((done) => { + startSession(reporter, async (connectionCode, clientId, server, socket, sessionId) => { + const commandResponse = await fetch(`http://localhost:${config.connectServerPort}/refresh/${sessionId}/start`, { + method: 'POST', + headers: { 'Authorization': `${connectionCode}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'refresh', + config: [ + { + type: 'homebrew', + formulae: ['zsh'] + } + ] + }) + }); + + expect(commandResponse.ok).to.be.true; + + server.close(); + done(); + }); + }); + }); + + it('Can handle a new action session (init)', async () => { + const reporter = new MockReporter(); + await fakeLogin(); + await mkdir(os.tmpdir(), { recursive: true }); + + await new Promise((done) => { + startSession(reporter, async (connectionCode, clientId, server, socket, sessionId) => { + const commandResponse = await fetch(`http://localhost:${config.connectServerPort}/init/${sessionId}/start`, { + method: 'POST', + headers: { 'Authorization': `${connectionCode}`, 'Content-Type': 'application/json' }, + }); + + expect(commandResponse.ok).to.be.true; + + server.close(); + done(); + }); + }); + }); + + afterEach(() => { + + vi.resetAllMocks(); + MockOs.reset(); + }) + + function startServer(reporter: Reporter, onOpen: (connectionCode: string, clientId: string, server: Server) => void) { + ConnectOrchestrator.run('codify', reporter, false, async (connectionCode: string, server: Server) => { + expect(connectionCode).to.be.a('string'); + + const socket = new WebSocket(`ws://localhost:${config.connectServerPort}/ws`, [connectionCode]); + socket.onopen = () => { + console.log('Connected'); + + // Every time a connection is opened, expect the opened message with info about the connection. + socket.onmessage = (event) => { + const messageData = JSON.parse(event.data); + + // We want to check if the open event happens. This event provides the client with their client Id to identify themselves in + // subsequent calls + if (messageData.key === 'opened') { + expect(messageData).toMatchObject({ + key: 'opened', + data: { clientId: expect.any(String), startTimestamp: expect.any(String) } + }); + + onOpen(connectionCode, messageData.data.clientId, server); + } + } + } + }) + } + + function startSession(reporter: Reporter, onOpen: (connectionCode: string, clientId: string, server: Server, socket: WebSocket, sessionId: string) => void) { + startServer(reporter, async (connectionCode, clientId, server) => { + const sessionResponse = await fetch(`http://localhost:${config.connectServerPort}/session`, { + method: 'POST', + headers: { 'Authorization': `${connectionCode}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId }) + }); + + expect(sessionResponse.ok).to.be.true; + const { sessionId } = await sessionResponse.json(); + expect(sessionId).to.be.a('string'); + + const socket = new WebSocket(`ws://localhost:${config.connectServerPort}/ws/session/${sessionId}`, [connectionCode]); + + socket.onmessage = (message) => { + expect(message).to.not.be.null; + } + + onOpen(connectionCode, clientId, server, socket, sessionId); + }) + } +}) From c598972b46ad4842b037278893419896d6f56564 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sun, 14 Dec 2025 11:53:10 -0500 Subject: [PATCH 52/67] feat: Added additional tests for edit, login and refresh --- src/orchestrators/login.ts | 20 +- test/orchestrator/edit/edit.test.ts | 87 ++++++++ test/orchestrator/login/login.test.ts | 138 ++++++++++++ test/orchestrator/refresh/refresh.test.ts | 243 ++++++++++++++++++++++ 4 files changed, 477 insertions(+), 11 deletions(-) create mode 100644 test/orchestrator/edit/edit.test.ts create mode 100644 test/orchestrator/login/login.test.ts create mode 100644 test/orchestrator/refresh/refresh.test.ts diff --git a/src/orchestrators/login.ts b/src/orchestrators/login.ts index 4c12d7aa..78befe8d 100644 --- a/src/orchestrators/login.ts +++ b/src/orchestrators/login.ts @@ -20,19 +20,11 @@ const schema = { required: ['accessToken'], } -interface Credentials { - accessToken: string; - email: string; - userId: string; - expiry: string; -} - export interface LoginArgs { username?: string; password?: string; } - export class LoginOrchestrator { static async run(args?: LoginArgs) { if (args?.username && !args?.password) { @@ -53,8 +45,14 @@ export class LoginOrchestrator { } private static async loginWithCredentials(username: string, password: string) { - const accessToken = await DashboardApiClient.login(username, password); - await LoginHelper.save(accessToken); + try { + const accessToken = await DashboardApiClient.login(username, password); + await LoginHelper.save(accessToken); + } catch (e) { + console.error(chalk.red(JSON.parse(e.message).error)); + + process.exit(1); + } } private static async loginViaBrowser() { @@ -82,7 +80,7 @@ Manually open it here: ${config.dashboardUrl}/auth/cli` await new Promise((resolve, reject) => { app.post('/', async (req, res) => { try { - const body = req.body as Credentials; + const body = req.body as { accessToken: string }; if (!ajv.validate(schema, body)) { console.error(chalk.red('Received invalid credentials. Please submit a support ticket')) diff --git a/test/orchestrator/edit/edit.test.ts b/test/orchestrator/edit/edit.test.ts new file mode 100644 index 00000000..ae85dc4f --- /dev/null +++ b/test/orchestrator/edit/edit.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MockOs } from '../mocks/system.js'; +import { MockReporter } from '../mocks/reporter.js'; +import { ConnectOrchestrator } from '../../../src/orchestrators/connect'; +import * as net from 'node:net'; +import { config } from '../../../src/config.js'; +import { fakeLogin, fakeLogout } from '../mocks/mock-login'; +import * as open from 'open' +import { LoginOrchestrator } from '../../../src/orchestrators/login'; +import { Server } from 'node:http'; +import { vol } from 'memfs'; +import { EditOrchestrator } from '../../../src/orchestrators/edit'; + +vi.mock(import('../../../src/orchestrators/login'), async () => { + return { + LoginOrchestrator: { + run: async () => {} + }, + } +}) + +vi.mock('node:fs', async () => { + const { fs } = await import('memfs'); + return fs +}) + +vi.mock('node:fs/promises', async () => { + const { fs } = await import('memfs'); + return fs.promises; +}) + +vi.mock(import('open'), async () => { + return { + default: vi.fn() + } +}) + +// The apply orchestrator directly calls plan so this will test both +describe('Edit orchestrator tests', () => { + beforeEach(() => { + vol.reset(); + }) + + it('It will start a local server on config.connectServerPort', async () => { + const reporter = new MockReporter(); + await fakeLogin(); + + const openSpy = vi.spyOn(open, 'default'); + EditOrchestrator.run('codify', reporter); + + await expect.poll(async () => { + return checkPortStatus(config.connectServerPort); + }).toBeTruthy() + }); + + + afterEach(() => { + vi.resetAllMocks(); + MockOs.reset(); + }) + + function checkPortStatus(port: number, host = '127.0.0.1') { + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + + socket.once('connect', () => { + // If 'connect' event fires, the port is open and listening + socket.destroy(); + resolve(true); // Port is in use + }); + + socket.once('error', (err) => { + // Any error typically means the port is not listening + // EADDRNOTAVAIL, ECONNREFUSED, etc. + reject(err); // Port is likely free or unreachable + }); + + socket.once('timeout', () => { + socket.destroy(); + reject(new Error('Connection attempt timed out')); + }); + + socket.connect(port, host); + }); + } + +}) diff --git a/test/orchestrator/login/login.test.ts b/test/orchestrator/login/login.test.ts new file mode 100644 index 00000000..394a7f5d --- /dev/null +++ b/test/orchestrator/login/login.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MockOs } from '../mocks/system.js'; +import { MockReporter } from '../mocks/reporter.js'; +import * as net from 'node:net'; +import { config } from '../../../src/config.js'; +import { fakeLogout } from '../mocks/mock-login'; +import * as open from 'open' +import { LoginOrchestrator } from '../../../src/orchestrators/login'; +import { vol } from 'memfs'; +import { LoginHelper } from '../../../src/connect/login-helper'; + +vi.mock('node:fs', async () => { + const { fs } = await import('memfs'); + return fs +}) + +vi.mock('node:fs/promises', async () => { + const { fs } = await import('memfs'); + return fs.promises; +}) + +vi.mock(import('open'), async () => { + return { + default: vi.fn() + } +}) + +const tempJWT = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30' + +// The apply orchestrator directly calls plan so this will test both +describe('Login orchestrator tests', () => { + beforeEach(() => { + vol.reset(); + }) + + it('It can save a successful login', async () => { + await fakeLogout(); + expect(LoginHelper.get().isLoggedIn).to.be.false; + + const openSpy = vi.spyOn(open, 'default'); + LoginOrchestrator.run(); + + await expect.poll(async () => { + return checkPortStatus(config.loginServerPort); + }, {}).toBeTruthy() + + expect(openSpy).toBeCalledWith('https://dashboard.codifycli.com/auth/cli'); + + const saveResponse = await fetch(`http://localhost:${config.loginServerPort}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + accessToken: tempJWT, + }) + }) + + expect(saveResponse.ok).to.be.true; + expect(LoginHelper.get().isLoggedIn).to.be.true; + expect(LoginHelper.get().credentials).toMatchObject({ + accessToken: tempJWT, + }) + + expect(() => checkPortStatus(config.loginServerPort)).to.throw + }); + + it('It will login via credentials (wrong credentials)', async () => { + await fakeLogout(); + expect(LoginHelper.get().isLoggedIn).to.be.false; + + expect(async () => await LoginOrchestrator.run({ + username: 'my-user', + password: 'password' + })).to.throw; + + expect(LoginHelper.get().isLoggedIn).to.be.false; + }); + + it('It will login via credentials (correct credentials)', async () => { + await fakeLogout(); + expect(LoginHelper.get().isLoggedIn).to.be.false; + + global.fetch = vi.fn(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ accessToken: tempJWT }) + })) + + const fetchSpy = vi.spyOn(global, 'fetch'); + + await LoginOrchestrator.run({ + username: 'my-user', + password: 'password' + }) + + expect(fetchSpy).toHaveBeenCalledWith(`${config.dashboardUrl}/api/v1/auth/cli`, + { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + email: 'my-user', + password: 'password', + }) + }) + expect(LoginHelper.get().isLoggedIn).to.be.true; + expect(LoginHelper.get().credentials).toMatchObject({ + accessToken: tempJWT, + }) + }); + + + afterEach(() => { + vi.resetAllMocks(); + MockOs.reset(); + }) + + function checkPortStatus(port: number, host = '127.0.0.1') { + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + + socket.once('connect', () => { + // If 'connect' event fires, the port is open and listening + socket.destroy(); + resolve(true); // Port is in use + }); + + socket.once('error', (err) => { + // Any error typically means the port is not listening + // EADDRNOTAVAIL, ECONNREFUSED, etc. + reject(err); // Port is likely free or unreachable + }); + + socket.once('timeout', () => { + socket.destroy(); + reject(new Error('Connection attempt timed out')); + }); + + socket.connect(port, host); + }); + } + +}) diff --git a/test/orchestrator/refresh/refresh.test.ts b/test/orchestrator/refresh/refresh.test.ts new file mode 100644 index 00000000..989a725b --- /dev/null +++ b/test/orchestrator/refresh/refresh.test.ts @@ -0,0 +1,243 @@ +import path from 'path'; + +import { describe, it, vi, afterEach, expect } from 'vitest'; +import { MockOs } from '../mocks/system.js'; +import { MockReporter } from '../mocks/reporter.js'; +import { ImportOrchestrator } from '../../../src/orchestrators/import.js'; +import { MockResource, MockResourceConfig } from '../mocks/resource.js'; +import { ResourceSettings } from 'codify-plugin-lib'; +import { ResourceConfig } from '../../../src/entities/resource-config.js'; +import { FileModificationResult } from '../../../src/utils/file-modification-calculator.js'; +import { fs, vol } from 'memfs'; +import { RefreshOrchestrator } from '../../../src/orchestrators/refresh'; + +vi.mock('../mocks/get-mock-resources.js', async () => { + return { + getMockResources: () => ([ + new class extends MockResource { + getSettings(): ResourceSettings { + const orgSettings = super.getSettings(); + return { + ...orgSettings, + importAndDestroy: { + requiredParameters: ['propA', 'propB'], + refreshKeys: ['propA', 'propB', 'directory'], + } + } + } + + async refresh(parameters: Partial): Promise> | Partial | null> { + expect(parameters).toMatchObject({ + propA: expect.any(String), + propB: expect.any(String), + directory: null + }) + + return super.refresh(parameters); + } + }, + new class extends MockResource { + getSettings(): ResourceSettings { + return { + id: 'jenv', + schema: { + '$schema': 'http://json-schema.org/draft-07/schema', + '$id': 'https://www.codifycli.com/jenv.json', + 'type': 'object', + 'properties': { + 'add': { + 'type': 'array' + }, + 'global': { + 'type': 'string' + }, + 'requiredProp': { + 'type': 'string' + } + }, + 'required': ['requiredProp'] + }, + parameterSettings: { + add: { type: 'array' }, + }, + importAndDestroy: { + requiredParameters: ['requiredProp'], + refreshKeys: ['add', 'global', 'requiredProp'], + } + } + } + }, + new class extends MockResource { + getSettings(): ResourceSettings { + return { + id: 'alias', + schema: { + '$schema': 'http://json-schema.org/draft-07/schema', + '$id': 'https://www.codifycli.com/alias.json', + 'type': 'object', + 'properties': { + 'alias': { + 'type': 'string' + }, + 'value': { + 'type': 'string' + }, + }, + 'required': ['alias'] + }, + parameterSettings: { + add: { type: 'array' }, + }, + importAndDestroy: { + requiredParameters: ['alias'], + refreshKeys: ['alias', 'value'], + }, + allowMultiple: true, + } + } + }, + new class extends MockResource { + getSettings(): ResourceSettings { + return { + id: 'mock2', + parameterSettings: { + propB: { type: 'number' }, + directory: { type: 'directory' }, + array: { type: 'array', canModify: true } + } + } + } + } + ]) + } +}) + +vi.mock('../../../src/plugins/plugin.js', async () => { + const { MockPlugin } = await import('../mocks/plugin.js'); + return { Plugin: MockPlugin }; +}) + +vi.mock('node:fs', async () => { + const { fs } = await import('memfs'); + return fs +}) + +vi.mock('node:fs/promises', async () => { + const { fs } = await import('memfs'); + return fs.promises; +}) + +describe('Refresh orchestrator tests', () => { + it('Can import and update an existing project (without prompting the user)(this is the no args version)', async () => { + const processSpy = vi.spyOn(process, 'cwd'); + processSpy.mockReturnValue('/'); + + fs.writeFileSync('/codify.json', + `[ + { + "type": "jenv", + "add": [ + "system", + "11", + "11.0" + ], + "global": "17", + "requiredProp": "this-jenv" + } +]`, + { encoding: 'utf-8' }); + + const reporter = new MockReporter({ + displayImportResult: (importResult) => { + console.log(JSON.stringify(importResult, null, 2)); + expect(importResult.errors.length).to.eq(0); + expect(importResult.result.length).to.eq(1); + expect(importResult.result[0].type).to.eq('jenv'); + expect(importResult.result[0].parameters).toMatchObject({ // Make sure the system values are returned here + 'add': [ + 'system', + '11', + '11.0', + '11.0.24', + '17', + '17.0.12', + 'openjdk64-11.0.24', + 'openjdk64-17.0.12' + ], + 'global': '17', + 'requiredProp': 'this-jenv' + }) + }, + // Option 0 is write to a new file (no current project exists) + promptOptions: (message, options) => { + expect(options[0]).toContain('Update existing'); + return 0; + }, + displayFileModifications: (diff: Array<{ file: string, modification: FileModificationResult }>) => { + expect(diff[0].file).to.eq('/codify.json') + console.log(diff[0].file); + }, + }); + + const askRequiredParametersSpy = vi.spyOn(reporter, 'promptUserForValues'); + const displayImportResultSpy = vi.spyOn(reporter, 'displayImportResult'); + const displayFileModifications = vi.spyOn(reporter, 'displayFileModifications'); + const promptConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); + + MockOs.create('jenv', { + 'add': [ + 'system', + '11', + '11.0', + '11.0.24', + '17', + '17.0.12', + 'openjdk64-11.0.24', + 'openjdk64-17.0.12' + ], + 'global': '17', + 'requiredProp': 'this-jenv' + }) + + await RefreshOrchestrator.run( + { + path: '/' + }, + reporter, + ); + + expect(askRequiredParametersSpy).toHaveBeenCalledTimes(0); + expect(displayImportResultSpy).toHaveBeenCalledOnce(); + expect(displayFileModifications).toHaveBeenCalledOnce(); + expect(promptConfirmationSpy).toHaveBeenCalledOnce(); + + const fileWritten = fs.readFileSync('/codify.json', 'utf8') as string; + console.log(fileWritten); + + expect(JSON.parse(fileWritten)).toMatchObject([ + { + 'type': 'jenv', + 'add': [ + 'system', + '11', + '11.0', + '11.0.24', + '17', + '17.0.12', + 'openjdk64-11.0.24', + 'openjdk64-17.0.12' + ], + 'global': '17', + 'requiredProp': 'this-jenv' + } + ]) + }); + + + afterEach(() => { + vi.resetAllMocks(); + vol.reset(); + MockOs.reset(); + }) + +}) From 1c93d9293907ab3003e7077bb6090fcb801b36e8 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sun, 14 Dec 2025 18:17:58 -0500 Subject: [PATCH 53/67] feat: Add readme and license --- LICENSE | 201 ++++++++++++++++++ README.md | 348 ++++--------------------------- codify-imports/neal.codify.jsonc | 98 --------- package.json | 2 +- 4 files changed, 248 insertions(+), 401 deletions(-) create mode 100644 LICENSE delete mode 100644 codify-imports/neal.codify.jsonc diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..520ae3fd --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Kevin Wang + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 69dff13e..24718b7b 100644 --- a/README.md +++ b/README.md @@ -1,325 +1,69 @@ -oclif-hello-world -================= +# Codify - Your Development Environment as Code -oclif example Hello World CLI +Codify is a command-line tool that brings the power of Infrastructure as Code (IaC) to your local development environment. Manage system settings, install packages, and automate your setup using a simple, declarative configuration file. -[![oclif](https://img.shields.io/badge/cli-oclif-brightgreen.svg)](https://oclif.io) -[![CircleCI](https://circleci.com/gh/oclif/hello-world/tree/main.svg?style=shield)](https://circleci.com/gh/oclif/hello-world/tree/main) -[![GitHub license](https://img.shields.io/github/license/oclif/hello-world)](https://github.com/oclif/hello-world/blob/main/LICENSE) - - -* [Usage](#usage) -* [Commands](#commands) - -# Usage - -```sh-session -$ npm install -g codify -$ codify COMMAND -running command... -$ codify (--version|-v) -codify/1.0.0 darwin-arm64 node-v22.19.0 -$ codify --help [COMMAND] -USAGE - $ codify COMMAND -... -``` - -# Commands - -* [`codify apply`](#codify-apply) -* [`codify destroy`](#codify-destroy) -* [`codify help [COMMAND]`](#codify-help-command) -* [`codify import`](#codify-import) -* [`codify init`](#codify-init) -* [`codify plan`](#codify-plan) -* [`codify update [CHANNEL]`](#codify-update-channel) -* [`codify validate`](#codify-validate) - -## `codify apply` - -Install or update resources on the system based on a codify.jsonc file. - -``` -USAGE - $ codify apply [--debug] [-o plain|default|json] [-p ] [-S ] - -FLAGS - -S, --sudoPassword= Automatically use this password for any commands that require elevated permissions. - -o, --output=