From d7c9f1a8ffea77f250d8b85cb8cc3762eed8245a Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 22 Apr 2026 23:28:26 -0400 Subject: [PATCH 01/35] feat: Added includeSensitive flag for remote inits --- src/connect/http-routes/handlers/init-handler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/connect/http-routes/handlers/init-handler.ts b/src/connect/http-routes/handlers/init-handler.ts index 3d68dad2..ec5c3d7b 100644 --- a/src/connect/http-routes/handlers/init-handler.ts +++ b/src/connect/http-routes/handlers/init-handler.ts @@ -18,7 +18,8 @@ export function initHandler() { session.additionalData.filePath = filePath; session.additionalData.existingFile = '[]'; - return spawn(ShellUtils.getDefaultShell(), ['-c', `${ConnectOrchestrator.nodeBinary} ${ConnectOrchestrator.rootCommand} init -p ${filePath}`], { + const sensitiveFlag = body.includeSensitive ? ' --includeSensitive' : ''; + return spawn(ShellUtils.getDefaultShell(), ['-c', `${ConnectOrchestrator.nodeBinary} ${ConnectOrchestrator.rootCommand} init -p ${filePath}${sensitiveFlag}`], { name: 'xterm-color', cols: 80, rows: 30, From 1cc245ba663b277a78229d5ee0f7cda0f1185a7f Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 22 Apr 2026 23:50:34 -0400 Subject: [PATCH 02/35] feat: Added distro filtering --- package-lock.json | 22 ++- package.json | 2 +- src/common/initialize-plugins.ts | 1 + src/entities/project.test.ts | 231 ++++++++++++++++++++++++++++++- src/entities/project.ts | 28 +++- src/entities/resource-config.ts | 9 +- src/utils/os-utils.ts | 30 ++++ 7 files changed, 305 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 209a76a8..c166258e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "codify", - "version": "1.0.0-beta9", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codify", - "version": "1.0.0-beta9", + "version": "1.0.2", "license": "Apache-2.0", "dependencies": { "@codifycli/ink-form": "0.0.12", - "@codifycli/schemas": "1.0.0", + "@codifycli/schemas": "1.1.0-beta4", "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", "@inkjs/ui": "^2", "@mischnic/json-sourcemap": "^0.1.1", @@ -1114,6 +1114,16 @@ "node": ">=22.0.0" } }, + "node_modules/@codifycli/plugin-core/node_modules/@codifycli/schemas": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@codifycli/schemas/-/schemas-1.0.0.tgz", + "integrity": "sha512-E7F56uA7DENvQJP4Wnwe1y+gwl5SWcGsbOH4gNNs6FL5BE2WagVDz0jR6/dm1Bfjmg6N0AvROIQJmUaRW+To2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "ajv": "^8.18.0" + } + }, "node_modules/@codifycli/plugin-core/node_modules/@homebridge/node-pty-prebuilt-multiarch": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/@homebridge/node-pty-prebuilt-multiarch/-/node-pty-prebuilt-multiarch-0.13.1.tgz", @@ -1148,9 +1158,9 @@ } }, "node_modules/@codifycli/schemas": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@codifycli/schemas/-/schemas-1.0.0.tgz", - "integrity": "sha512-E7F56uA7DENvQJP4Wnwe1y+gwl5SWcGsbOH4gNNs6FL5BE2WagVDz0jR6/dm1Bfjmg6N0AvROIQJmUaRW+To2g==", + "version": "1.1.0-beta4", + "resolved": "https://registry.npmjs.org/@codifycli/schemas/-/schemas-1.1.0-beta4.tgz", + "integrity": "sha512-bBEr9c+MqMcs+Ke5//JfQ3+Vmixh+8TvMqeJKh0OKPEntzwGmLVTqv6g8CDk9/M8H+To2KASLw2pjEHEBiJGSw==", "license": "ISC", "dependencies": { "ajv": "^8.18.0" diff --git a/package.json b/package.json index 359bf79f..c9d8a457 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ }, "dependencies": { "@codifycli/ink-form": "0.0.12", - "@codifycli/schemas": "1.0.0", + "@codifycli/schemas": "1.1.0-beta4", "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", "@inkjs/ui": "^2", "@mischnic/json-sourcemap": "^0.1.1", diff --git a/src/common/initialize-plugins.ts b/src/common/initialize-plugins.ts index 6e8a7083..39cb5e95 100644 --- a/src/common/initialize-plugins.ts +++ b/src/common/initialize-plugins.ts @@ -45,6 +45,7 @@ export class PluginInitOrchestrator { if (!args.noProgress) ctx.subprocessFinished(SubProcessName.INITIALIZE_PLUGINS) project.removeResourcesUsingOsFilter(); + await project.removeResourcesUsingDistroFilter(); return { resourceDefinitions, pluginManager, project }; } diff --git a/src/entities/project.test.ts b/src/entities/project.test.ts index ec849f67..a8db575e 100644 --- a/src/entities/project.test.ts +++ b/src/entities/project.test.ts @@ -1,7 +1,17 @@ -import { describe, expect, it } from 'vitest'; +import { LinuxDistro, ResourceOs } from '@codifycli/schemas'; +import { describe, expect, it, vi } from 'vitest'; + +import { OsUtils } from '../utils/os-utils.js'; import { Project } from './project.js'; import { ResourceConfig } from './resource-config.js'; -import { InMemoryFile } from '../parser/entities'; + +function makeResource(type: string, os?: ResourceOs[], distro?: LinuxDistro[]): ResourceConfig { + return new ResourceConfig({ type, ...(os ? { os } : {}), ...(distro ? { distro } : {}) }); +} + +function makeProject(...configs: ResourceConfig[]): Project { + return new Project(null, configs, []); +} describe('Project Unit Tests', () => { it('Can add unique names for duplicate resources', async () => { @@ -26,4 +36,219 @@ describe('Project Unit Tests', () => { // expect(project.resourceConfigs[3].id).to.eq('other') }) -}) + describe('removeResourcesUsingOsFilter', () => { + it('keeps resources with no os filter', () => { + vi.spyOn(OsUtils, 'getOs').mockReturnValue(ResourceOs.MACOS); + + const project = makeProject( + makeResource('tool-a'), + makeResource('tool-b'), + ); + + project.removeResourcesUsingOsFilter(); + + expect(project.resourceConfigs).toHaveLength(2); + }); + + it('keeps resources that match the current os', () => { + vi.spyOn(OsUtils, 'getOs').mockReturnValue(ResourceOs.MACOS); + + const project = makeProject( + makeResource('mac-tool', [ResourceOs.MACOS]), + makeResource('linux-tool', [ResourceOs.LINUX]), + ); + + project.removeResourcesUsingOsFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + expect(project.resourceConfigs[0].type).toBe('mac-tool'); + }); + + it('keeps resources that list multiple os including the current one', () => { + vi.spyOn(OsUtils, 'getOs').mockReturnValue(ResourceOs.LINUX); + + const project = makeProject( + makeResource('cross-platform', [ResourceOs.MACOS, ResourceOs.LINUX]), + ); + + project.removeResourcesUsingOsFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + }); + + it('removes resources whose os does not match the current os', () => { + vi.spyOn(OsUtils, 'getOs').mockReturnValue(ResourceOs.WINDOWS); + + const project = makeProject( + makeResource('mac-only', [ResourceOs.MACOS]), + makeResource('linux-only', [ResourceOs.LINUX]), + ); + + project.removeResourcesUsingOsFilter(); + + expect(project.resourceConfigs).toHaveLength(0); + }); + }); + + describe('removeResourcesUsingDistroFilter', () => { + it('does nothing on macOS (not Linux)', async () => { + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(false); + + const project = makeProject( + makeResource('tool', undefined, [LinuxDistro.UBUNTU]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + }); + + it('does nothing when distro cannot be determined', async () => { + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(undefined); + + const project = makeProject( + makeResource('tool', undefined, [LinuxDistro.UBUNTU]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + }); + + it('keeps resources with no distro filter', async () => { + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(LinuxDistro.UBUNTU); + + const project = makeProject( + makeResource('tool-a'), + makeResource('tool-b'), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(2); + }); + + it('keeps resources that match the current distro exactly', async () => { + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(LinuxDistro.UBUNTU); + + const project = makeProject( + makeResource('ubuntu-tool', undefined, [LinuxDistro.UBUNTU]), + makeResource('arch-tool', undefined, [LinuxDistro.ARCH]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + expect(project.resourceConfigs[0].type).toBe('ubuntu-tool'); + }); + + it('keeps resources when current distro matches debian-based group', async () => { + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(LinuxDistro.UBUNTU); + + const project = makeProject( + makeResource('debian-tool', undefined, [LinuxDistro.DEBIAN_BASED]), + makeResource('rpm-tool', undefined, [LinuxDistro.RPM_BASED]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + expect(project.resourceConfigs[0].type).toBe('debian-tool'); + }); + + it('keeps resources when current distro matches rpm-based group', async () => { + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(LinuxDistro.FEDORA); + + const project = makeProject( + makeResource('debian-tool', undefined, [LinuxDistro.DEBIAN_BASED]), + makeResource('rpm-tool', undefined, [LinuxDistro.RPM_BASED]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + expect(project.resourceConfigs[0].type).toBe('rpm-tool'); + }); + + it('debian-based group covers all expected distros', async () => { + const debianDistros = [ + LinuxDistro.DEBIAN, + LinuxDistro.UBUNTU, + LinuxDistro.MINT, + LinuxDistro.POP_OS, + LinuxDistro.ELEMENTARY_OS, + LinuxDistro.KALI, + ]; + + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + + for (const distro of debianDistros) { + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(distro); + + const project = makeProject( + makeResource('tool', undefined, [LinuxDistro.DEBIAN_BASED]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + } + }); + + it('rpm-based group covers all expected distros', async () => { + const rpmDistros = [ + LinuxDistro.FEDORA, + LinuxDistro.CENTOS, + LinuxDistro.RHEL, + LinuxDistro.AMAZON_LINUX, + LinuxDistro.OPENSUSE, + LinuxDistro.SUSE, + ]; + + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + + for (const distro of rpmDistros) { + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(distro); + + const project = makeProject( + makeResource('tool', undefined, [LinuxDistro.RPM_BASED]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + } + }); + + it('removes resources when no distro in filter matches the current distro', async () => { + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(LinuxDistro.ARCH); + + const project = makeProject( + makeResource('tool', undefined, [LinuxDistro.UBUNTU, LinuxDistro.DEBIAN]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(0); + }); + + it('keeps resources that list multiple distros including the current one', async () => { + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(LinuxDistro.ARCH); + + const project = makeProject( + makeResource('tool', undefined, [LinuxDistro.UBUNTU, LinuxDistro.ARCH]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + }); + }); +}); diff --git a/src/entities/project.ts b/src/entities/project.ts index fc371c91..587da3da 100644 --- a/src/entities/project.ts +++ b/src/entities/project.ts @@ -15,7 +15,6 @@ import { ResourceDefinitionMap } from '../plugins/plugin-manager.js'; import { DependencyGraphResolver } from '../utils/dependency-graph-resolver.js'; import { groupBy } from '../utils/index.js'; import { OsUtils } from '../utils/os-utils.js'; -import { ShellUtils } from '../utils/shell.js'; import { ConfigBlock, ConfigType } from './config.js'; import { type Plan } from './plan.js'; import { ProjectConfig } from './project-config.js'; @@ -187,12 +186,12 @@ ${JSON.stringify(projectConfigs, null, 2)}`); } if (os.type() === OS.Linux) { - const currentDistro = await ShellUtils.getLinuxDistro(); + const currentDistro = await OsUtils.getLinuxDistro(); if (!currentDistro) { throw new Error('Unable to determine Linux distribution'); } - this.resourceConfigs.filter((c) => { + const distroInvalidConfigs = this.resourceConfigs.filter((c) => { const distros = resourceDefinitions.get(c.type)?.linuxDistros; if (!distros) { return false; @@ -201,8 +200,8 @@ ${JSON.stringify(projectConfigs, null, 2)}`); return !distros.includes(currentDistro); }); - if (invalidConfigs.length > 0) { - throw new LinuxDistroNotSupportedError(invalidConfigs, this.sourceMaps); + if (distroInvalidConfigs.length > 0) { + throw new LinuxDistroNotSupportedError(distroInvalidConfigs, this.sourceMaps); } } } @@ -217,6 +216,25 @@ ${JSON.stringify(projectConfigs, null, 2)}`); }); } + async removeResourcesUsingDistroFilter() { + if (!OsUtils.isLinux()) { + return; + } + + const currentDistro = await OsUtils.getLinuxDistro(); + if (!currentDistro) { + return; + } + + this.resourceConfigs = this.resourceConfigs.filter((r) => { + if (!r.distro || r.distro.length === 0) { + return true; + } + + return r.distro.some((d) => OsUtils.distroMatchesCurrent(d, currentDistro)); + }); + } + resolveDependenciesAndCalculateEvalOrder(resourceDefinitions?: ResourceDefinitionMap) { this.resolveResourceDependencies(resourceDefinitions); this.calculateEvaluationOrder(); diff --git a/src/entities/resource-config.ts b/src/entities/resource-config.ts index 3d1d2f32..e2016706 100644 --- a/src/entities/resource-config.ts +++ b/src/entities/resource-config.ts @@ -1,4 +1,4 @@ -import { ResourceJson, ResourceOs, ResourceConfig as SchemaResourceConfig } from '@codifycli/schemas'; +import { LinuxDistro, ResourceJson, ResourceOs, ResourceConfig as SchemaResourceConfig } from '@codifycli/schemas'; import { deepEqual } from '../utils/index.js'; import { ConfigBlock, ConfigType } from './config.js'; @@ -29,6 +29,7 @@ export class ResourceConfig implements ConfigBlock { name?: string; dependsOn: string[]; os?: ResourceOs[]; + distro?: LinuxDistro[]; sourceMapKey?: string; // Calculated @@ -38,12 +39,13 @@ export class ResourceConfig implements ConfigBlock { resourceInfo?: ResourceInfo; constructor(config: SchemaResourceConfig, sourceMapKey?: string) { - const { dependsOn, name, type, os, ...parameters } = config; + const { dependsOn, name, type, os, distro, ...parameters } = config; this.raw = config; this.type = type; this.name = name; this.os = os; + this.distro = distro; this.parameters = parameters ?? {}; this.dependsOn = dependsOn ?? [] this.sourceMapKey = sourceMapKey; @@ -65,7 +67,8 @@ export class ResourceConfig implements ConfigBlock { type: this.type, ...(excludeName || !this.name ? {} : { name: this.name }), ...(this.dependsOn.length > 0 ? { dependsOn: this.dependsOn } : {}), - ...(this.os && this.os?.length > 0 ? { os: this.os } : {}) + ...(this.os && this.os?.length > 0 ? { os: this.os } : {}), + ...(this.distro && this.distro?.length > 0 ? { distro: this.distro } : {}) }; } diff --git a/src/utils/os-utils.ts b/src/utils/os-utils.ts index fd3e443a..e79408e1 100644 --- a/src/utils/os-utils.ts +++ b/src/utils/os-utils.ts @@ -4,6 +4,24 @@ import * as fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +const DEBIAN_BASED_DISTROS: LinuxDistro[] = [ + LinuxDistro.DEBIAN, + LinuxDistro.UBUNTU, + LinuxDistro.MINT, + LinuxDistro.POP_OS, + LinuxDistro.ELEMENTARY_OS, + LinuxDistro.KALI, +]; + +const RPM_BASED_DISTROS: LinuxDistro[] = [ + LinuxDistro.FEDORA, + LinuxDistro.CENTOS, + LinuxDistro.RHEL, + LinuxDistro.AMAZON_LINUX, + LinuxDistro.OPENSUSE, + LinuxDistro.SUSE, +]; + export enum Shell { ZSH = 'zsh', BASH = 'bash', @@ -193,6 +211,18 @@ export const OsUtils = { isRedhatBased(): boolean { return fsSync.existsSync('/etc/redhat-release'); + }, + + distroMatchesCurrent(filter: LinuxDistro, current: LinuxDistro): boolean { + if (filter === LinuxDistro.DEBIAN_BASED) { + return DEBIAN_BASED_DISTROS.includes(current); + } + + if (filter === LinuxDistro.RPM_BASED) { + return RPM_BASED_DISTROS.includes(current); + } + + return filter === current; } }; From 6c848990438a6c09abeebb3af9945156239de9c4 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Fri, 24 Apr 2026 08:46:17 -0400 Subject: [PATCH 03/35] feat: Add verbosity toggle --- .gitignore | 1 + package.json | 2 +- src/orchestrators/apply.ts | 9 +++++++ src/orchestrators/destroy.ts | 9 +++++++ src/ui/components/default-component.tsx | 2 +- .../components/progress/progress-display.tsx | 25 ++++++++++++++++--- src/ui/reporters/default-reporter.tsx | 9 +++++++ src/ui/reporters/reporter.ts | 1 + 8 files changed, 53 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 37876a0d..eadb76bf 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ node_modules oclif.manifest.json .env +.codify-files diff --git a/package.json b/package.json index c9d8a457..76fb088f 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,7 @@ "deploy": "npm run pkg && npm run notarize && npm run upload", "prepublishOnly": "npm run build" }, - "version": "1.0.2", + "version": "1.1.0-beta", "bugs": "https://github.com/codifycli/codify/issues", "keywords": [ "oclif", diff --git a/src/orchestrators/apply.ts b/src/orchestrators/apply.ts index 69bc9ade..dc737022 100644 --- a/src/orchestrators/apply.ts +++ b/src/orchestrators/apply.ts @@ -1,4 +1,5 @@ import { ProcessName, ctx } from '../events/context.js'; +import { DefaultReporter } from '../ui/reporters/default-reporter.js'; import { Reporter } from '../ui/reporters/reporter.js'; import { sleep } from '../utils/index.js'; import { PlanOrchestrator } from './plan.js'; @@ -28,6 +29,14 @@ export const ApplyOrchestrator = { const { plan, pluginManager, project } = planResult; const filteredPlan = plan.filterNoopResources() + let currentVerbosity = args.verbosityLevel ?? 0; + if (reporter instanceof DefaultReporter) { + reporter.onVerbosityToggle(async () => { + currentVerbosity = currentVerbosity === 0 ? 3 : 0; + await pluginManager.setVerbosityLevel(currentVerbosity); + }); + } + if (!args.noProgress) ctx.processStarted(ProcessName.APPLY); if (!args.noProgress) await reporter.displayProgress(); diff --git a/src/orchestrators/destroy.ts b/src/orchestrators/destroy.ts index 136c3799..15106e3b 100644 --- a/src/orchestrators/destroy.ts +++ b/src/orchestrators/destroy.ts @@ -5,6 +5,7 @@ import { ResourceConfig } from '../entities/resource-config.js'; import { ResourceInfo } from '../entities/resource-info.js'; import { ProcessName, SubProcessName, ctx } from '../events/context.js'; import { PluginManager, ResourceDefinitionMap } from '../plugins/plugin-manager.js'; +import { DefaultReporter } from '../ui/reporters/default-reporter.js'; import { PromptType, Reporter } from '../ui/reporters/reporter.js'; import { wildCardMatch } from '../utils/wild-card-match.js'; @@ -53,6 +54,14 @@ export class DestroyOrchestrator { const filteredPlan = plan.filterNoopResources() + let currentVerbosity = args.verbosityLevel ?? 0; + if (reporter instanceof DefaultReporter) { + reporter.onVerbosityToggle(async () => { + currentVerbosity = currentVerbosity === 0 ? 3 : 0; + await pluginManager.setVerbosityLevel(currentVerbosity); + }); + } + await reporter.displayProgress(); await ctx.process(ProcessName.DESTROY, () => pluginManager.apply(destroyProject, filteredPlan) diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 61faad09..5f2063ff 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -57,7 +57,7 @@ export function DefaultComponent(props: { } { renderStatus === RenderStatus.PROGRESS && ( - + ) } { diff --git a/src/ui/components/progress/progress-display.tsx b/src/ui/components/progress/progress-display.tsx index cd11da5f..f992f202 100644 --- a/src/ui/components/progress/progress-display.tsx +++ b/src/ui/components/progress/progress-display.tsx @@ -1,7 +1,10 @@ -import { Box, Text } from 'ink'; +import { Box, Text, useInput } from 'ink'; import { useAtom } from 'jotai'; -import React from 'react'; +import { EventEmitter } from 'node:events'; +import React, { useState } from 'react'; +import { ProcessName } from '../../../events/context.js'; +import { RenderEvent } from '../../reporters/reporter.js'; import { store } from '../../store/index.js'; import Spinner from './spinner.js'; @@ -21,8 +24,21 @@ export interface ProgressState { }> | null; } -export function ProgressDisplay() { +export function ProgressDisplay(props: { emitter: EventEmitter }) { + const { emitter } = props; const [progress] = useAtom(store.progressState); + const [isVerbose, setIsVerbose] = useState(false); + + const isApplyOrDestroy = progress?.name === ProcessName.APPLY || progress?.name === ProcessName.DESTROY; + + useInput((input) => { + if (!isApplyOrDestroy) return; + if (input === 'v') { + setIsVerbose((prev) => !prev); + emitter.emit(RenderEvent.TOGGLE_VERBOSITY); + } + }); + if (!progress) { return; } @@ -38,6 +54,9 @@ export function ProgressDisplay() { + {isApplyOrDestroy && ( + {isVerbose ? '[v] Hide verbose logs' : '[v] Show verbose logs'} + )} } diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index f9f55cbb..0cdfcc0d 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -46,6 +46,7 @@ const ProgressLabelMapping = { export class DefaultReporter implements Reporter { private renderEmitter = new EventEmitter(); private progressState: ProgressState | null = null + private verbosityToggleCallback: (() => void) | null = null; silent = false; constructor() { @@ -56,6 +57,14 @@ export class DefaultReporter implements Reporter { ctx.on(Event.PROCESS_FINISH, (name) => this.onProcessFinishEvent(name)) ctx.on(Event.SUB_PROCESS_START, (name, additionalName) => this.onSubprocessStartEvent(name, additionalName)); ctx.on(Event.SUB_PROCESS_FINISH, (name, additionalName) => this.onSubprocessFinishEvent(name, additionalName)); + + this.renderEmitter.on(RenderEvent.TOGGLE_VERBOSITY, () => { + this.verbosityToggleCallback?.(); + }); + } + + onVerbosityToggle(callback: () => void): void { + this.verbosityToggleCallback = callback; } async promptPressKeyToContinue(message?: string): Promise { diff --git a/src/ui/reporters/reporter.ts b/src/ui/reporters/reporter.ts index a3853bb6..26176f9a 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -23,6 +23,7 @@ export enum RenderEvent { PROMPT_SUDO_GRANTED = 'promptSudoGranted', SUDO_PROMPT_RESULT = 'promptSudoResult', STATE_TRANSITION = 'stateTransition', + TOGGLE_VERBOSITY = 'toggleVerbosity', } /** From fab98588ee9efb260fe46268126b6ea513ffa55d Mon Sep 17 00:00:00 2001 From: kevinwang Date: Fri, 24 Apr 2026 09:05:41 -0400 Subject: [PATCH 04/35] feat: Add defaulting to plain reporter if tty is not available --- src/common/base-command.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/common/base-command.ts b/src/common/base-command.ts index 22e3083e..f1388b9a 100644 --- a/src/common/base-command.ts +++ b/src/common/base-command.ts @@ -5,7 +5,7 @@ import { CommandRequestData, PressKeyToContinueRequestData } from '@codifycli/sc import createDebug from 'debug'; import { LoginHelper } from '../connect/login-helper.js'; -import { Event, ctx } from '../events/context.js'; +import { ctx, Event } from '../events/context.js'; import { LoginOrchestrator } from '../orchestrators/login.js'; import { Reporter, ReporterFactory, ReporterType } from '../ui/reporters/reporter.js'; import { spawnSafe } from '../utils/spawn.js'; @@ -18,9 +18,8 @@ export abstract class BaseCommand extends Command { }), 'output': Flags.option({ char: 'o', - default: 'default', options: ['plain', 'default', 'json'], - description: 'Control the output format.', + description: 'Control the output format. Default to default and plain for non-tty environments. Use json for scripts', })(), path: Flags.string({ char: 'p', description: 'Path to run Codify from.' }), } @@ -41,7 +40,7 @@ export abstract class BaseCommand extends Command { createDebug.enable('*'); } - const reporterType = this.getReporterType(flags); + const reporterType = this.getReporterType(flags) this.reporter = ReporterFactory.create(reporterType) if (flags.secure) { @@ -157,6 +156,8 @@ export abstract class BaseCommand extends Command { } } - return ReporterType.DEFAULT; + if (!process.stdin.isTTY) console.log('Running in non-TTY shell. Defaulting to plain output.') + + return !process.stdin.isTTY ? ReporterType.PLAIN : ReporterType.DEFAULT; } } From c644025f1c6ac8b50bfff4862b1a27d014d0b07e Mon Sep 17 00:00:00 2001 From: kevinwang Date: Fri, 24 Apr 2026 09:07:26 -0400 Subject: [PATCH 05/35] feat: Added auto approve flag for scripts --- src/commands/apply.ts | 6 ++++++ src/commands/destroy.ts | 6 ++++++ src/orchestrators/apply.ts | 9 ++++++--- src/orchestrators/destroy.ts | 9 ++++++--- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/commands/apply.ts b/src/commands/apply.ts index d90bc887..991865a8 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -25,6 +25,11 @@ For more information, visit: https://codifycli.com/docs/commands/apply description: 'Automatically use this password for any handlers that require elevated permissions.', char: 'S' }), + 'yes': Flags.boolean({ + description: 'Automatically approve the apply without prompting for confirmation.', + char: 'y', + default: false, + }), } static args = { @@ -53,6 +58,7 @@ For more information, visit: https://codifycli.com/docs/commands/apply await ApplyOrchestrator.run({ path: flags.path ?? args.pathArgs, verbosityLevel: flags.debug ? 3 : 0, + autoApprove: flags.yes, // secure: flags.secure, }, this.reporter); diff --git a/src/commands/destroy.ts b/src/commands/destroy.ts index 5338e6c8..47c60c8c 100644 --- a/src/commands/destroy.ts +++ b/src/commands/destroy.ts @@ -33,6 +33,11 @@ For more information, visit: https://codifycli.com/docs/commands/destory` char: 'S', helpValue: '' }), + 'yes': Flags.boolean({ + description: 'Automatically approve the destroy without prompting for confirmation.', + char: 'y', + default: false, + }), } public async run(): Promise { @@ -50,6 +55,7 @@ For more information, visit: https://codifycli.com/docs/commands/destory` verbosityLevel: flags.debug ? 3 : 0, typeIds: args, path: flags.path, + autoApprove: flags.yes, }, this.reporter) process.exit(0); diff --git a/src/orchestrators/apply.ts b/src/orchestrators/apply.ts index dc737022..35510996 100644 --- a/src/orchestrators/apply.ts +++ b/src/orchestrators/apply.ts @@ -9,6 +9,7 @@ export interface ApplyArgs { secure?: boolean; verbosityLevel?: number; noProgress?: boolean; + autoApprove?: boolean; } export const ApplyOrchestrator = { @@ -21,9 +22,11 @@ export const ApplyOrchestrator = { return process.exit(0); } - const confirm = await reporter.promptConfirmation('Do you want to continue?') - if (!confirm) { - return process.exit(0); + if (!args.autoApprove) { + const confirm = await reporter.promptConfirmation('Do you want to continue?') + if (!confirm) { + return process.exit(0); + } } const { plan, pluginManager, project } = planResult; diff --git a/src/orchestrators/destroy.ts b/src/orchestrators/destroy.ts index 15106e3b..42e9b1c8 100644 --- a/src/orchestrators/destroy.ts +++ b/src/orchestrators/destroy.ts @@ -14,6 +14,7 @@ export interface DestroyArgs { path?: string; secureMode?: boolean; verbosityLevel?: number; + autoApprove?: boolean; } export class DestroyOrchestrator { @@ -47,9 +48,11 @@ export class DestroyOrchestrator { return; } - const confirm = await reporter.promptConfirmation('Do you want to destroy?') - if (!confirm) { - return; + if (!args.autoApprove) { + const confirm = await reporter.promptConfirmation('Do you want to destroy?') + if (!confirm) { + return; + } } const filteredPlan = plan.filterNoopResources() From a0f241cd305314b7441748cfd3eb3a202dc50419 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Fri, 24 Apr 2026 09:52:48 -0400 Subject: [PATCH 06/35] feat: Added toggle for adding sudo password during apply --- src/common/base-command.ts | 25 ++++-- .../components/progress/progress-display.tsx | 79 +++++++++++++++++-- src/ui/reporters/default-reporter.tsx | 17 ++++ src/ui/reporters/reporter.ts | 3 + 4 files changed, 113 insertions(+), 11 deletions(-) diff --git a/src/common/base-command.ts b/src/common/base-command.ts index f1388b9a..33190ff8 100644 --- a/src/common/base-command.ts +++ b/src/common/base-command.ts @@ -7,8 +7,10 @@ import createDebug from 'debug'; import { LoginHelper } from '../connect/login-helper.js'; import { ctx, Event } from '../events/context.js'; import { LoginOrchestrator } from '../orchestrators/login.js'; +import { DefaultReporter } from '../ui/reporters/default-reporter.js'; import { Reporter, ReporterFactory, ReporterType } from '../ui/reporters/reporter.js'; import { spawnSafe } from '../utils/spawn.js'; +import { SudoUtils } from '../utils/sudo.js'; import { prettyPrintError } from './errors.js'; export abstract class BaseCommand extends Command { @@ -43,6 +45,22 @@ export abstract class BaseCommand extends Command { const reporterType = this.getReporterType(flags) this.reporter = ReporterFactory.create(reporterType) + let cachedSudoPassword: string | null = flags.sudoPassword ?? null; + + if (this.reporter instanceof DefaultReporter) { + if (cachedSudoPassword !== null) { + this.reporter.notifySudoPasswordPreSupplied(); + } + + this.reporter.onSudoPasswordSubmitted(async (password: string) => { + const isValid = SudoUtils.validate(password); + if (isValid) { + cachedSudoPassword = password; + } + (this.reporter as DefaultReporter).notifySudoPasswordResult(isValid); + }); + } + if (flags.secure) { console.log(chalk.blue('Running Codify in secure mode. Sudo will be prompted every time')); } @@ -50,14 +68,9 @@ export abstract class BaseCommand extends Command { ctx.on(Event.COMMAND_REQUEST, async (pluginName: string, data: CommandRequestData) => { try { const password = data.options.requiresRoot - ? (flags.sudoPassword) ?? (await this.reporter.promptSudo(pluginName, data, flags.secure)) + ? cachedSudoPassword ?? (await this.reporter.promptSudo(pluginName, data, flags.secure)) : undefined; - // We print that we used sudo everytime even if the user provides it in the beginning - if (flags.sudoPassword && data.options.requiresRoot) { - console.log(chalk.blue(`Plugin: "${pluginName}" requires root access to run command: "sudo ${data.command}"`)); - } - if (data.options.stdin) { await this.reporter.hide(); console.log(chalk.blue(`Plugin "${pluginName}" is requesting stdin`)); diff --git a/src/ui/components/progress/progress-display.tsx b/src/ui/components/progress/progress-display.tsx index f992f202..b10f1546 100644 --- a/src/ui/components/progress/progress-display.tsx +++ b/src/ui/components/progress/progress-display.tsx @@ -1,7 +1,8 @@ +import { PasswordInput } from '@inkjs/ui'; import { Box, Text, useInput } from 'ink'; import { useAtom } from 'jotai'; import { EventEmitter } from 'node:events'; -import React, { useState } from 'react'; +import React, { useLayoutEffect, useState } from 'react'; import { ProcessName } from '../../../events/context.js'; import { RenderEvent } from '../../reporters/reporter.js'; @@ -28,15 +29,59 @@ export function ProgressDisplay(props: { emitter: EventEmitter }) { const { emitter } = props; const [progress] = useAtom(store.progressState); const [isVerbose, setIsVerbose] = useState(false); + const [isEnteringPassword, setIsEnteringPassword] = useState(false); + const [passwordError, setPasswordError] = useState(false); + const [passwordAttempts, setPasswordAttempts] = useState(0); + const [passwordSaved, setPasswordSaved] = useState(false); + const [passwordInputKey, setPasswordInputKey] = useState(0); const isApplyOrDestroy = progress?.name === ProcessName.APPLY || progress?.name === ProcessName.DESTROY; + useLayoutEffect(() => { + const onResult = ({ success }: { success: boolean }) => { + if (success) { + setPasswordSaved(true); + setIsEnteringPassword(false); + setPasswordError(false); + setPasswordAttempts(0); + } else { + setPasswordAttempts((prev) => { + const next = prev + 1; + if (next >= 3) { + setIsEnteringPassword(false); + setPasswordError(false); + return 0; + } + setPasswordError(true); + setPasswordInputKey((k) => k + 1); + return next; + }); + } + }; + + const onPreSupplied = () => setPasswordSaved(true); + + emitter.on(RenderEvent.SUDO_PASSWORD_RESULT, onResult); + emitter.on(RenderEvent.SUDO_PASSWORD_PRE_SUPPLIED, onPreSupplied); + + return () => { + emitter.off(RenderEvent.SUDO_PASSWORD_RESULT, onResult); + emitter.off(RenderEvent.SUDO_PASSWORD_PRE_SUPPLIED, onPreSupplied); + }; + }, []); + useInput((input) => { if (!isApplyOrDestroy) return; if (input === 'v') { setIsVerbose((prev) => !prev); emitter.emit(RenderEvent.TOGGLE_VERBOSITY); } + if (input === 'p' && !passwordSaved) { + setIsEnteringPassword((prev) => !prev); + setPasswordError(false); + setPasswordAttempts(0); + setPasswordInputKey((k) => k + 1); + } }); if (!progress) { @@ -51,11 +96,35 @@ export function ProgressDisplay(props: { emitter: EventEmitter }) { ? : {label} } - - - + + {!isEnteringPassword && ( + + + + )} + + {isEnteringPassword && ( + + {'─'.repeat(40)} + + Password: + emitter.emit(RenderEvent.SUDO_PASSWORD_SUBMITTED, pw)} /> + + {passwordError && ( + {` Incorrect password, try again (${passwordAttempts}/3)`} + )} + {'─'.repeat(40)} + + )} + {isApplyOrDestroy && ( - {isVerbose ? '[v] Hide verbose logs' : '[v] Show verbose logs'} + + {isVerbose ? '[v] Hide verbose logs' : '[v] Show verbose logs'} + {passwordSaved + ? ✓ sudo password + : {isEnteringPassword ? '[p] Cancel' : '[p] Enter sudo password'} + } + )} } diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index 0cdfcc0d..02e89c47 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -47,6 +47,7 @@ export class DefaultReporter implements Reporter { private renderEmitter = new EventEmitter(); private progressState: ProgressState | null = null private verbosityToggleCallback: (() => void) | null = null; + private sudoPasswordSubmittedCallback: ((password: string) => void) | null = null; silent = false; constructor() { @@ -61,12 +62,28 @@ export class DefaultReporter implements Reporter { this.renderEmitter.on(RenderEvent.TOGGLE_VERBOSITY, () => { this.verbosityToggleCallback?.(); }); + + this.renderEmitter.on(RenderEvent.SUDO_PASSWORD_SUBMITTED, (password: string) => { + this.sudoPasswordSubmittedCallback?.(password); + }); } onVerbosityToggle(callback: () => void): void { this.verbosityToggleCallback = callback; } + onSudoPasswordSubmitted(callback: (password: string) => void): void { + this.sudoPasswordSubmittedCallback = callback; + } + + notifySudoPasswordResult(success: boolean): void { + this.renderEmitter.emit(RenderEvent.SUDO_PASSWORD_RESULT, { success }); + } + + notifySudoPasswordPreSupplied(): void { + setImmediate(() => this.renderEmitter.emit(RenderEvent.SUDO_PASSWORD_PRE_SUPPLIED)); + } + async promptPressKeyToContinue(message?: string): Promise { const previousRenderState = this.getRenderState(); diff --git a/src/ui/reporters/reporter.ts b/src/ui/reporters/reporter.ts index 26176f9a..5844abc4 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -24,6 +24,9 @@ export enum RenderEvent { SUDO_PROMPT_RESULT = 'promptSudoResult', STATE_TRANSITION = 'stateTransition', TOGGLE_VERBOSITY = 'toggleVerbosity', + SUDO_PASSWORD_SUBMITTED = 'sudoPasswordSubmitted', + SUDO_PASSWORD_RESULT = 'sudoPasswordResult', + SUDO_PASSWORD_PRE_SUPPLIED = 'sudoPasswordPreSupplied', } /** From f9d342c246dac269d850f8f9a0fee2bbefdbb8db Mon Sep 17 00:00:00 2001 From: kevinwang Date: Fri, 24 Apr 2026 10:17:57 -0400 Subject: [PATCH 07/35] feat: Added toggle for adding sudo password during apply --- src/common/base-command.ts | 4 +- .../components/progress/progress-display.tsx | 61 ++----------------- src/ui/reporters/default-reporter.tsx | 47 +++++++++++--- src/ui/reporters/reporter.ts | 3 +- 4 files changed, 48 insertions(+), 67 deletions(-) diff --git a/src/common/base-command.ts b/src/common/base-command.ts index 33190ff8..219ff519 100644 --- a/src/common/base-command.ts +++ b/src/common/base-command.ts @@ -52,12 +52,12 @@ export abstract class BaseCommand extends Command { this.reporter.notifySudoPasswordPreSupplied(); } - this.reporter.onSudoPasswordSubmitted(async (password: string) => { + this.reporter.onSudoPasswordSubmitted((password: string) => { const isValid = SudoUtils.validate(password); if (isValid) { cachedSudoPassword = password; } - (this.reporter as DefaultReporter).notifySudoPasswordResult(isValid); + return isValid; }); } diff --git a/src/ui/components/progress/progress-display.tsx b/src/ui/components/progress/progress-display.tsx index b10f1546..a8b61197 100644 --- a/src/ui/components/progress/progress-display.tsx +++ b/src/ui/components/progress/progress-display.tsx @@ -1,4 +1,3 @@ -import { PasswordInput } from '@inkjs/ui'; import { Box, Text, useInput } from 'ink'; import { useAtom } from 'jotai'; import { EventEmitter } from 'node:events'; @@ -29,43 +28,16 @@ export function ProgressDisplay(props: { emitter: EventEmitter }) { const { emitter } = props; const [progress] = useAtom(store.progressState); const [isVerbose, setIsVerbose] = useState(false); - const [isEnteringPassword, setIsEnteringPassword] = useState(false); - const [passwordError, setPasswordError] = useState(false); - const [passwordAttempts, setPasswordAttempts] = useState(0); const [passwordSaved, setPasswordSaved] = useState(false); - const [passwordInputKey, setPasswordInputKey] = useState(0); const isApplyOrDestroy = progress?.name === ProcessName.APPLY || progress?.name === ProcessName.DESTROY; useLayoutEffect(() => { - const onResult = ({ success }: { success: boolean }) => { - if (success) { - setPasswordSaved(true); - setIsEnteringPassword(false); - setPasswordError(false); - setPasswordAttempts(0); - } else { - setPasswordAttempts((prev) => { - const next = prev + 1; - if (next >= 3) { - setIsEnteringPassword(false); - setPasswordError(false); - return 0; - } - setPasswordError(true); - setPasswordInputKey((k) => k + 1); - return next; - }); - } - }; - const onPreSupplied = () => setPasswordSaved(true); - emitter.on(RenderEvent.SUDO_PASSWORD_RESULT, onResult); emitter.on(RenderEvent.SUDO_PASSWORD_PRE_SUPPLIED, onPreSupplied); return () => { - emitter.off(RenderEvent.SUDO_PASSWORD_RESULT, onResult); emitter.off(RenderEvent.SUDO_PASSWORD_PRE_SUPPLIED, onPreSupplied); }; }, []); @@ -77,10 +49,7 @@ export function ProgressDisplay(props: { emitter: EventEmitter }) { emitter.emit(RenderEvent.TOGGLE_VERBOSITY); } if (input === 'p' && !passwordSaved) { - setIsEnteringPassword((prev) => !prev); - setPasswordError(false); - setPasswordAttempts(0); - setPasswordInputKey((k) => k + 1); + emitter.emit(RenderEvent.SUDO_PASSWORD_TOGGLE); } }); @@ -96,33 +65,15 @@ export function ProgressDisplay(props: { emitter: EventEmitter }) { ? : {label} } - - {!isEnteringPassword && ( - - - - )} - - {isEnteringPassword && ( - - {'─'.repeat(40)} - - Password: - emitter.emit(RenderEvent.SUDO_PASSWORD_SUBMITTED, pw)} /> - - {passwordError && ( - {` Incorrect password, try again (${passwordAttempts}/3)`} - )} - {'─'.repeat(40)} - - )} - + + + {isApplyOrDestroy && ( - + {isVerbose ? '[v] Hide verbose logs' : '[v] Show verbose logs'} {passwordSaved ? ✓ sudo password - : {isEnteringPassword ? '[p] Cancel' : '[p] Enter sudo password'} + : [p] Enter sudo password } )} diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index 02e89c47..102852b0 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -47,7 +47,7 @@ export class DefaultReporter implements Reporter { private renderEmitter = new EventEmitter(); private progressState: ProgressState | null = null private verbosityToggleCallback: (() => void) | null = null; - private sudoPasswordSubmittedCallback: ((password: string) => void) | null = null; + private sudoPasswordSubmittedCallback: ((password: string) => boolean) | null = null; silent = false; constructor() { @@ -63,8 +63,8 @@ export class DefaultReporter implements Reporter { this.verbosityToggleCallback?.(); }); - this.renderEmitter.on(RenderEvent.SUDO_PASSWORD_SUBMITTED, (password: string) => { - this.sudoPasswordSubmittedCallback?.(password); + this.renderEmitter.on(RenderEvent.SUDO_PASSWORD_TOGGLE, () => { + this.handleInlineSudoPassword(); }); } @@ -72,14 +72,10 @@ export class DefaultReporter implements Reporter { this.verbosityToggleCallback = callback; } - onSudoPasswordSubmitted(callback: (password: string) => void): void { + onSudoPasswordSubmitted(callback: (password: string) => boolean): void { this.sudoPasswordSubmittedCallback = callback; } - notifySudoPasswordResult(success: boolean): void { - this.renderEmitter.emit(RenderEvent.SUDO_PASSWORD_RESULT, { success }); - } - notifySudoPasswordPreSupplied(): void { setImmediate(() => this.renderEmitter.emit(RenderEvent.SUDO_PASSWORD_PRE_SUPPLIED)); } @@ -327,6 +323,41 @@ export class DefaultReporter implements Reporter { store.set(store.progressState, structuredClone(this.progressState)); } + private async handleInlineSudoPassword(): Promise { + let attemptCount = 0; + + while (attemptCount < 3) { + this.renderEmitter.emit(RenderEvent.DISABLE_SUDO_PROMPT, false); + const passwordAttempt = await this.updateStateAndAwaitEvent( + () => this.updateRenderState(RenderStatus.SUDO_PROMPT, attemptCount), + RenderEvent.SUDO_PROMPT_RESULT, + ); + this.renderEmitter.emit(RenderEvent.DISABLE_SUDO_PROMPT, true); + + const isValid = this.sudoPasswordSubmittedCallback?.(passwordAttempt) ?? false; + if (isValid) { + await sleep(50); + this.updateRenderState(RenderStatus.NOTHING, null); + await sleep(50); + await this.displayProgress(); + this.renderEmitter.emit(RenderEvent.SUDO_PASSWORD_PRE_SUPPLIED); + return; + } + + if (attemptCount + 1 < 3) { + ctx.log(chalk.red(`Sorry, try again. (${attemptCount + 1}/3)`)); + } + + attemptCount++; + } + + // All attempts exhausted — restore progress display without marking saved + await sleep(50); + this.updateRenderState(RenderStatus.NOTHING, null); + await sleep(50); + await this.displayProgress(); + } + private async getUserPassword(): Promise { let attemptCount = 0; diff --git a/src/ui/reporters/reporter.ts b/src/ui/reporters/reporter.ts index 5844abc4..59ab543e 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -24,8 +24,7 @@ export enum RenderEvent { SUDO_PROMPT_RESULT = 'promptSudoResult', STATE_TRANSITION = 'stateTransition', TOGGLE_VERBOSITY = 'toggleVerbosity', - SUDO_PASSWORD_SUBMITTED = 'sudoPasswordSubmitted', - SUDO_PASSWORD_RESULT = 'sudoPasswordResult', + SUDO_PASSWORD_TOGGLE = 'sudoPasswordToggle', SUDO_PASSWORD_PRE_SUPPLIED = 'sudoPasswordPreSupplied', } From 2a30c3f3d67fe3cad7f5946faa28ded89d93d1cd Mon Sep 17 00:00:00 2001 From: kevinwang Date: Fri, 24 Apr 2026 10:26:21 -0400 Subject: [PATCH 08/35] feat: Changed to custom component --- src/ui/components/default-component.tsx | 27 +++------ .../components/widgets/SudoPasswordInput.tsx | 57 +++++++++++++++++++ src/ui/reporters/default-reporter.tsx | 27 +++++---- src/ui/reporters/reporter.ts | 1 + 4 files changed, 80 insertions(+), 32 deletions(-) create mode 100644 src/ui/components/widgets/SudoPasswordInput.tsx diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 5f2063ff..9b3e9161 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -1,12 +1,11 @@ import { Form, FormProps } from '@codifycli/ink-form'; -import { PasswordInput, TextInput } from '@inkjs/ui'; +import { TextInput } from '@inkjs/ui'; import chalk from 'chalk'; import { Box, Static, Text } from 'ink'; import SelectInput from 'ink-select-input'; import { useAtom } from 'jotai'; -import { selectAtom } from 'jotai/utils'; import { EventEmitter } from 'node:events'; -import React, { useLayoutEffect, useState } from 'react'; +import React, { useLayoutEffect } from 'react'; import { Plan } from '../../entities/plan.js'; import { FileModificationResult } from '../../generators/index.js'; @@ -21,12 +20,12 @@ import { MultiSelect } from './multi-select/MultiSelect.js'; import { PlanComponent } from './plan/plan.js'; import { ProgressDisplay } from './progress/progress-display.js'; import { PromptPressKeyToContinue } from './widgets/PromptPressKeyToContinue.js'; +import { SudoPasswordInput } from './widgets/SudoPasswordInput.js'; export function DefaultComponent(props: { emitter: EventEmitter }) { const { emitter } = props - const [disableSudoPrompt, setDisableSudoPrompt] = useState(false); const [{ status: renderStatus, data: renderData }] = useAtom(store.renderState); // Use layoutEffect runs before the first render, whereas useEffect runs after @@ -37,15 +36,8 @@ export function DefaultComponent(props: { emitter.on(RenderEvent.LOG, logListener); - const disableSudoPrompt = (isDisabled: boolean) => { - setDisableSudoPrompt(isDisabled); - } - - emitter.on(RenderEvent.DISABLE_SUDO_PROMPT, disableSudoPrompt) - return () => { emitter.off(RenderEvent.LOG, logListener); - emitter.off(RenderEvent.DISABLE_SUDO_PROMPT, disableSudoPrompt); } }, []); @@ -91,13 +83,12 @@ export function DefaultComponent(props: { } { renderStatus === RenderStatus.SUDO_PROMPT && ( - - Password: - {/* Use sudoAttemptCount as a hack to reset password input between attempts */} - { - emitter.emit(RenderEvent.SUDO_PROMPT_RESULT, password); - }}/> - + 0} + onSubmit={(password) => emitter.emit(RenderEvent.SUDO_PROMPT_RESULT, password)} + onCancel={() => emitter.emit(RenderEvent.SUDO_PASSWORD_CANCEL)} + /> ) } { diff --git a/src/ui/components/widgets/SudoPasswordInput.tsx b/src/ui/components/widgets/SudoPasswordInput.tsx new file mode 100644 index 00000000..872fb1d5 --- /dev/null +++ b/src/ui/components/widgets/SudoPasswordInput.tsx @@ -0,0 +1,57 @@ +import { Box, Text, useInput } from 'ink'; +import React, { useState } from 'react'; + +export function SudoPasswordInput(props: { + hasError: boolean; + onSubmit: (password: string) => void; + onCancel: () => void; +}) { + const { hasError, onSubmit, onCancel } = props; + const [value, setValue] = useState(''); + + const borderColor = hasError ? 'red' : 'cyan'; + + useInput((input, key) => { + if (key.escape) { + onCancel(); + return; + } + + if (key.return) { + onSubmit(value); + setValue(''); + return; + } + + if (key.backspace || key.delete) { + setValue((prev) => prev.slice(0, -1)); + return; + } + + // Ignore non-printable characters + if (input && !key.ctrl && !key.meta) { + setValue((prev) => prev + input); + } + }); + + return ( + + + Sudo Password: + {value.replace(/./g, '*')} + + + {hasError && Incorrect password, try again} + Enter to confirm · Esc to cancel + + ); +} \ No newline at end of file diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index 102852b0..dea9e06e 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -327,14 +327,19 @@ export class DefaultReporter implements Reporter { let attemptCount = 0; while (attemptCount < 3) { - this.renderEmitter.emit(RenderEvent.DISABLE_SUDO_PROMPT, false); - const passwordAttempt = await this.updateStateAndAwaitEvent( - () => this.updateRenderState(RenderStatus.SUDO_PROMPT, attemptCount), - RenderEvent.SUDO_PROMPT_RESULT, - ); - this.renderEmitter.emit(RenderEvent.DISABLE_SUDO_PROMPT, true); + const result = await (await Promise.all([ + this.updateRenderState(RenderStatus.SUDO_PROMPT, attemptCount), + Promise.race([ + this.awaitEvent(RenderEvent.SUDO_PROMPT_RESULT), + this.awaitEvent<'cancel'>(RenderEvent.SUDO_PASSWORD_CANCEL), + ]), + ])).at(1) as string | 'cancel'; + + if (result === 'cancel') { + break; + } - const isValid = this.sudoPasswordSubmittedCallback?.(passwordAttempt) ?? false; + const isValid = this.sudoPasswordSubmittedCallback?.(result) ?? false; if (isValid) { await sleep(50); this.updateRenderState(RenderStatus.NOTHING, null); @@ -344,14 +349,10 @@ export class DefaultReporter implements Reporter { return; } - if (attemptCount + 1 < 3) { - ctx.log(chalk.red(`Sorry, try again. (${attemptCount + 1}/3)`)); - } - attemptCount++; } - // All attempts exhausted — restore progress display without marking saved + // Cancelled or all attempts exhausted — restore progress display await sleep(50); this.updateRenderState(RenderStatus.NOTHING, null); await sleep(50); @@ -362,12 +363,10 @@ export class DefaultReporter implements Reporter { let attemptCount = 0; while (attemptCount < 3) { - this.renderEmitter.emit(RenderEvent.DISABLE_SUDO_PROMPT, false); const passwordAttempt = await this.updateStateAndAwaitEvent( () => this.updateRenderState(RenderStatus.SUDO_PROMPT, attemptCount), RenderEvent.SUDO_PROMPT_RESULT, ); - this.renderEmitter.emit(RenderEvent.DISABLE_SUDO_PROMPT, true); // Validates that the password works if (SudoUtils.validate(passwordAttempt)) { diff --git a/src/ui/reporters/reporter.ts b/src/ui/reporters/reporter.ts index 59ab543e..0b754cfb 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -25,6 +25,7 @@ export enum RenderEvent { STATE_TRANSITION = 'stateTransition', TOGGLE_VERBOSITY = 'toggleVerbosity', SUDO_PASSWORD_TOGGLE = 'sudoPasswordToggle', + SUDO_PASSWORD_CANCEL = 'sudoPasswordCancel', SUDO_PASSWORD_PRE_SUPPLIED = 'sudoPasswordPreSupplied', } From 92b47caadb962dcf4d2e013fe7b853fbe9967dd0 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Fri, 24 Apr 2026 10:35:22 -0400 Subject: [PATCH 09/35] feat: Changed to custom components intead of inkjs --- package-lock.json | 32 ++----------------- package.json | 1 - src/ui/components/default-component.tsx | 2 +- src/ui/components/import/import-result.tsx | 25 ++++++--------- src/ui/components/init/InitBanner.tsx | 11 +++++-- src/ui/components/widgets/TextInput.tsx | 37 ++++++++++++++++++++++ 6 files changed, 58 insertions(+), 50 deletions(-) create mode 100644 src/ui/components/widgets/TextInput.tsx diff --git a/package-lock.json b/package-lock.json index c166258e..842ea8b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,17 @@ { "name": "codify", - "version": "1.0.2", + "version": "1.1.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codify", - "version": "1.0.2", + "version": "1.1.0-beta", "license": "Apache-2.0", "dependencies": { "@codifycli/ink-form": "0.0.12", "@codifycli/schemas": "1.1.0-beta4", "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", - "@inkjs/ui": "^2", "@mischnic/json-sourcemap": "^0.1.1", "@oclif/core": "^4.0.8", "@oclif/plugin-autocomplete": "^3.2.24", @@ -2131,24 +2130,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@inkjs/ui": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@inkjs/ui/-/ui-2.0.0.tgz", - "integrity": "sha512-5+8fJmwtF9UvikzLfph9sA+LS+l37Ij/szQltkuXLOAXwNkBX9innfzh4pLGXIB59vKEQUtc6D4qGvhD7h3pAg==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "cli-spinners": "^3.0.0", - "deepmerge": "^4.3.1", - "figures": "^6.1.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "ink": ">=5" - } - }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -7793,15 +7774,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", diff --git a/package.json b/package.json index 76fb088f..0ced07fb 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "@codifycli/ink-form": "0.0.12", "@codifycli/schemas": "1.1.0-beta4", "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", - "@inkjs/ui": "^2", "@mischnic/json-sourcemap": "^0.1.1", "@oclif/core": "^4.0.8", "@oclif/plugin-autocomplete": "^3.2.24", diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 9b3e9161..a005ba4e 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -1,5 +1,4 @@ import { Form, FormProps } from '@codifycli/ink-form'; -import { TextInput } from '@inkjs/ui'; import chalk from 'chalk'; import { Box, Static, Text } from 'ink'; import SelectInput from 'ink-select-input'; @@ -21,6 +20,7 @@ import { PlanComponent } from './plan/plan.js'; import { ProgressDisplay } from './progress/progress-display.js'; import { PromptPressKeyToContinue } from './widgets/PromptPressKeyToContinue.js'; import { SudoPasswordInput } from './widgets/SudoPasswordInput.js'; +import { TextInput } from './widgets/TextInput.js'; export function DefaultComponent(props: { emitter: EventEmitter diff --git a/src/ui/components/import/import-result.tsx b/src/ui/components/import/import-result.tsx index 31065e8a..d665d3ed 100644 --- a/src/ui/components/import/import-result.tsx +++ b/src/ui/components/import/import-result.tsx @@ -1,4 +1,3 @@ -import { OrderedList } from '@inkjs/ui'; import { Box, Text } from 'ink'; import React from 'react'; @@ -15,13 +14,11 @@ export function ImportResultComponent(props: { { result.length > 0 && !props.showConfigs && ( Successfully imported the following configs: - - { - result.map((r, idx) => - {r.type} - ) - } - + + {result.map((r, idx) => ( + {idx + 1}. {r.type} + ))} + ) } { @@ -39,13 +36,11 @@ export function ImportResultComponent(props: { { errors.length > 0 && ( The following configs failed to import: - - { - errors.map((e, idx) => - {e} - ) - } - + + {errors.map((e, idx) => ( + {idx + 1}. {e} + ))} + ) } diff --git a/src/ui/components/init/InitBanner.tsx b/src/ui/components/init/InitBanner.tsx index 2ce30022..921d4a0e 100644 --- a/src/ui/components/init/InitBanner.tsx +++ b/src/ui/components/init/InitBanner.tsx @@ -1,5 +1,4 @@ -import { Select } from '@inkjs/ui'; -import { Box, Static, Text } from 'ink'; +import { Box, Static, Text, useInput } from 'ink'; import BigText from 'ink-big-text'; import Gradient from 'ink-gradient'; import EventEmitter from 'node:events'; @@ -8,6 +7,12 @@ import React from 'react'; import { RenderEvent } from '../../reporters/reporter.js'; export function InitBanner(props: { emitter: EventEmitter }) { + useInput((_, key) => { + if (key.return) { + props.emitter.emit(RenderEvent.PROMPT_RESULT); + } + }); + return { () => @@ -20,6 +25,6 @@ export function InitBanner(props: { emitter: EventEmitter }) { Codify will scan your system for any supported programs or settings and automatically generate configs for you. } -