Skip to content

Commit bef6369

Browse files
committed
feat: add the template core resource
Adds a `template` config block (like `project`, it lives in the CLI and is never sent to a plugin) that includes the resources of a Codify template: a registry template (`name` or `@profile/name`) or a dashboard document (uuid). Requires @codifycli/schemas 1.3.0. - TemplateExpander replaces each template declaration with its resources, in place and recursively. Templates can't declare plugins. Cycles are skipped and a template included twice is added once. Every resource records the template chains it came from (origins); dependsOn/os/distro on a template declaration apply to every resource in it and `dependsOn: ["template.<name>"]` resolves to the template's resources. - After plugin init, TemplateResourceMerger merges template resources that the owning plugin matches (allowMultiple identifying parameters, or any resource of the type when allowMultiple is false) into the project resource or an earlier template resource. Arrays are combined, objects merged and scalar conflicts keep the winner (shown as plan warnings). Unmatched duplicates get numeric names in declaration order. - The template resources are planned and applied with the rest of the project. DependencyGraphResolver is now a stable Kahn sort, so resources keep their declaration order (template resources stay at the template's position) unless a dependency requires a move. Previously independent resources came out roughly reversed. - Actions that only come from templates must be approved before planning (their condition runs during the plan). Declining stops the run. Approvals are remembered in ~/.codify/approved-template-commands.json until the template or command changes. -y does not approve them; --auto-approve-all (or -yy) does. The json reporter never approves. - `codify apply <template>` is now a project with a single template declaration (CodifyParser.parseTemplate removed). - Plans show which template a resource came from. - import/refresh/edit don't expand templates. Fixes the file modification calculator corrupting files with template blocks when deleting a resource (template blocks were not counted). - Template versions are in the schema but not supported by the registry yet: declaring one throws. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QnBk4KjqTgmDacTAp63Un
1 parent 93e0312 commit bef6369

47 files changed

Lines changed: 1915 additions & 149 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CLAUDE.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,27 @@ Parent Process Plugin Process
188188
7. **Reporter display methods are async**: All `Reporter` interface display methods (`displayPlan`, `displayImportResult`, `displayFileModifications`, `displayMessage`, `displayPluginError`) return `Promise<void>`. Always `await` them at call sites — `DefaultReporter.updateRenderState()` has a 50ms sleep, so unawaited calls cause `process.exit(1)` to fire before the UI renders.
189189
8. **Mock reporter async assertions**: Assertions inside `MockReporter` config callbacks (e.g. `displayFileModifications`) will silently pass if the call isn't awaited. Making display methods async surfaced latent bugs where expected file paths were wrong.
190190

191+
## Template Resource Architecture
192+
193+
`{ "type": "template", "source": "nvm" | "@profile/name" | "<dashboard document uuid>" }` is a core config block (like `project`). It lives in the CLI, is never sent to a plugin, and is replaced by the resources of the template.
194+
195+
**Flow** (only for `plan`, `apply` and `destroy`: `PluginInitOrchestrator` with `expandTemplates: true`):
196+
```
197+
Parse → TemplateExpander.expand() → PluginManager.initialize() → os/distro filters
198+
→ TemplateResourceMerger.merge() → validate → resolve dependencies
199+
→ TemplateCommandApproval.ensureApproved() → plan → apply
200+
```
201+
202+
- **`src/entities/template-config.ts`**: `TemplateConfig` (`ConfigType.TEMPLATE`), validated against `TemplateSchema` from `@codifycli/schemas`. `Project.templateConfigs` holds the declarations and `Project.declarations` keeps the declaration order.
203+
- **`src/templates/template-loader.ts`**: `ApiTemplateLoader` loads registry templates (`DashboardApiClient.getTemplate`, no login) or dashboard documents (uuid, login required). `version` is in the schema but not supported by the registry yet (throws). Local files are intentionally not supported.
204+
- **`src/templates/template-expander.ts`**: expands templates recursively, in place. Templates can't declare plugins. Each resource records `origins` (the template chains it was included through). Cycles are skipped; a template included twice is added once and gains an extra origin.
205+
- **Template constraints**: `dependsOn` on a template declaration is stored in `ResourceConfig.inheritedDependsOn` (never written to files or sent to plugins). `os`/`distro` on a template are applied through `origins` in the Project filters. `dependsOn: ["template.<name>"]` / `"template"` / `"<name>"` resolve to the resources of that template.
206+
- **`src/templates/template-resource-merger.ts`**: after plugin init, template resources that the owning plugin `match`es with an earlier resource are merged into it (project resources win, then earlier templates). Arrays are combined, objects merged, scalar conflicts keep the winner and become `Plan.warnings`. Unmatched unnamed duplicates get numeric names in declaration order.
207+
- **Ordering**: `DependencyGraphResolver` is a stable Kahn sort (declaration order unless a dependency requires a move), so template resources stay at the template's position.
208+
- **`src/templates/template-command-approval.ts`**: `action` resources only declared in templates must be approved before planning (the `condition` runs during the plan). Approvals are stored in `~/.codify/approved-template-commands.json` keyed by a hash of the template sources and the full config. Declining throws `TemplateCommandsDeclinedError` (whole run stops). `-y` does not approve them; `--auto-approve-all` / `-yy` does. `Reporter.promptTemplateCommandApproval` must never auto approve (Json reporter throws, Stub declines).
209+
- `codify apply <template>` is a project with a single template declaration (the resolver's fetched template is seeded into the loader).
210+
- `import`/`refresh`/`edit` do not expand templates: template blocks are left untouched in files.
211+
191212
## Plugin Error Handling Architecture
192213

193214
Plugin errors flow as structured `PluginErrorData` over IPC and are caught as `PluginError` instances on the CLI side:

‎package-lock.json‎

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

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
},
66
"dependencies": {
77
"@codifycli/ink-form": "0.0.12",
8-
"@codifycli/schemas": "1.2.0",
8+
"@codifycli/schemas": "1.3.0",
99
"@homebridge/node-pty-prebuilt-multiarch": "npm:node-pty@1.2.0-beta.15",
1010
"@mischnic/json-sourcemap": "^0.1.1",
1111
"@oclif/core": "^4.0.8",

‎src/commands/apply.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Args, Flags } from '@oclif/core'
22
import chalk from 'chalk';
33

4+
import { autoApproveAllFlag, isAutoApproveAll } from '../common/approval-flags.js';
45
import { BaseCommand } from '../common/base-command.js';
56
import { ApplyOrchestrator } from '../orchestrators/apply.js';
67

@@ -16,6 +17,9 @@ any changes.
1617
For scripts: use ${chalk.bold.bgMagenta(' --output json ')} which will skip approval and
1718
apply changes directly.
1819
20+
Commands (actions) that come from templates always have to be approved, even with -y.
21+
Use ${chalk.bold.bgMagenta(' -yy ')} or ${chalk.bold.bgMagenta(' --auto-approve-all ')} to approve them as well.
22+
1923
The config can be a local file or directory, a public template name, or a Codify
2024
dashboard document id. With no argument, the codify file in the current directory is used.
2125
@@ -33,6 +37,7 @@ For more information, visit: https://codifycli.com/docs/commands/apply
3337
char: 'y',
3438
default: false,
3539
}),
40+
'auto-approve-all': autoApproveAllFlag,
3641
'verbose': Flags.boolean({
3742
char: 'v',
3843
description: 'Print plugin output (stdout/stderr) to the terminal.',
@@ -54,10 +59,11 @@ For more information, visit: https://codifycli.com/docs/commands/apply
5459
'<%= config.bin %> <%= command.id %> --path ~',
5560
'<%= config.bin %> <%= command.id %> -o json',
5661
'<%= config.bin %> <%= command.id %> -S <sudo password>',
62+
'<%= config.bin %> <%= command.id %> -yy',
5763
]
5864

5965
public async run(): Promise<void> {
60-
const { flags, args } = await this.parse(Apply)
66+
const { flags, args, raw } = await this.parse(Apply)
6167

6268
if (flags.path && args.pathArgs) {
6369
throw new Error('Cannot specify both --path and path argument');
@@ -67,6 +73,7 @@ For more information, visit: https://codifycli.com/docs/commands/apply
6773
path: flags.path ?? args.pathArgs,
6874
verbosityLevel: flags.debug || flags.verbose ? 3 : 0,
6975
autoApprove: flags.yes,
76+
autoApproveAll: isAutoApproveAll(flags, raw),
7077
allowSleep: flags['allow-sleep'],
7178
// secure: flags.secure,
7279
}, this.reporter);

‎src/commands/destroy.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import chalk from 'chalk';
33

44
import { BaseCommand } from '../common/base-command.js';
55
import { DestroyOrchestrator } from '../orchestrators/destroy.js';
6+
import { autoApproveAllFlag, isAutoApproveAll } from '../common/approval-flags.js';
67

78
export default class Destroy extends BaseCommand {
89
static strict = false;
@@ -38,6 +39,7 @@ For more information, visit: https://codifycli.com/docs/commands/destory`
3839
char: 'y',
3940
default: false,
4041
}),
42+
'auto-approve-all': autoApproveAllFlag,
4143
'verbose': Flags.boolean({
4244
char: 'v',
4345
description: 'Print plugin output (stdout/stderr) to the terminal.',
@@ -60,6 +62,7 @@ For more information, visit: https://codifycli.com/docs/commands/destory`
6062
typeIds: args,
6163
path: flags.path,
6264
autoApprove: flags.yes,
65+
autoApproveAll: isAutoApproveAll(flags, raw),
6366
allowSleep: flags['allow-sleep'],
6467
}, this.reporter)
6568

