@celilo/cli 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -85,13 +85,6 @@
85
85
  "when": 1781481660000,
86
86
  "tag": "0011_backups_name",
87
87
  "breakpoints": true
88
- },
89
- {
90
- "idx": 12,
91
- "version": "6",
92
- "when": 1781481720000,
93
- "tag": "0012_module_systems_sizing",
94
- "breakpoints": true
95
88
  }
96
89
  ]
97
90
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,9 +52,9 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@aws-sdk/client-s3": "^3.1024.0",
55
- "@celilo/capabilities": "^0.5.0",
56
- "@celilo/cli-display": "^0.1.9",
57
- "@celilo/event-bus": "^0.1.7",
55
+ "@celilo/capabilities": "workspace:^",
56
+ "@celilo/cli-display": "workspace:^",
57
+ "@celilo/event-bus": "workspace:^",
58
58
  "@clack/prompts": "^1.1.0",
59
59
  "ajv": "^8.18.0",
60
60
  "drizzle-orm": "^0.36.4",
@@ -636,23 +636,6 @@ export class ProxmoxClient {
636
636
  if (!result.success) return result;
637
637
  return { success: true, data: findNodeForVmid(result.data, vmid) };
638
638
  }
639
-
640
- /**
641
- * Power-cycle a guest so PENDING config changes take effect (ISS-0150). A
642
- * cpu/memory change Terraform writes to the VM config is "pending" until the
643
- * QEMU process is recreated; `qm reboot` does a shutdown+start that applies it.
644
- * Returns the UPID and waits for the task to finish.
645
- */
646
- async rebootVm(node: string, vmid: number): Promise<ProxmoxResult<string>> {
647
- const res = await makeProxmoxPost<string>(
648
- this.credentials,
649
- `/nodes/${node}/qemu/${vmid}/status/reboot`,
650
- {},
651
- );
652
- if (!res.success) return res;
653
- await pollTaskUntilDone(this.credentials, node, res.data, 300_000);
654
- return res;
655
- }
656
639
  }
657
640
 
