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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 19 additions & 30 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
},
"dependencies": {
"@codifycli/ink-form": "0.0.12",
"@codifycli/schemas": "1.1.0-beta8",
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5",
"@codifycli/schemas": "1.2.0",
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
"@mischnic/json-sourcemap": "^0.1.1",
"@oclif/core": "^4.0.8",
"@oclif/plugin-autocomplete": "^3.2.24",
Expand Down Expand Up @@ -43,7 +43,7 @@
},
"description": "Codify is a configuration-as-code tool that declaratively installs and manages developer tools and applications. Check out https://dashboard.codifycli.com for an editor.",
"devDependencies": {
"@codifycli/plugin-core": "^1.1.0-beta19",
"@codifycli/plugin-core": "^1.2.0",
"@oclif/prettier-config": "^0.2.1",
"@types/chalk": "^2.2.0",
"@types/cors": "^2.8.19",
Expand Down Expand Up @@ -145,7 +145,7 @@
"deploy": "npm run pkg && npm run notarize && npm run upload",
"prepublishOnly": "npm run build"
},
"version": "1.1.0",
"version": "1.2.0-beta.4",
"bugs": "https://github.com/codifycli/codify/issues",
"keywords": [
"oclif",
Expand Down
63 changes: 63 additions & 0 deletions src/common/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,69 @@ export class PluginError extends CodifyError {
}
}

export class ShellValidationError extends CodifyError {
name = 'ShellValidationError';
timedOut: boolean;
capturedOutput: string;
rcFiles: string[];

constructor(timedOut: boolean, capturedOutput: string, rcFiles: string[]) {
super(timedOut
? 'Shell initialization timed out — a shell rc file may be waiting for input'
: 'Shell initialization produced unexpected output'
);
this.timedOut = timedOut;
this.capturedOutput = capturedOutput;
this.rcFiles = rcFiles;
}

formattedMessage(): string {
const rcFileList = this.rcFiles.map((f) => chalk.white(` • ${f}`)).join('\n');
const indentedOutput = (text: string) => (text || '(none)')
.split('\n')
.map((l) => chalk.red('│ ') + chalk.white(l))
.join('\n');

if (this.timedOut) {
return [
chalk.bold('Shell Validation Error: Shell timed out after 10 seconds'),
'',
'Something in your shell initialization is waiting for interactive input',
chalk.white('(e.g. a password prompt, a `read` call, or an SSH key passphrase)'),
'',
chalk.white('Codify sources your interactive shell to install tools exactly as you would.') +
' A shell that hangs on startup will prevent Codify from running.',
'',
chalk.bold('Output captured before timeout:'),
indentedOutput(this.capturedOutput),
'',
chalk.bold('Check the following shell rc files for interactive prompts:'),
rcFileList,
].join('\n');
}

return [
chalk.bold('Shell Validation Error: Unexpected output detected on shell startup'),
'',
chalk.bold('Unexpected output:'),
indentedOutput(this.capturedOutput),
'',
'Your shell initialization is printing extra output',
chalk.white('(e.g. a greeting, banner, or debug message)'),
chalk.white('Codify sources your interactive shell to install tools exactly as you would.') +
' Any extra output from your rc files will break plugin commands that parse shell output.',
'',
chalk.bold('Check the following rc files and wrap output-producing lines in an interactive guard:'),
rcFileList,
'',
chalk.bold('Example fix for .zshrc / .bashrc:'),
chalk.white(' if [[ "$TERM_PROGRAM" != "codify" ]]; then'),
chalk.white(' echo "your greeting here" # skipped when Codify sources your shell'),
chalk.white(' fi'),
].join('\n');
}
}