‎src/commands/plan.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { BaseCommand } from '../common/base-command.js';
33
import { PlanOrchestrator } from '../orchestrators/plan.js';
44
import { Args } from '@oclif/core';
55

6+
import { autoApproveAllFlag } from '../common/approval-flags.js';
7+
68
export default class Plan extends BaseCommand {
79
static description =
810
`Generate an execution plan to apply changes from a codify.jsonc file.
@@ -24,6 +26,10 @@ For more information, visit: https://codifycli.com/docs/commands/plan`
2426
'<%= config.bin %> <%= command.id %> <template name>',
2527
]
2628

29+
static flags = {
30+
'auto-approve-all': autoApproveAllFlag,
31+
}
32+
2733
static args = {
2834
pathArgs: Args.string({ description: 'Path to a codify file or directory, a template name, or a document id' }),
2935
}
@@ -42,7 +48,9 @@ For more information, visit: https://codifycli.com/docs/commands/plan`
4248
await PlanOrchestrator.run({
4349
verbosityLevel: flags.debug ? 3 : 0,
4450
path: flags.path ?? args.pathArgs,
45-
secureMode: flags.secure,
51+
// No --secure flag is declared on plan (kept as before)
52+
secureMode: (flags as { secure?: boolean }).secure,
53+
autoApproveTemplateCommands: flags['auto-approve-all'],
4654
}, this.reporter);
4755