658
641
  /**
@@ -1090,7 +1090,7 @@ export const COMMANDS: CommandDef[] = [
1090
1090
  },
1091
1091
  {
1092
1092
  name: 'proxmox',
1093
- description: 'Proxmox cluster introspection (nodes, capacity, instance sizing)',
1093
+ description: 'Proxmox cluster introspection (nodes, capacity)',
1094
1094
  subcommands: [
1095
1095
  {
1096
1096
  name: 'node',
@@ -1103,70 +1103,6 @@ export const COMMANDS: CommandDef[] = [
1103
1103
  },
1104
1104
  ],
1105
1105
  },
1106
- {
1107
- name: 'vm',
1108
- description: 'celilo-provisioned VM sizing',
1109
- subcommands: [
1110
- {
1111
- name: 'list',
1112
- description: 'List celilo VMs with desired vs actual size',
1113
- args: [{ name: 'service-id', description: 'Proxmox service (optional if only one)' }],
1114
- },
1115
- {
1116
- name: 'resize',
1117
- description: 'Resize a celilo VM (canonical size + reconcile)',
1118
- args: [{ name: 'name', description: 'Instance name (module/hostname)' }],
1119
- flags: [
1120
- { name: 'memory', description: 'New RAM in MB', takesValue: true },
1121
- { name: 'cpu', description: 'New vCPU count', takesValue: true },
1122
- { name: 'force', description: 'Override the node capacity check', takesValue: false },
1123
- {
1124
- name: 'allow-reboot',
1125
- description: 'Approve the stop/start a resize needs',
1126
- takesValue: false,
1127
- },
1128
- { name: 'skip-backup', description: 'Skip the pre-resize backup', takesValue: false },
1129
- {
1130
- name: 'yes',
1131
- description: 'Auto-approve reboot + backup (headless)',
1132
- takesValue: false,
1133
- },
1134
- ],
1135
- },
1136
- ],
1137
- },
1138
- {
1139
- name: 'ct',
1140
- description: 'celilo-provisioned container (LXC) sizing',
1141
- subcommands: [
1142
- {
1143
- name: 'list',
1144
- description: 'List celilo containers with desired vs actual size',
1145
- args: [{ name: 'service-id', description: 'Proxmox service (optional if only one)' }],
1146
- },
1147
- {
1148
- name: 'resize',
1149
- description: 'Resize a celilo container (canonical size + reconcile)',
1150
- args: [{ name: 'name', description: 'Instance name (module/hostname)' }],
1151
- flags: [
1152
- { name: 'memory', description: 'New RAM in MB', takesValue: true },
1153
- { name: 'cpu', description: 'New vCPU count', takesValue: true },
1154
- { name: 'force', description: 'Override the node capacity check', takesValue: false },
1155
- {
1156
- name: 'allow-reboot',
1157
- description: 'Approve the stop/start a resize needs',
1158
- takesValue: false,
1159
- },
1160
- { name: 'skip-backup', description: 'Skip the pre-resize backup', takesValue: false },
1161
- {
1162
- name: 'yes',
1163
- description: 'Auto-approve reboot + backup (headless)',
1164
- takesValue: false,
1165
- },
1166
- ],
1167
- },
1168
- ],
1169
- },
1170
1106
  ],
1171
1107
  },
1172
1108
  {
@@ -0,0 +1,52 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { parseChangeset } from '../../module/versioning/changeset-version';
6
+ import { handleModuleChangeset } from './module-changeset';
7
+
8
+ let dir: string;
9
+ beforeEach(async () => {
10
+ dir = await mkdtemp(join(tmpdir(), 'celilo-cs-'));
11
+ await writeFile(
12
+ join(dir, 'manifest.yml'),
13
+ 'celilo_contract: "1.0"\nid: lunacycle\nversion: 1.0.0\n',
14
+ );
15
+ });
16
+ afterEach(async () => {
17
+ await rm(dir, { recursive: true, force: true });
18
+ });
19
+
20
+ describe('handleModuleChangeset', () => {
21
+ it('writes a changeset keyed by the module id that the engine can parse', async () => {
22
+ const res = await handleModuleChangeset([dir], { bump: 'minor', message: 'Added a view.' });
23
+ expect(res.success).toBe(true);
24
+
25
+ const files = (await readdir(join(dir, '.changeset'))).filter((f) => f.endsWith('.md'));
26
+ expect(files.length).toBe(1);
27
+ const parsed = parseChangeset(await readFile(join(dir, '.changeset', files[0]), 'utf-8'));
28
+ expect(parsed.bumps).toEqual({ lunacycle: 'minor' });
29
+ expect(parsed.summary).toBe('Added a view.');
30
+ });
31
+
32
+ it('honors an explicit --name and defaults the body', async () => {
33
+ await handleModuleChangeset([dir], { bump: 'patch', name: 'fix-thing' });
34
+ const parsed = parseChangeset(await readFile(join(dir, '.changeset', 'fix-thing.md'), 'utf-8'));
35
+ expect(parsed.bumps).toEqual({ lunacycle: 'patch' });
36
+ expect(parsed.summary.length).toBeGreaterThan(0);
37
+ });
38
+
39
+ it('rejects a missing or invalid bump', async () => {
40
+ expect((await handleModuleChangeset([dir], {})).success).toBe(false);
41
+ expect((await handleModuleChangeset([dir], { bump: 'huge' })).success).toBe(false);
42
+ });
43
+
44
+ it('errors when the manifest is unreadable', async () => {
45
+ const empty = await mkdtemp(join(tmpdir(), 'celilo-cs-empty-'));
46
+ try {
47
+ expect((await handleModuleChangeset([empty], { bump: 'patch' })).success).toBe(false);
48
+ } finally {
49
+ await rm(empty, { recursive: true, force: true });
50
+ }
51
+ });
52
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `celilo module changeset [<module-dir>] --bump <major|minor|patch> [-m <msg>]`
3
+ * — author a changeset for a module (ISS-0151 / v2/MODULE_VERSIONING.md).
4
+ *
5
+ * Writes `.changeset/<name>.md` keyed by the MODULE id (Option A), the same
6
+ * on-disk format `celilo module version` consumes. This is the thin authoring
7
+ * helper that stands in for `bunx changeset add` (which is npm-package-centric
8
+ * and can't target a celilo module id).
9
+ */
10
+
11
+ import { mkdir, writeFile } from 'node:fs/promises';
12
+ import { join, resolve } from 'node:path';
13
+ import { parse as parseYaml } from 'yaml';
14
+ import { isBumpType } from '../../module/versioning/changeset-version';
15
+ import { getFlag } from '../parser';
16
+ import type { CommandResult } from '../types';
17
+
18
+ /** A short, collision-resistant, filename-safe slug for the changeset file. */
19
+ function changesetName(bump: string): string {
20
+ const rand = crypto.randomUUID().replace(/-/g, '').slice(0, 10);
21
+ return `${bump}-${rand}`;
22
+ }
23
+
24
+ export async function handleModuleChangeset(
25
+ args: string[],
26
+ flags: Record<string, string | boolean>,
27
+ ): Promise<CommandResult> {
28
+ const moduleDir = args[0] ?? '.';
29
+ const dir = resolve(moduleDir);
30
+
31
+ const bump = getFlag(flags, 'bump', '');
32
+ if (!isBumpType(bump)) {
33
+ return {
34
+ success: false,
35
+ error: 'A bump is required: --bump <major|minor|patch>',
36
+ };
37
+ }
38
+ const message = getFlag(flags, 'message', '');
39
+
40
+ let id: string;
41
+ try {
42
+ const manifest = parseYaml(await Bun.file(join(dir, 'manifest.yml')).text()) as { id?: string };
43
+ if (!manifest.id) {
44
+ return { success: false, error: `${moduleDir}: manifest.yml missing id` };
45
+ }
46
+ id = manifest.id;
47
+ } catch {
48
+ return { success: false, error: `${moduleDir}: could not read manifest.yml in ${dir}` };
49
+ }
50
+
51
+ const name = getFlag(flags, 'name', '') || changesetName(bump);
52
+ const body = message.trim() || `${bump} change to ${id}.`;
53
+ const content = `---\n"${id}": ${bump}\n---\n\n${body}\n`;
54
+
55
+ const changesetDir = join(dir, '.changeset');
56
+ await mkdir(changesetDir, { recursive: true });
57
+ const file = join(changesetDir, `${name}.md`);
58
+ await writeFile(file, content);
59
+
60
+ return {
61
+ success: true,
62
+ message: `Wrote .changeset/${name}.md (${id}: ${bump}).`,
63
+ data: { id, bump, file },
64
+ };
65
+ }
@@ -61,6 +61,7 @@ interface ManifestForPublish extends ManifestForCapabilityCheck {
61
61
  id: string;
62
62
  version: string;
63
63
  description?: string;
64
+ version_source?: { kind?: string };
64
65
  }