export function prettyPrintError(error: unknown): void {
if (error instanceof CodifyError) {
return console.error(chalk.red(error.formattedMessage()));
Expand Down
3 changes: 3 additions & 0 deletions src/common/initialize-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { CODIFY_FILE_REGEX, CodifyParser } from '../parser/index.js';
import { PluginManager, ResourceDefinitionMap } from '../plugins/plugin-manager.js';
import { Reporter } from '../ui/reporters/reporter.js';
import { FileUtils } from '../utils/file.js';
import { ShellUtils } from '../utils/shell.js';

export interface InitializeArgs {
path?: string;
Expand All @@ -34,6 +35,8 @@ export class PluginInitOrchestrator {
args: InitializeArgs,
reporter: Reporter,
): Promise<InitializationResult> {
await ShellUtils.validateShell();

const project = await PluginInitOrchestrator.parseProject(
args,
reporter
Expand Down
4 changes: 4 additions & 0 deletions src/entities/apply-note.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface ApplyNote {
message: string;
resourceType?: string;
}
6 changes: 4 additions & 2 deletions src/entities/apply-result.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ResourceOperation } from '@codifycli/schemas';

import { PluginError } from '../common/errors.js';
import { ApplyNote } from './apply-note.js';
import { ResourcePlan } from './plan.js';

export interface ApplyResultEntry {
Expand All @@ -13,6 +14,7 @@ export interface ApplyResultEntry {
export interface ApplyResult {
entries: ApplyResultEntry[];
errors: PluginError[];
notes: ApplyNote[];

isPartialFailure(): boolean;
}
Expand All @@ -21,9 +23,8 @@ export function createApplyResult(
succeededPlans: ResourcePlan[],
failedErrors: PluginError[],
skippedIds: Set<string>,
notes: ApplyNote[] = [],
): ApplyResult {
const failedByType = new Map(failedErrors.map((e) => [e.resourceType, e]));

const entries: ApplyResultEntry[] = [
...succeededPlans.map((p) => ({
id: p.id,
Expand All @@ -46,6 +47,7 @@ export function createApplyResult(
return {
entries,
errors: failedErrors,
notes,
isPartialFailure() {
return failedErrors.length > 0;
},
Expand Down
129 changes: 129 additions & 0 deletions src/entities/depends-on-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { 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';

function makeProject(...configs: ResourceConfig[]): Project {
return new Project(null, configs, []);
}

describe('dependsOn resolution', () => {
it('resolves by type — all resources of that type become dependencies', () => {
const a = new ResourceConfig({ type: 'npm', name: 'lodash' });
const b = new ResourceConfig({ type: 'npm', name: 'react' });
const c = new ResourceConfig({ type: 'node', dependsOn: ['npm'] });

const project = makeProject(a, b, c);
project.resolveDependenciesAndCalculateEvalOrder();

expect(c.dependencyIds).toContain('npm.lodash');
expect(c.dependencyIds).toContain('npm.react');
expect(project.evaluationOrder).toContain('npm.lodash');
expect(project.evaluationOrder).toContain('npm.react');
expect(project.evaluationOrder!.indexOf('node')).toBeGreaterThan(project.evaluationOrder!.indexOf('npm.lodash'));
expect(project.evaluationOrder!.indexOf('node')).toBeGreaterThan(project.evaluationOrder!.indexOf('npm.react'));
});

it('resolves by fully qualified id (type.name) — exactly one resource', () => {
const a = new ResourceConfig({ type: 'git-clone', name: 'my-repo' });
const b = new ResourceConfig({ type: 'git-clone', name: 'other-repo' });
const c = new ResourceConfig({ type: 'node', dependsOn: ['git-clone.my-repo'] });

const project = makeProject(a, b, c);
project.resolveDependenciesAndCalculateEvalOrder();

expect(c.dependencyIds).toEqual(['git-clone.my-repo']);
expect(c.dependencyIds).not.toContain('git-clone.other-repo');
});

it('throws when a fully qualified id is not found', () => {
const a = new ResourceConfig({ type: 'git-clone', name: 'my-repo' });
const c = new ResourceConfig({ type: 'node', dependsOn: ['git-clone.missing'] });

const project = makeProject(a, c);
expect(() => project.resolveDependenciesAndCalculateEvalOrder()).toThrow(/git-clone\.missing/);
});

it('resolves by name alone when unambiguous', () => {
const a = new ResourceConfig({ type: 'git-clone', name: 'my-repo' });
const c = new ResourceConfig({ type: 'node', dependsOn: ['my-repo'] });

const project = makeProject(a, c);
project.resolveDependenciesAndCalculateEvalOrder();

expect(c.dependencyIds).toEqual(['git-clone.my-repo']);
});

it('resolves all resources sharing the same name (across different types)', () => {
const a = new ResourceConfig({ type: 'git-clone', name: 'shared' });
const b = new ResourceConfig({ type: 'npm', name: 'shared' });
const c = new ResourceConfig({ type: 'node', dependsOn: ['shared'] });

const project = makeProject(a, b, c);
project.resolveDependenciesAndCalculateEvalOrder();

expect(c.dependencyIds).toContain('git-clone.shared');
expect(c.dependencyIds).toContain('npm.shared');
});

it('throws when the reference matches nothing', () => {
const a = new ResourceConfig({ type: 'npm' });
const c = new ResourceConfig({ type: 'node', dependsOn: ['nonexistent'] });

const project = makeProject(a, c);
expect(() => project.resolveDependenciesAndCalculateEvalOrder()).toThrow(/nonexistent/);
});
});

describe('dependsOn resolution — OS filtering', () => {
it('drops dependsOn entries for resources removed by OS filter (fully qualified id)', () => {
vi.spyOn(OsUtils, 'getOs').mockReturnValue(ResourceOs.MACOS);
const apt = new ResourceConfig({ type: 'apt', name: 'linux-packages', os: [ResourceOs.LINUX] });
const docker = new ResourceConfig({ type: 'docker', dependsOn: ['apt.linux-packages'] });

const project = makeProject(apt, docker);
project.removeResourcesUsingOsFilter(); // removes apt on macOS
project.resolveDependenciesAndCalculateEvalOrder();

expect(docker.dependencyIds).not.toContain('apt.linux-packages');
});

it('drops dependsOn entries for resources removed by OS filter (type reference)', () => {
vi.spyOn(OsUtils, 'getOs').mockReturnValue(ResourceOs.MACOS);
const apt = new ResourceConfig({ type: 'apt', os: [ResourceOs.LINUX] });
const docker = new ResourceConfig({ type: 'docker', dependsOn: ['apt'] });

const project = makeProject(apt, docker);
project.removeResourcesUsingOsFilter();
project.resolveDependenciesAndCalculateEvalOrder();

expect(docker.dependencyIds).not.toContain('apt');
});

it('keeps dependsOn entry when only some resources of a type are filtered', () => {
vi.spyOn(OsUtils, 'getOs').mockReturnValue(ResourceOs.MACOS);
const aptLinux = new ResourceConfig({ type: 'apt', name: 'linux', os: [ResourceOs.LINUX] });
const aptAll = new ResourceConfig({ type: 'apt', name: 'all' }); // no OS filter
const docker = new ResourceConfig({ type: 'docker', dependsOn: ['apt'] });

const project = makeProject(aptLinux, aptAll, docker);
project.removeResourcesUsingOsFilter(); // removes apt.linux but keeps apt.all
project.resolveDependenciesAndCalculateEvalOrder();

expect(docker.dependencyIds).toContain('apt.all');
});

it('reproduces the reported cross-OS config scenario without throwing', () => {
vi.spyOn(OsUtils, 'getOs').mockReturnValue(ResourceOs.MACOS);
const homebrew = new ResourceConfig({ type: 'homebrew', name: 'macos-packages', os: [ResourceOs.MACOS] });
const apt = new ResourceConfig({ type: 'apt', name: 'linux-packages', os: [ResourceOs.LINUX] });
const docker = new ResourceConfig({ type: 'docker', dependsOn: ['homebrew.macos-packages', 'apt.linux-packages'] });
const nvm = new ResourceConfig({ type: 'nvm', dependsOn: ['homebrew.macos-packages', 'apt.linux-packages'] });

const project = makeProject(homebrew, apt, docker, nvm);
project.removeResourcesUsingOsFilter(); // on macOS: removes apt, keeps homebrew

expect(() => project.resolveDependenciesAndCalculateEvalOrder()).not.toThrow();
});
});
Loading
Loading