4856
process.exit(0);

‎src/common/approval-flags.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Flags } from '@oclif/core';
2+
3+
/**
4+
* --auto-approve-all (or -yy) approves everything, including commands that come from templates. A single -y never
5+
* approves commands from templates.
6+
*/
7+
export const autoApproveAllFlag = Flags.boolean({
8+
description: 'Approve everything without prompting, including commands (actions) that come from templates. Same as -yy. Only use this with templates you trust.',
9+
default: false,
10+
});
11+
12+
/** True if --auto-approve-all was passed or -y was passed twice (-yy or -y -y). */
13+
export function isAutoApproveAll(
14+
flags: { 'auto-approve-all'?: boolean },
15+
raw: Array<{ flag?: string; type: string }>,
16+
): boolean {
17+
const yesCount = raw.filter((token) => token.type === 'flag' && token.flag === 'yes').length;
18+
return Boolean(flags['auto-approve-all']) || yesCount >= 2;
19+
}

‎src/common/errors.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,3 +358,29 @@ export class NoCodifyFilesError extends CodifyError {
358358
return `${this.message}\n\n${chalk.white(usage)}`;
359359
}
360360
}
361+
362+
export class TemplateError extends CodifyError {
363+
name = 'TemplateError'
364+
templateSource: string;
365+
366+
constructor(templateSource: string, message: string) {
367+
super(message);
368+
this.templateSource = templateSource;
369+
}
370+
371+
formattedMessage(): string {
372+
return `Template error (${this.templateSource}): ${this.message}`;
373+
}
374+
}
375+
376+
export class TemplateCommandsDeclinedError extends CodifyError {
377+
name = 'TemplateCommandsDeclinedError'
378+
379+
constructor() {
380+
super('Commands from templates were not approved. Nothing was changed.');
381+
}
382+
383+
formattedMessage(): string {
384+
return this.message;
385+
}
386+
}