65
66
 
66
67
  /**
@@ -164,7 +165,15 @@ export async function publishOneModule(
164
165
 
165
166
  // Stale-check: src commits past the last manifest.yml commit. Only fires
166
167
  // when the dir is in git history; brand-new uncommitted modules pass.
167
- if (!opts.allowStale) {
168
+ //
169
+ // Skipped for changeset/pin modules (ISS-0151 / v2/MODULE_VERSIONING.md): a
170
+ // changeset module's version is authored via .changeset/ + `celilo module
171
+ // version` and ordered by +N, so source-after-manifest is normal, not drift;
172
+ // a pin module's version is checked against its upstream resolver. The gate
173
+ // stays for hand-maintained (recipe/unset) modules — the default.
174
+ const versionKind = manifest.version_source?.kind;
175
+ const staleGateApplies = versionKind !== 'changeset' && versionKind !== 'pin';
176
+ if (!opts.allowStale && staleGateApplies) {
168
177
  const stale = checkModuleStale(resolvedDir);
169
178
  if (stale) {
170
179
  return {
@@ -0,0 +1,118 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { handleModuleVersion } from './module-version';
6
+
7
+ let dir: string;
8
+
9
+ const MANIFEST = (kind: string | null) =>
10
+ [
11
+ 'celilo_contract: "1.0"',
12
+ 'id: lunacycle',
13
+ 'name: LunaCycle',
14
+ 'version: 1.0.1 # keep this comment',
15
+ ...(kind ? ['version_source:', ` kind: ${kind}`] : []),
16
+ 'description: an app',
17
+ ].join('\n');
18
+
19
+ async function seed(kind: string | null, changesets: Record<string, string> = {}) {
20
+ await writeFile(join(dir, 'manifest.yml'), MANIFEST(kind));
21
+ await mkdir(join(dir, '.changeset'), { recursive: true });
22
+ await writeFile(join(dir, '.changeset', 'README.md'), '# Changesets\n');
23
+ for (const [name, body] of Object.entries(changesets)) {
24
+ await writeFile(join(dir, '.changeset', name), body);
25
+ }
26
+ }
27
+
28
+ beforeEach(async () => {
29
+ dir = await mkdtemp(join(tmpdir(), 'celilo-modver-'));
30
+ });
31
+ afterEach(async () => {
32
+ await rm(dir, { recursive: true, force: true });
33
+ });
34
+
35
+ describe('handleModuleVersion (changeset kind)', () => {
36
+ it('bumps the manifest version, writes CHANGELOG, consumes changesets', async () => {
37
+ await seed('changeset', {
38
+ 'a.md': '---\n"lunacycle": minor\n---\n\nAdded tasks view.\n',
39
+ 'b.md': '---\n"lunacycle": patch\n---\n\nFixed a typo.\n',
40
+ });
41
+
42
+ const res = await handleModuleVersion([dir], {});
43
+ expect(res.success).toBe(true);
44
+
45
+ const manifest = await readFile(join(dir, 'manifest.yml'), 'utf-8');
46
+ expect(manifest).toContain('version: 1.1.0'); // max(minor,patch) = minor
47
+ expect(manifest).toContain('# keep this comment'); // comment preserved? (line replaced, but others intact)
48
+ expect(manifest).toContain('kind: changeset');
49
+
50
+ const changelog = await readFile(join(dir, 'CHANGELOG.md'), 'utf-8');
51
+ expect(changelog).toContain('# lunacycle');
52
+ expect(changelog).toContain('## 1.1.0');
53
+ expect(changelog).toContain('### Minor Changes');
54
+ expect(changelog).toContain('- Added tasks view.');
55
+
56
+ const remaining = (await readdir(join(dir, '.changeset'))).filter((f) => f !== 'README.md');
57
+ expect(remaining).toEqual([]); // both consumed
58
+ });
59
+
60
+ it('prepends newest-on-top to an existing CHANGELOG', async () => {
61
+ await seed('changeset', { 'a.md': '---\nlunacycle: patch\n---\nfix\n' });
62
+ await writeFile(
63
+ join(dir, 'CHANGELOG.md'),
64
+ '# lunacycle\n\n## 1.0.1\n\n### Patch Changes\n\n- old\n',
65
+ );
66
+
67
+ await handleModuleVersion([dir], {});
68
+ const changelog = await readFile(join(dir, 'CHANGELOG.md'), 'utf-8');
69
+ expect(changelog.indexOf('## 1.0.2')).toBeLessThan(changelog.indexOf('## 1.0.1'));
70
+ expect(changelog.split('# lunacycle').length).toBe(2); // header not duplicated
71
+ });
72
+
73
+ it('is a clean no-op when no changeset targets the module', async () => {
74
+ await seed('changeset', { 'a.md': '---\nnigredo: minor\n---\nother app\n' });
75
+ const res = await handleModuleVersion([dir], {});
76
+ expect(res.success).toBe(true);
77
+ const manifest = await readFile(join(dir, 'manifest.yml'), 'utf-8');
78
+ expect(manifest).toContain('version: 1.0.1'); // unchanged
79
+ // the non-targeting changeset is left in place
80
+ expect((await readdir(join(dir, '.changeset'))).includes('a.md')).toBe(true);
81
+ });
82
+
83
+ it('is a clean no-op with no changesets at all', async () => {
84
+ await seed('changeset', {});
85
+ const res = await handleModuleVersion([dir], {});
86
+ expect(res.success).toBe(true);
87
+ expect(await readFile(join(dir, 'manifest.yml'), 'utf-8')).toContain('version: 1.0.1');
88
+ });
89
+
90
+ it('fails loudly on a malformed changeset', async () => {
91
+ await seed('changeset', { 'bad.md': 'no frontmatter here' });
92
+ const res = await handleModuleVersion([dir], {});
93
+ expect(res.success).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe('handleModuleVersion (other kinds)', () => {
98
+ it('recipe kind is a no-op', async () => {
99
+ await seed('recipe', { 'a.md': '---\nlunacycle: minor\n---\nx\n' });
100
+ const res = await handleModuleVersion([dir], {});
101
+ expect(res.success).toBe(true);
102
+ expect(await readFile(join(dir, 'manifest.yml'), 'utf-8')).toContain('version: 1.0.1');
103
+ });
104
+
105
+ it('absent version_source defaults to recipe (no-op)', async () => {
106
+ await seed(null, { 'a.md': '---\nlunacycle: minor\n---\nx\n' });
107
+ const res = await handleModuleVersion([dir], {});
108
+ expect(res.success).toBe(true);
109
+ expect(await readFile(join(dir, 'manifest.yml'), 'utf-8')).toContain('version: 1.0.1');
110
+ });
111
+
112
+ it('pin kind is a not-yet-implemented notice (no change)', async () => {
113
+ await seed('pin', {});
114
+ const res = await handleModuleVersion([dir], {});
115
+ expect(res.success).toBe(true);
116
+ expect(res.success && res.message).toContain('pin');
117
+ });
118
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `celilo module version [<module-dir>]` — stamp a changeset-kind module's
3
+ * payload version into manifest.yml (ISS-0151 / v2/MODULE_VERSIONING.md).
4
+ *
5
+ * Reads `.changeset/*.md` (keyed by the MODULE id — Option A), computes the next
6
+ * `manifest.yml#version`, prepends a CHANGELOG entry, and deletes the consumed
7
+ * changesets. It only edits the working tree — the caller (release.yml's version
8
+ * phase) commits the result and opens the "Version Packages" PR.
9
+ *
10
+ * Only `version_source.kind: changeset` is handled here. `pin` (wrapper modules)
11
+ * is a no-op-with-notice for now; `recipe` (and an absent block) has no
12
+ * changeset-driven version and is a clean no-op.
13
+ */
14
+
15
+ import { readFile, readdir, unlink, writeFile } from 'node:fs/promises';
16
+ import { join, resolve } from 'node:path';
17
+ import { parse as parseYaml } from 'yaml';
18
+ import {
19
+ parseChangeset,
20
+ planModuleVersion,
21
+ renderChangelogSection,
22
+ } from '../../module/versioning/changeset-version';
23
+ import type { CommandResult } from '../types';
24
+
25
+ /** Replace the top-level `version:` line (column 0 — not nested capability versions). */
26
+ function rewriteManifestVersion(raw: string, next: string): string {
27
+ if (!/^version:[ \t]*\S.*$/m.test(raw)) {
28
+ throw new Error('manifest.yml has no top-level `version:` line to rewrite');
29
+ }
30
+ // Replace only the value; keep the `version:` key prefix and any trailing
31
+ // comment so a targeted edit preserves the rest of the line.
32
+ return raw.replace(/^(version:[ \t]*)\S+(.*)$/m, `$1${next}$2`);
33
+ }
34
+
35
+ /** Prepend a new section below the `# <id>` header (creating the file if needed). */
36
+ function updateChangelog(existing: string | null, moduleId: string, section: string): string {
37
+ const headerLine = `# ${moduleId}`;
38
+ if (existing?.startsWith(headerLine)) {
39
+ const rest = existing.slice(headerLine.length).replace(/^\n+/, '');
40
+ return `${headerLine}\n\n${section}\n${rest}`;
41
+ }
42
+ const tail = existing ? `\n${existing.trimStart()}` : '';
43
+ return `${headerLine}\n\n${section}${tail}`;
44
+ }
45
+
46
+ export async function handleModuleVersion(
47
+ args: string[],
48
+ _flags: Record<string, string | boolean>,
49
+ ): Promise<CommandResult> {
50
+ const moduleDir = args[0] ?? '.';
51
+ const dir = resolve(moduleDir);
52
+
53
+ let manifestRaw: string;
54
+ let id: string;
55
+ let version: string;
56
+ let kind: string;
57
+ try {
58
+ manifestRaw = await readFile(join(dir, 'manifest.yml'), 'utf-8');
59
+ const manifest = parseYaml(manifestRaw) as {
60
+ id?: string;
61
+ version?: string;
62
+ version_source?: { kind?: string };
63
+ };
64
+ if (!manifest.id || !manifest.version) {
65
+ return { success: false, error: `${moduleDir}: manifest.yml missing id or version` };
66
+ }
67
+ id = manifest.id;
68
+ version = manifest.version;
69
+ kind = manifest.version_source?.kind ?? 'recipe';
70
+ } catch {
71
+ return { success: false, error: `${moduleDir}: could not read manifest.yml in ${dir}` };
72
+ }
73
+
74
+ if (kind === 'pin') {
75
+ return {
76
+ success: true,
77
+ message: `${id}: version_source.kind=pin — resolver-based stamping not yet implemented (ISS-0151); leaving version ${version} unchanged.`,
78
+ };
79
+ }
80
+ if (kind !== 'changeset') {
81
+ return {
82
+ success: true,
83
+ message: `${id}: version_source.kind=${kind} — no changeset-driven version; ordered by the +N revision. Nothing to do.`,
84
+ };
85
+ }
86
+
87
+ // changeset kind: read .changeset/*.md (excluding README.md).
88
+ const changesetDir = join(dir, '.changeset');
89
+ let files: string[];
90
+ try {
91
+ files = (await readdir(changesetDir)).filter((f) => f.endsWith('.md') && f !== 'README.md');
92
+ } catch {
93
+ files = [];
94
+ }
95
+ if (files.length === 0) {
96
+ return { success: true, message: `${id}: no changesets — nothing to version (${version}).` };
97
+ }
98
+
99
+ // Parse, remembering which file each came from so we only delete consumed ones.
100
+ const parsed: { file: string; targetsModule: boolean; cs: ReturnType<typeof parseChangeset> }[] =
101
+ [];
102
+ for (const file of files) {
103
+ const content = await readFile(join(changesetDir, file), 'utf-8');
104
+ let cs: ReturnType<typeof parseChangeset>;
105
+ try {
106
+ cs = parseChangeset(content);
107
+ } catch (e) {
108
+ return {
109
+ success: false,
110
+ error: `${id}: malformed changeset ${file}: ${e instanceof Error ? e.message : String(e)}`,
111
+ };
112
+ }
113
+ parsed.push({ file, cs, targetsModule: id in cs.bumps });
114
+ }
115
+
116
+ const plan = planModuleVersion(
117
+ version,
118
+ id,
119
+ parsed.map((p) => p.cs),
120
+ );
121
+ if (!plan) {
122
+ return {
123
+ success: true,
124
+ message: `${id}: ${files.length} changeset(s) present but none target "${id}" — nothing to version (${version}).`,
125
+ };
126
+ }
127
+
128
+ // 1. Stamp the new version into manifest.yml (targeted line edit — comments preserved).
129
+ await writeFile(join(dir, 'manifest.yml'), rewriteManifestVersion(manifestRaw, plan.next));
130
+
131
+ // 2. Prepend the CHANGELOG section.
132
+ const changelogPath = join(dir, 'CHANGELOG.md');
133
+ let existingChangelog: string | null = null;
134
+ try {
135
+ existingChangelog = await readFile(changelogPath, 'utf-8');
136
+ } catch {
137
+ existingChangelog = null;
138
+ }
139
+ await writeFile(
140
+ changelogPath,
141
+ updateChangelog(existingChangelog, id, renderChangelogSection(plan)),
142
+ );
143
+
144
+ // 3. Consume the changesets that targeted this module.
145
+ const consumed = parsed.filter((p) => p.targetsModule);
146
+ for (const p of consumed) {
147
+ await unlink(join(changesetDir, p.file));
148
+ }
149
+
150
+ return {
151
+ success: true,
152
+ message: `${id}: ${plan.current} → ${plan.next} (${plan.bump}); consumed ${consumed.length} changeset(s), wrote CHANGELOG.md.`,
153
+ data: { id, from: plan.current, to: plan.next, bump: plan.bump },
154
+ };
155
+ }
@@ -11,7 +11,6 @@ import { ProxmoxClient, type ProxmoxCredentials } from '../../api-clients/proxmo
11
11
  import { getServiceCredentials, listContainerServices } from '../../services/container-service';
