Skip to content

Commit ef19f5c

Browse files
committed
feat: add template resolution for codify commands. Refactor initialization steps
1 parent 2a93754 commit ef19f5c

22 files changed

Lines changed: 429 additions & 94 deletions

File tree

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/api/dashboard/index.ts‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { config } from '../../config.js';
22
import { LoginHelper } from '../../connect/login-helper.js';
3-
import { CloudDocument } from './types.js';
3+
import { RemoteDocument, RegistryTemplate } from './types.js';
44
import { UnauthorizedError } from '../../common/errors.js';
55

66
export const DashboardApiClient = {
7-
async getDocument(id: string): Promise<CloudDocument> {
7+
async getDocument(id: string): Promise<RemoteDocument> {
88
const login = LoginHelper.get()?.credentials;
99
if (!login) {
1010
throw new Error('Not logged in');
@@ -25,7 +25,29 @@ export const DashboardApiClient = {
2525
}
2626

2727
const json = await res.json();
28-
return json as CloudDocument;
28+
return json as RemoteDocument;
29+
},
30+
31+
/**
32+
* Fetch a public template by exact name or id. Does not require login.
33+
* Returns null if no template matches.
34+
*/
35+
async getTemplate(nameOrId: string): Promise<null | RegistryTemplate> {
36+
const res = await fetch(
37+
`${config.apiUrl}/v1/templates/${encodeURIComponent(nameOrId)}`,
38+
{ method: 'GET', headers: { 'Content-Type': 'application/json' } },
39+
);
40+
41+
if (res.status === 404) {
42+
return null;
43+
}
44+
45+
if (!res.ok) {
46+
const message = await res.text();
47+
throw new Error(`Unable to fetch template "${nameOrId}": ${message}`);
48+
}
49+
50+
return await res.json() as RegistryTemplate;
2951
},
3052

3153
async getDefaultDocumentId(): Promise<null | string> {

‎src/api/dashboard/types.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import { Config } from '@codifycli/schemas';
22

3-
export interface CloudDocument {
3+
export interface RemoteDocument {
44
id: string;
55
contents: Array<Config>
66
}
7+
8+
export interface RegistryTemplate {
9+
id: string;
10+
name: string;
11+
displayName: string;
12+
description: string | null;
13+
// Raw JSONC contents of the template (as authored in the dashboard editor)
14+
rawConfigs: string;
15+
}

‎src/commands/apply.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ any changes.
1616
For scripts: use ${chalk.bold.bgMagenta(' --output json ')} which will skip approval and
1717
apply changes directly.
1818
19+
The config can be a local file or directory, a public template name, or a Codify
20+
dashboard document id. With no argument, the codify file in the current directory is used.
21+
1922
For more information, visit: https://codifycli.com/docs/commands/apply
2023
`
2124

@@ -41,11 +44,13 @@ For more information, visit: https://codifycli.com/docs/commands/apply
4144
}
4245

4346
static args = {
44-
pathArgs: Args.string(),
47+
pathArgs: Args.string({ description: 'Path to a codify file or directory, a template name, or a document id' }),
4548
}
4649

4750
static examples = [
4851
'<%= config.bin %> <%= command.id %>',
52+
'<%= config.bin %> <%= command.id %> ./my-setup.codify.jsonc',
53+
'<%= config.bin %> <%= command.id %> <template name>',
4954
'<%= config.bin %> <%= command.id %> --path ~',
5055
'<%= config.bin %> <%= command.id %> -o json',
5156
'<%= config.bin %> <%= command.id %> -S <sudo password>',

‎src/commands/plan.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ For more information, visit: https://codifycli.com/docs/commands/plan`
2121
'<%= config.bin %> <%= command.id %>',
2222
'<%= config.bin %> <%= command.id %> -o json',
2323
'<%= config.bin %> <%= command.id %> -p ../',
24+
'<%= config.bin %> <%= command.id %> <template name>',
2425
]
2526

2627
static args = {
27-
pathArgs: Args.string(),
28+
pathArgs: Args.string({ description: 'Path to a codify file or directory, a template name, or a document id' }),
2829
}
2930

3031
async init(): Promise<void> {

‎src/common/errors.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,3 +324,37 @@ export function prettyPrintError(error: unknown): void {
324324

325325
console.error(chalk.red(String(error)));
326326
}
327+
328+
/**
329+
* Thrown when Codify can't find a config to run. Includes usage help listing the different
330+
* ways a config can be specified.
331+
*/
332+
export class NoCodifyFilesError extends CodifyError {
333+
name = 'NoCodifyFilesError'
334+
335+
/** `location` isn't a file, directory, document id or template. */
336+
static notFound(location: string): NoCodifyFilesError {
337+
return new NoCodifyFilesError(`No Codify file, directory, or template found matching "${location}"`);
338+
}
339+
340+
/** `dir` exists but doesn't contain any codify files. */
341+
static emptyDir(dir: string): NoCodifyFilesError {
342+
return new NoCodifyFilesError(`No Codify files found in ${dir}`);
343+
}
344+
345+
formattedMessage(): string {
346+
const usage = [
347+
'Codify looks for files named *codify.jsonc, *codify.json, *codify.json5 or *codify.yaml.',
348+
'',
349+
'Usage:',
350+
` ${chalk.bold('codify apply')} Use the Codify file in the current directory`,
351+
` ${chalk.bold('codify apply ./path/to/file')} Use a specific file or directory`,
352+
` ${chalk.bold('codify apply <template name>')} Use a public template from the Codify registry`,
353+
` ${chalk.bold('codify apply <document id>')} Use a document from the Codify dashboard (requires login)`,
354+
'',
355+
`Run ${chalk.bold('codify init')} to generate a Codify file from your current machine.`,
356+
].join('\n');
357+
358+
return `${this.message}\n\n${chalk.white(usage)}`;
359+
}
360+
}

‎src/common/initialize-plugins.ts‎

Lines changed: 25 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
11
import { Config } from '@codifycli/schemas';
2-
import * as fs from 'node:fs/promises'
3-
import * as path from 'node:path'
4-
import { validate } from 'uuid';
52

6-
import { DashboardApiClient } from '../api/dashboard/index.js';
7-
import { LoginHelper } from '../connect/login-helper.js';
83
import { Project } from '../entities/project.js';
94
import { SubProcessName, ctx } from '../events/context.js';
10-
import { CODIFY_FILE_REGEX, CodifyParser } from '../parser/index.js';
5+
import { CodifyParser } from '../parser/index.js';
6+
import { CodifyResolver, type CodifySource, CodifySourceType } from '../parser/resolver.js';
117
import { PluginManager, ResourceDefinitionMap } from '../plugins/plugin-manager.js';
128
import { Reporter } from '../ui/reporters/reporter.js';
13-
import { FileUtils } from '../utils/file.js';
149
import { ShellUtils } from '../utils/shell.js';
1510

1611
export interface InitializeArgs {
@@ -22,6 +17,7 @@ export interface InitializeArgs {
2217
forceEmptyProject?: boolean;
2318
codifyConfigs?: Config[];
2419
noProgress?: boolean;
20+
allowTemplates?: boolean;
2521
}
2622

2723
export interface InitializationResult {
@@ -65,13 +61,15 @@ export class PluginInitOrchestrator {
6561
return CodifyParser.parseJson(args.codifyConfigs);
6662
}
6763

68-
const codifyPath = await PluginInitOrchestrator.resolveCodifyRootPath(args, reporter);
69-
ctx.subprocessStarted(SubProcessName.PARSE);
70-
71-
const project = codifyPath
72-
? await CodifyParser.parse(codifyPath)
73-
: Project.empty()
64+
const source = await CodifyResolver.resolve({
65+
location: args.path,
66+
allowTemplates: args.allowTemplates,
67+
allowEmpty: args.allowEmptyProject,
68+
}, reporter);
7469

70+
ctx.subprocessStarted(SubProcessName.PARSE);
71+
const project = await PluginInitOrchestrator.parseSource(source);
72+
project.source = source;
7573
ctx.subprocessFinished(SubProcessName.PARSE);
7674

7775
if (args.transformProject) {
@@ -81,59 +79,23 @@ export class PluginInitOrchestrator {
8179
return project;
8280
}
8381

84-
/** Resolve the root codify file to run.
85-
* Order:
86-
* 1. If path is specified, return that.
87-
* 2. If path is a dir with only one *codify.json|*codify.jsonc|*codify.json5|*codify.yaml, return that.
88-
* 3. If path is a UUID, return file from Codify cloud.
89-
* 4. If multiple exists in the path (dir), then prompt the user to select one.
90-
* 5. If no path is provided, run steps 2 - 4 for the current dir.
91-
* 6. If none exists, return default file from codify cloud.
92-
* 7. If user is not logged in, return an error.
93-
*
94-
* @param args
95-
* @private
96-
*/
97-
private static async resolveCodifyRootPath(args: InitializeArgs, reporter: Reporter): Promise<string | undefined> {
98-
const inputPath = args.path ?? process.cwd();
99-
100-
// Cloud files will be fetched and processed later in the parser.
101-
const isCloud = validate(inputPath);
102-
if (isCloud) {
103-
return inputPath;
104-
}
105-
106-
// Direct files can have its path returned.
107-
const isPathDir = await FileUtils.isDir(inputPath);
108-
if (!isPathDir) {
109-
return inputPath;
110-
}
111-
112-
const filesInDir = await fs.readdir(inputPath);
113-
const codifyFiles = filesInDir.filter((f) => CODIFY_FILE_REGEX.test(f))
82+
private static async parseSource(source: CodifySource | null): Promise<Project> {
83+
switch (source?.type) {
84+
case CodifySourceType.LOCAL: {
85+
return CodifyParser.parse(source.path);
86+
}
11487

115-
if (codifyFiles.length === 1) {
116-
return codifyFiles[0];
117-
}
118-
119-
if (codifyFiles.length > 0) {
120-
const answer = await reporter.promptOptions(
121-
'Multiple codify files found in dir. Please select one:',
122-
codifyFiles,
123-
);
88+
case CodifySourceType.REMOTE: {
89+
return CodifyParser.parse(source.documentId);
90+
}
12491

125-
return path.join(inputPath, codifyFiles[answer]);
126-
}
92+
case CodifySourceType.TEMPLATE: {
93+
return CodifyParser.parseTemplate(source.template);
94+
}
12795

128-
if (LoginHelper.get()?.isLoggedIn) {
129-
return (await DashboardApiClient.getDefaultDocumentId()) ?? undefined;
96+
default: {
97+
return Project.empty();
98+
}
13099
}
131-
132-
if (args.allowEmptyProject) {
133-
return undefined;
134-
}
135-
136-
throw new Error('No codify files found.');
137100
}
138-
139101
}

‎src/entities/project.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { OS, PlanRequestData, ResourceOperation, ValidateResponseData } from '@codifycli/schemas';
22
import * as os from 'node:os'
3-
import { validate } from 'uuid'
43

54
import {
65
LinuxDistroNotSupportedError,
@@ -10,6 +9,7 @@ import {
109
TypeNotFoundError
1110
} from '../common/errors.js';
1211
import { ctx } from '../events/context.js';
12+
import { type CodifySource, CodifySourceType } from '../parser/entities.js';
1313
import { SourceMapCache } from '../parser/source-maps.js';
1414
import { ResourceDefinitionMap } from '../plugins/plugin-manager.js';
1515
import { DependencyGraphResolver } from '../utils/dependency-graph-resolver.js';
@@ -27,6 +27,8 @@ export class Project {
2727
evaluationOrder: null | string[] = null;
2828

2929
codifyFiles: string[];
30+
// Where this project was loaded from. Null for projects built in memory (empty projects, json configs, etc.)
31+
source: CodifySource | null = null;
3032

3133
sourceMaps?: SourceMapCache;
3234
planRequestsCache?: Map<string, PlanRequestData>
@@ -73,9 +75,16 @@ ${JSON.stringify(projectConfigs, null, 2)}`);
7375
return this.stateConfigs !== null && this.stateConfigs !== undefined && this.stateConfigs.length > 0;
7476
}
7577

76-
// TODO: Update to a more robust method in the future
77-
isCloud(): boolean {
78-
return validate(this.codifyFiles[0])
78+
isRemote(): boolean {
79+
return this.source?.type === CodifySourceType.REMOTE;
80+
}
81+
82+
isTemplate(): boolean {
83+
return this.source?.type === CodifySourceType.TEMPLATE;
84+
}
85+
86+
get templateName(): string | undefined {
87+
return this.source?.type === CodifySourceType.TEMPLATE ? this.source.template.name : undefined;
7988
}
8089

8190
filterInPlace(ids: string[]): Project {
@@ -134,6 +143,7 @@ ${JSON.stringify(projectConfigs, null, 2)}`);
134143
this.sourceMaps,
135144
)
136145

146+
uninstallProject.source = this.source;
137147
uninstallProject.stateConfigs = uninstallProject.resourceConfigs;
138148
uninstallProject.resourceConfigs = [];
139149
this.isDestroyProject = true;

‎src/generators/file-modification-calculator.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export class FileModificationCalculator {
9797
return;
9898
}
9999

100-
if (this.existingFile?.fileType !== FileType.JSON && this.existingFile?.fileType !== FileType.JSON5 && this.existingFile?.fileType !== FileType.JSONC && this.existingFile?.fileType !== FileType.CLOUD) {
100+
if (this.existingFile?.fileType !== FileType.JSON && this.existingFile?.fileType !== FileType.JSON5 && this.existingFile?.fileType !== FileType.JSONC && this.existingFile?.fileType !== FileType.REMOTE) {
101101
throw new Error(`Only updating .json, .json5, and .jsonc files are currently supported. Found ${this.existingFile?.filePath}`);
102102
}
103103

@@ -239,7 +239,7 @@ export class FileModificationCalculator {
239239
return 'cjson'
240240
}
241241

242-
if (fileType === FileType.CLOUD) {
242+
if (fileType === FileType.REMOTE) {
243243
return 'cjson'
244244
}
245245

‎src/generators/writer.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { FileUtils } from '../utils/file.js';
55

66
export class FileUpdater {
77
static async write(filePath: string, content: string) {
8-
// Cloud file
8+
// Remote file
99
if (validate(filePath)) {
1010
return DashboardApiClient.saveDocumentUpdate(filePath, content);
1111
}

0 commit comments

Comments
 (0)