‎src/common/initialize-plugins.ts‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { Config } from '@codifycli/schemas';
22

33
import { Project } from '../entities/project.js';
4+
import { TemplateConfig } from '../entities/template-config.js';
45
import { SubProcessName, ctx } from '../events/context.js';
56
import { CodifyParser } from '../parser/index.js';
67
import { CodifyResolver, type CodifySource, CodifySourceType } from '../parser/resolver.js';
78
import { PluginManager, ResourceDefinitionMap } from '../plugins/plugin-manager.js';
89
import { Reporter } from '../ui/reporters/reporter.js';
10+
import { templateExpander, templateLoader } from '../templates/template-expander.js';
11+
import { MergeWarning, TemplateResourceMerger } from '../templates/template-resource-merger.js';
912
import { ShellUtils } from '../utils/shell.js';
1013

1114
export interface InitializeArgs {
@@ -18,12 +21,16 @@ export interface InitializeArgs {
1821
codifyConfigs?: Config[];
1922
noProgress?: boolean;
2023
allowTemplates?: boolean;
24+
// Expand template declarations into their resources (plan, apply and destroy)
25+
expandTemplates?: boolean;
2126
}
2227

2328
export interface InitializationResult {
2429
resourceDefinitions: ResourceDefinitionMap
2530
pluginManager: PluginManager,
2631
project: Project,
32+
// Values that could not be merged when merging duplicate resources from templates
33+
templateMergeWarnings: MergeWarning[],
2734
}
2835

2936
export class PluginInitOrchestrator {
@@ -46,7 +53,13 @@ export class PluginInitOrchestrator {
4653
project.removeResourcesUsingOsFilter();
4754
await project.removeResourcesUsingDistroFilter();
4855

49-
return { resourceDefinitions, pluginManager, project };
56+
// Merging duplicates asks the plugins (match), so it runs after plugin initialization and after the os/distro
57+
// filters so that resources that don't apply to this machine are not merged.
58+
const templateMergeWarnings = project.templatesExpanded
59+
? await TemplateResourceMerger.merge(project, pluginManager, resourceDefinitions)
60+
: [];
61+
62+
return { resourceDefinitions, pluginManager, project, templateMergeWarnings };
5063
}
5164

5265
private static async parseProject(
@@ -58,7 +71,12 @@ export class PluginInitOrchestrator {
5871
}
5972

6073
if (args.codifyConfigs) {
61-
return CodifyParser.parseJson(args.codifyConfigs);
74+
const project = await CodifyParser.parseJson(args.codifyConfigs);
75+
if (args.expandTemplates) {
76+
await templateExpander.expand(project);
77+
}
78+
79+
return project;
6280
}
6381

6482
const source = await CodifyResolver.resolve({
@@ -70,6 +88,9 @@ export class PluginInitOrchestrator {
7088
ctx.subprocessStarted(SubProcessName.PARSE);
7189
const project = await PluginInitOrchestrator.parseSource(source);
7290
project.source = source;
91+
if (args.expandTemplates) {
92+
await templateExpander.expand(project);
93+
}
7394
ctx.subprocessFinished(SubProcessName.PARSE);
7495

7596
if (args.transformProject) {
@@ -90,7 +111,10 @@ export class PluginInitOrchestrator {
90111
}
91112

92113
case CodifySourceType.TEMPLATE: {
93-
return CodifyParser.parseTemplate(source.template);
114+
// `codify apply <template>` is a project with a single template declaration. The template was already fetched
115+
// by the resolver.
116+
templateLoader.seed(source.location, source.template);
117+
return Project.create([new TemplateConfig({ type: 'template', source: source.location })], []);
94118
}
95119

96120
default: {

‎src/entities/config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export enum ConfigType {
22
PROJECT = 'project',
33
RESOURCE = 'resource',
4+
TEMPLATE = 'template',
45
}
56

67

0 commit comments

Comments
 (0)