12
12
  import { celiloIntro } from '../prompts';
13
13
  import type { CommandResult } from '../types';
14
- import { resolveProxmoxService } from './proxmox-service';
15
14
 
16
15
  function formatUptime(sec: number): string {
17
16
  if (sec <= 0) return '—';
@@ -20,6 +19,40 @@ function formatUptime(sec: number): string {
20
19
  return days > 0 ? `${days}d${hours}h` : `${hours}h`;
21
20
  }
22
21
 
22
+ /**
23
+ * Resolve which Proxmox service to introspect: an explicit service-id arg, else
24
+ * the sole Proxmox service. Returns an error message when the choice is
25
+ * ambiguous or the named service doesn't exist.
26
+ */
27
+ function resolveProxmoxService(
28
+ services: Awaited<ReturnType<typeof listContainerServices>>,
29
+ requested: string | undefined,
30
+ ): { service: (typeof services)[number] } | { error: string } {
31
+ const proxmox = services.filter((s) => s.providerName === 'proxmox');
32
+ if (proxmox.length === 0) {
33
+ return {
34
+ error: 'No Proxmox container service configured. Add one: celilo service add proxmox',
35
+ };
36
+ }
37
+ if (requested) {
38
+ const match = proxmox.find((s) => s.serviceId === requested);
39
+ if (!match) {
40
+ return {
41
+ error: `No Proxmox service '${requested}'. Known: ${proxmox.map((s) => s.serviceId).join(', ')}`,
42
+ };
43
+ }
44
+ return { service: match };
45
+ }
46
+ if (proxmox.length > 1) {
47
+ return {
48
+ error: `Multiple Proxmox services — specify one: celilo proxmox node list <service-id>\n ${proxmox
49
+ .map((s) => s.serviceId)
50
+ .join('\n ')}`,
51
+ };
52
+ }
53
+ return { service: proxmox[0] };
54
+ }
55
+
23
56
  export async function handleProxmoxNodeList(
24
57
  args: string[],
25
58
  _flags: Record<string, boolean | string> = {},
@@ -248,21 +248,13 @@ export async function getCompletions(words: string[], current: number): Promise<
248
248
  // Service subcommands
249
249
  // Proxmox subcommands
250
250
  if (command === 'proxmox' && currentIndex === 1) {
251
- return filterSuggestions(['node', 'vm', 'ct'], args[1] || '');
251
+ return filterSuggestions(['node'], args[1] || '');
252
252
  }
253
253
  if (command === 'proxmox' && args[1] === 'node' && currentIndex === 2) {
254
254
  return filterSuggestions(['list'], args[2] || '');
255
255
  }
256
- if (command === 'proxmox' && (args[1] === 'vm' || args[1] === 'ct') && currentIndex === 2) {
257
- return filterSuggestions(['list', 'resize'], args[2] || '');
258
- }
259
- // proxmox <node|vm|ct> list <service-id> — proxmox services only
260
- if (
261
- command === 'proxmox' &&
262
- (args[1] === 'node' || args[1] === 'vm' || args[1] === 'ct') &&
263
- args[2] === 'list' &&
264
- currentIndex === 3
265
- ) {
256
+ // proxmox node list <service-id> proxmox services only
257
+ if (command === 'proxmox' && args[1] === 'node' && args[2] === 'list' && currentIndex === 3) {
266
258
  const services = await listContainerServices();
267
259
  const serviceIds = services.filter((s) => s.providerName === 'proxmox').map((s) => s.serviceId);
268
260
  return filterSuggestions(serviceIds, args[3] || '');