@openpalm/lib 0.9.8 → 0.10.1

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.
Files changed (56) hide show
  1. package/README.md +31 -71
  2. package/package.json +1 -1
  3. package/src/control-plane/audit.ts +4 -4
  4. package/src/control-plane/backup.ts +31 -0
  5. package/src/control-plane/channels.ts +88 -156
  6. package/src/control-plane/cleanup-guardrails.test.ts +289 -0
  7. package/src/control-plane/compose-args.test.ts +170 -0
  8. package/src/control-plane/compose-args.ts +57 -0
  9. package/src/control-plane/config-persistence.ts +270 -0
  10. package/src/control-plane/core-assets.ts +58 -234
  11. package/src/control-plane/crypto.ts +14 -0
  12. package/src/control-plane/docker.ts +94 -204
  13. package/src/control-plane/env-schema-validation.test.ts +118 -0
  14. package/src/control-plane/extends-support.test.ts +105 -0
  15. package/src/control-plane/home.ts +133 -0
  16. package/src/control-plane/install-edge-cases.test.ts +314 -717
  17. package/src/control-plane/lifecycle.ts +215 -233
  18. package/src/control-plane/lock.test.ts +194 -0
  19. package/src/control-plane/lock.ts +176 -0
  20. package/src/control-plane/memory-config.ts +34 -160
  21. package/src/control-plane/opencode-client.test.ts +154 -0
  22. package/src/control-plane/opencode-client.ts +113 -0
  23. package/src/control-plane/provider-config.ts +34 -0
  24. package/src/control-plane/redact-schema.ts +50 -0
  25. package/src/control-plane/registry-components.test.ts +313 -0
  26. package/src/control-plane/registry.test.ts +414 -0
  27. package/src/control-plane/registry.ts +418 -0
  28. package/src/control-plane/rollback.ts +128 -0
  29. package/src/control-plane/scheduler.ts +18 -190
  30. package/src/control-plane/secret-backend.test.ts +359 -0
  31. package/src/control-plane/secret-backend.ts +322 -0
  32. package/src/control-plane/secret-mappings.ts +185 -0
  33. package/src/control-plane/secrets.ts +186 -112
  34. package/src/control-plane/setup-config.schema.json +306 -0
  35. package/src/control-plane/setup-status.ts +15 -8
  36. package/src/control-plane/setup-validation.ts +90 -0
  37. package/src/control-plane/setup.test.ts +336 -929
  38. package/src/control-plane/setup.ts +159 -849
  39. package/src/control-plane/spec-to-env.test.ts +100 -0
  40. package/src/control-plane/spec-to-env.ts +195 -0
  41. package/src/control-plane/spec-validator.ts +159 -0
  42. package/src/control-plane/stack-spec.test.ts +150 -0
  43. package/src/control-plane/stack-spec.ts +101 -22
  44. package/src/control-plane/types.ts +6 -99
  45. package/src/control-plane/validate.ts +107 -0
  46. package/src/index.ts +101 -159
  47. package/src/provider-constants.ts +2 -31
  48. package/src/control-plane/connection-mapping.ts +0 -191
  49. package/src/control-plane/connection-migration-flags.ts +0 -40
  50. package/src/control-plane/connection-profiles.ts +0 -317
  51. package/src/control-plane/core-asset-provider.ts +0 -21
  52. package/src/control-plane/fs-asset-provider.ts +0 -65
  53. package/src/control-plane/fs-registry-provider.ts +0 -46
  54. package/src/control-plane/paths.ts +0 -77
  55. package/src/control-plane/registry-provider.ts +0 -19
  56. package/src/control-plane/staging.ts +0 -399
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Registry catalog discovery and refresh.
3
+ *
4
+ * `OP_HOME/registry` is the only persistent catalog location.
5
+ * Install seeds it once; refresh replaces it explicitly.
6
+ */
7
+ import { cpSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { execFileSync } from 'node:child_process';
9
+ import { join } from 'node:path';
10
+ import { tmpdir } from 'node:os';
11
+ import { parse as parseYaml } from 'yaml';
12
+ import { createLogger } from '../logger.js';
13
+ import { isChannelAddon } from './channels.js';
14
+ import { randomHex, writeChannelSecrets } from './config-persistence.js';
15
+ import {
16
+ resolveRegistryAddonsDir,
17
+ resolveRegistryAutomationsDir,
18
+ resolveRegistryDir,
19
+ } from './home.js';
20
+
21
+ const BRANCH_RE = /^[a-zA-Z0-9._\/-]+$/;
22
+ const URL_RE = /^(https:\/\/|git@)/;
23
+ const VALID_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
24
+ const logger = createLogger('registry');
25
+
26
+ let warnedMissingRegistryAddonsDir = false;
27
+
28
+ export function validateBranch(branch: string): string {
29
+ const normalized = branch.trim();
30
+ if (!BRANCH_RE.test(normalized)) throw new Error(`Invalid registry branch name: ${branch}`);
31
+ if (normalized.includes('..')) throw new Error(`Invalid registry branch name (contains '..'): ${branch}`);
32
+ return normalized;
33
+ }
34
+
35
+ export function validateRegistryUrl(url: string): string {
36
+ const normalized = url.trim();
37
+ if (!normalized.startsWith('/') && !URL_RE.test(normalized)) {
38
+ throw new Error(`Invalid registry URL: ${url}`);
39
+ }
40
+ return normalized;
41
+ }
42
+
43
+ export function isValidComponentName(name: string): boolean {
44
+ return VALID_NAME_RE.test(name);
45
+ }
46
+
47
+ const DEFAULT_REPO = 'itlackey/openpalm';
48
+
49
+ export interface RegistryConfig {
50
+ repoUrl: string;
51
+ branch: string;
52
+ }
53
+
54
+ export function getRegistryConfig(): RegistryConfig {
55
+ return {
56
+ repoUrl: validateRegistryUrl(process.env.OP_REGISTRY_URL ?? `https://github.com/${DEFAULT_REPO}.git`),
57
+ branch: validateBranch(process.env.OP_REGISTRY_BRANCH ?? 'main'),
58
+ };
59
+ }
60
+
61
+ export type RegistryAutomationEntry = {
62
+ name: string;
63
+ type: 'automation';
64
+ description: string;
65
+ schedule: string;
66
+ ymlContent: string;
67
+ };
68
+
69
+ export type RegistryComponentEntry = {
70
+ compose: string;
71
+ schema: string;
72
+ };
73
+
74
+ export type RegistryAddonConfig = {
75
+ schemaPath: string;
76
+ userEnvPath: string;
77
+ envSchema: string;
78
+ };
79
+
80
+ export type RegistryCatalogVerification = {
81
+ root: string;
82
+ addonCount: number;
83
+ automationCount: number;
84
+ };
85
+
86
+ export type MutationResult = { ok: true } | { ok: false; error: string };
87
+ export type AddonMutationResult = (
88
+ | { ok: true; enabled: boolean; changed: boolean; services: string[] }
89
+ | { ok: false; error: string }
90
+ );
91
+
92
+ function countValidAddons(rootDir: string): number {
93
+ const addonsDir = join(rootDir, 'addons');
94
+ if (!existsSync(addonsDir)) return 0;
95
+ return readdirSync(addonsDir, { withFileTypes: true }).filter((entry) => {
96
+ if (!entry.isDirectory() || !isValidComponentName(entry.name)) return false;
97
+ const addonDir = join(addonsDir, entry.name);
98
+ return existsSync(join(addonDir, 'compose.yml')) && existsSync(join(addonDir, '.env.schema'));
99
+ }).length;
100
+ }
101
+
102
+ function countValidAutomations(rootDir: string): number {
103
+ const automationsDir = join(rootDir, 'automations');
104
+ if (!existsSync(automationsDir)) return 0;
105
+ return readdirSync(automationsDir).filter((file) => {
106
+ if (!file.endsWith('.yml')) return false;
107
+ return isValidComponentName(file.replace(/\.yml$/, ''));
108
+ }).length;
109
+ }
110
+
111
+ export function verifyRegistryCatalog(rootDir = resolveRegistryDir()): RegistryCatalogVerification {
112
+ const addonCount = countValidAddons(rootDir);
113
+ const automationCount = countValidAutomations(rootDir);
114
+
115
+ if (addonCount === 0) throw new Error('Registry catalog is incomplete: missing valid addons');
116
+ if (automationCount === 0) throw new Error('Registry catalog is incomplete: missing valid automations');
117
+
118
+ return {
119
+ root: rootDir,
120
+ addonCount,
121
+ automationCount,
122
+ };
123
+ }
124
+
125
+ export function materializeRegistryCatalog(sourceRoot: string): string {
126
+ const sourceAddonsDir = join(sourceRoot, '.openpalm', 'registry', 'addons');
127
+ const sourceAutomationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
128
+ const tempRoot = mkdtempSync(join(tmpdir(), 'openpalm-registry-materialize-'));
129
+
130
+ try {
131
+ const tempAddonsDir = join(tempRoot, 'addons');
132
+ const tempAutomationsDir = join(tempRoot, 'automations');
133
+ mkdirSync(tempAddonsDir, { recursive: true });
134
+ mkdirSync(tempAutomationsDir, { recursive: true });
135
+
136
+ if (existsSync(sourceAddonsDir)) cpSync(sourceAddonsDir, tempAddonsDir, { recursive: true });
137
+ if (existsSync(sourceAutomationsDir)) cpSync(sourceAutomationsDir, tempAutomationsDir, { recursive: true });
138
+
139
+ verifyRegistryCatalog(tempRoot);
140
+
141
+ rmSync(resolveRegistryDir(), { recursive: true, force: true });
142
+ mkdirSync(resolveRegistryDir(), { recursive: true });
143
+ cpSync(tempAddonsDir, resolveRegistryAddonsDir(), { recursive: true });
144
+ cpSync(tempAutomationsDir, resolveRegistryAutomationsDir(), { recursive: true });
145
+ return resolveRegistryDir();
146
+ } finally {
147
+ rmSync(tempRoot, { recursive: true, force: true });
148
+ }
149
+ }
150
+
151
+ export function refreshRegistryCatalog(config?: RegistryConfig): RegistryCatalogVerification {
152
+ const raw = config ?? getRegistryConfig();
153
+ const repoUrl = validateRegistryUrl(raw.repoUrl);
154
+ const branch = validateBranch(raw.branch);
155
+ const cloneDir = mkdtempSync(join(tmpdir(), 'openpalm-registry-refresh-'));
156
+
157
+ try {
158
+ execFileSync(
159
+ 'git',
160
+ ['clone', '--depth', '1', '--filter=blob:none', '--sparse', '--branch', branch, repoUrl, '.'],
161
+ { cwd: cloneDir, stdio: 'pipe', timeout: 60_000 },
162
+ );
163
+ execFileSync('git', ['sparse-checkout', 'set', '.openpalm'], {
164
+ cwd: cloneDir,
165
+ stdio: 'pipe',
166
+ timeout: 30_000,
167
+ });
168
+ const root = materializeRegistryCatalog(cloneDir);
169
+ return verifyRegistryCatalog(root);
170
+ } catch (err) {
171
+ const msg = err instanceof Error ? err.message : String(err);
172
+ throw new Error(`Failed to refresh registry from ${repoUrl}: ${msg}`);
173
+ } finally {
174
+ rmSync(cloneDir, { recursive: true, force: true });
175
+ }
176
+ }
177
+
178
+ export function discoverRegistryComponents(): Record<string, RegistryComponentEntry> {
179
+ const addonsDir = resolveRegistryAddonsDir();
180
+ if (!existsSync(addonsDir)) return {};
181
+
182
+ const result: Record<string, RegistryComponentEntry> = {};
183
+ for (const entry of readdirSync(addonsDir, { withFileTypes: true })) {
184
+ if (!entry.isDirectory() || !VALID_NAME_RE.test(entry.name)) continue;
185
+ const addonDir = join(addonsDir, entry.name);
186
+ const composeFile = join(addonDir, 'compose.yml');
187
+ const schemaFile = join(addonDir, '.env.schema');
188
+ if (!existsSync(composeFile) || !existsSync(schemaFile)) continue;
189
+
190
+ result[entry.name] = {
191
+ compose: readFileSync(composeFile, 'utf-8'),
192
+ schema: readFileSync(schemaFile, 'utf-8'),
193
+ };
194
+ }
195
+
196
+ return result;
197
+ }
198
+
199
+ export function discoverRegistryAutomations(): RegistryAutomationEntry[] {
200
+ const automationsDir = resolveRegistryAutomationsDir();
201
+ if (!existsSync(automationsDir)) return [];
202
+
203
+ return readdirSync(automationsDir)
204
+ .filter((file) => file.endsWith('.yml'))
205
+ .map((file) => {
206
+ const name = file.replace(/\.yml$/, '');
207
+ if (!VALID_NAME_RE.test(name)) return null;
208
+
209
+ const ymlContent = readFileSync(join(automationsDir, file), 'utf-8');
210
+ let description = '';
211
+ let schedule = '';
212
+
213
+ try {
214
+ const parsed = parseYaml(ymlContent);
215
+ if (parsed && typeof parsed === 'object') {
216
+ description = parsed.description ?? '';
217
+ schedule = parsed.schedule ?? '';
218
+ }
219
+ } catch {
220
+ // best-effort metadata extraction
221
+ }
222
+
223
+ return {
224
+ name,
225
+ type: 'automation' as const,
226
+ description,
227
+ schedule,
228
+ ymlContent,
229
+ };
230
+ })
231
+ .filter((entry): entry is RegistryAutomationEntry => entry !== null);
232
+ }
233
+
234
+ export function getRegistryAutomation(name: string): string | null {
235
+ if (!VALID_NAME_RE.test(name)) return null;
236
+ const ymlPath = join(resolveRegistryAutomationsDir(), `${name}.yml`);
237
+ if (!existsSync(ymlPath)) return null;
238
+ return readFileSync(ymlPath, 'utf-8');
239
+ }
240
+
241
+ export function getRegistryAddonConfig(homeDir: string, name: string): RegistryAddonConfig {
242
+ if (!VALID_NAME_RE.test(name)) {
243
+ throw new Error(`Invalid addon name: ${name}`);
244
+ }
245
+
246
+ const schemaPath = `registry/addons/${name}/.env.schema`;
247
+ return {
248
+ schemaPath,
249
+ userEnvPath: 'vault/user/user.env',
250
+ envSchema: readFileSync(join(homeDir, schemaPath), 'utf-8'),
251
+ };
252
+ }
253
+
254
+ export function listAvailableAddonIds(): string[] {
255
+ const addonsDir = resolveRegistryAddonsDir();
256
+ if (!existsSync(addonsDir) && !warnedMissingRegistryAddonsDir) {
257
+ warnedMissingRegistryAddonsDir = true;
258
+ logger.warn('registry addons directory is missing', { addonsDir });
259
+ }
260
+ return Object.keys(discoverRegistryComponents()).sort();
261
+ }
262
+
263
+ export function listEnabledAddonIds(homeDir: string): string[] {
264
+ const addonsDir = join(homeDir, 'stack', 'addons');
265
+ if (!existsSync(addonsDir)) return [];
266
+
267
+ return readdirSync(addonsDir, { withFileTypes: true })
268
+ .filter((entry) => entry.isDirectory() && existsSync(join(addonsDir, entry.name, 'compose.yml')))
269
+ .map((entry) => entry.name)
270
+ .sort();
271
+ }
272
+
273
+ function copyAddonFromRegistry(homeDir: string, name: string): void {
274
+ if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
275
+
276
+ const sourceDir = join(resolveRegistryAddonsDir(), name);
277
+ if (!existsSync(join(sourceDir, 'compose.yml')) || !existsSync(join(sourceDir, '.env.schema'))) {
278
+ throw new Error(`Addon "${name}" not found in registry`);
279
+ }
280
+
281
+ const targetDir = join(homeDir, 'stack', 'addons', name);
282
+ rmSync(targetDir, { recursive: true, force: true });
283
+ mkdirSync(join(homeDir, 'stack', 'addons'), { recursive: true });
284
+ cpSync(sourceDir, targetDir, { recursive: true });
285
+ }
286
+
287
+ function removeEnabledAddon(homeDir: string, name: string): void {
288
+ if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
289
+ rmSync(join(homeDir, 'stack', 'addons', name), { recursive: true, force: true });
290
+ }
291
+
292
+ function readAddonServiceNames(composePath: string): string[] {
293
+ if (!existsSync(composePath)) return [];
294
+
295
+ try {
296
+ const parsed = parseYaml(readFileSync(composePath, "utf-8"));
297
+ const services = parsed && typeof parsed === "object" ? (parsed as { services?: unknown }).services : undefined;
298
+ if (!services || typeof services !== "object" || Array.isArray(services)) return [];
299
+ return Object.keys(services as Record<string, unknown>);
300
+ } catch (error) {
301
+ logger.warn("failed to parse addon compose services", {
302
+ composePath,
303
+ error: error instanceof Error ? error.message : String(error),
304
+ });
305
+ return [];
306
+ }
307
+ }
308
+
309
+ export function getAddonServiceNames(homeDir: string, name: string): string[] {
310
+ if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
311
+
312
+ const composeCandidates = [
313
+ join(homeDir, "stack", "addons", name, "compose.yml"),
314
+ join(homeDir, "registry", "addons", name, "compose.yml"),
315
+ ];
316
+
317
+ for (const composePath of composeCandidates) {
318
+ const services = readAddonServiceNames(composePath);
319
+ if (services.length > 0) return services;
320
+ }
321
+
322
+ return [];
323
+ }
324
+
325
+ export function enableAddon(homeDir: string, name: string): MutationResult {
326
+ try {
327
+ copyAddonFromRegistry(homeDir, name);
328
+ // Pre-create the addon data directory so Docker doesn't create it as root
329
+ mkdirSync(join(homeDir, 'data', name), { recursive: true });
330
+ return { ok: true };
331
+ } catch (error) {
332
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
333
+ }
334
+ }
335
+
336
+ export function disableAddonByName(homeDir: string, name: string): MutationResult {
337
+ try {
338
+ removeEnabledAddon(homeDir, name);
339
+ return { ok: true };
340
+ } catch (error) {
341
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
342
+ }
343
+ }
344
+
345
+ export function setAddonEnabled(homeDir: string, vaultDir: string, name: string, enabled: boolean): AddonMutationResult {
346
+ if (!VALID_NAME_RE.test(name)) {
347
+ return { ok: false, error: `Invalid addon name: ${name}` };
348
+ }
349
+
350
+ if (!listAvailableAddonIds().includes(name)) {
351
+ return { ok: false, error: `Addon "${name}" not found in registry` };
352
+ }
353
+
354
+ const wasEnabled = listEnabledAddonIds(homeDir).includes(name);
355
+ const services = getAddonServiceNames(homeDir, name);
356
+
357
+ if (wasEnabled === enabled) {
358
+ return {
359
+ ok: true,
360
+ enabled: wasEnabled,
361
+ changed: false,
362
+ services,
363
+ };
364
+ }
365
+
366
+ const mutation = enabled ? enableAddon(homeDir, name) : disableAddonByName(homeDir, name);
367
+ if (!mutation.ok) return mutation;
368
+
369
+ if (enabled) {
370
+ const composePath = join(homeDir, "stack", "addons", name, "compose.yml");
371
+ if (isChannelAddon(composePath)) {
372
+ writeChannelSecrets(vaultDir, { [name]: randomHex(16) });
373
+ }
374
+ }
375
+
376
+ return {
377
+ ok: true,
378
+ enabled,
379
+ changed: true,
380
+ services,
381
+ };
382
+ }
383
+
384
+ export function installAutomationFromRegistry(name: string, configDir: string): MutationResult {
385
+ if (!VALID_NAME_RE.test(name)) {
386
+ return { ok: false, error: `Invalid automation name: ${name}` };
387
+ }
388
+
389
+ const automationYml = getRegistryAutomation(name);
390
+ if (!automationYml) {
391
+ return { ok: false, error: `Automation "${name}" not found in registry` };
392
+ }
393
+
394
+ const automationsDir = join(configDir, 'automations');
395
+ mkdirSync(automationsDir, { recursive: true });
396
+
397
+ const ymlPath = join(automationsDir, `${name}.yml`);
398
+ if (existsSync(ymlPath)) {
399
+ return { ok: false, error: `Automation "${name}" is already installed` };
400
+ }
401
+
402
+ writeFileSync(ymlPath, automationYml);
403
+ return { ok: true };
404
+ }
405
+
406
+ export function uninstallAutomation(name: string, configDir: string): MutationResult {
407
+ if (!VALID_NAME_RE.test(name)) {
408
+ return { ok: false, error: `Invalid automation name: ${name}` };
409
+ }
410
+
411
+ const ymlPath = join(configDir, 'automations', `${name}.yml`);
412
+ if (!existsSync(ymlPath)) {
413
+ return { ok: false, error: `Automation "${name}" is not installed` };
414
+ }
415
+
416
+ rmSync(ymlPath, { force: true });
417
+ return { ok: true };
418
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Snapshot-based rollback for the OpenPalm control plane.
3
+ *
4
+ * Before writing validated changes to live paths, the current state
5
+ * is snapshotted to ~/.cache/openpalm/rollback/. On deploy failure
6
+ * (or manual `openpalm rollback`), the snapshot is restored.
7
+ */
8
+ import { mkdirSync, copyFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import type { ControlPlaneState } from "./types.js";
11
+ import { resolveRollbackDir } from "./home.js";
12
+
13
+ /** Files that are tracked for rollback (relative to homeDir).
14
+ * Only vault/stack/ files are included — vault/user/ and config/ are
15
+ * user-owned and never overwritten by lifecycle operations. */
16
+ const SNAPSHOT_FILES = [
17
+ "vault/stack/stack.env",
18
+ "vault/stack/guardian.env",
19
+ ];
20
+
21
+ /**
22
+ * Copy a file if it exists, creating parent directories as needed.
23
+ */
24
+ function safeCopy(src: string, dest: string): void {
25
+ if (!existsSync(src)) return;
26
+ mkdirSync(dirname(dest), { recursive: true });
27
+ copyFileSync(src, dest);
28
+ }
29
+
30
+ /**
31
+ * Save the current live configuration files to the rollback directory.
32
+ * Also snapshots stack/core.compose.yml and all addon compose.yml files
33
+ * under stack/addons/.
34
+ */
35
+ export function snapshotCurrentState(state: ControlPlaneState): void {
36
+ const rollbackDir = resolveRollbackDir();
37
+ mkdirSync(rollbackDir, { recursive: true });
38
+
39
+ // Snapshot known files
40
+ for (const rel of SNAPSHOT_FILES) {
41
+ const src = join(state.homeDir, rel);
42
+ const dest = join(rollbackDir, rel);
43
+ safeCopy(src, dest);
44
+ }
45
+
46
+ // Snapshot stack/core.compose.yml
47
+ const coreCompose = join(state.homeDir, "stack/core.compose.yml");
48
+ safeCopy(coreCompose, join(rollbackDir, "stack/core.compose.yml"));
49
+
50
+ // Snapshot stack/addons/*/compose.yml
51
+ const addonsDir = join(state.homeDir, "stack/addons");
52
+ if (existsSync(addonsDir)) {
53
+ for (const entry of readdirSync(addonsDir, { withFileTypes: true })) {
54
+ if (entry.isDirectory()) {
55
+ const addonCompose = join(addonsDir, entry.name, "compose.yml");
56
+ if (existsSync(addonCompose)) {
57
+ safeCopy(
58
+ addonCompose,
59
+ join(rollbackDir, "stack/addons", entry.name, "compose.yml"),
60
+ );
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ // Write a timestamp marker
67
+ writeFileSync(
68
+ join(rollbackDir, ".snapshot-ts"),
69
+ new Date().toISOString() + "\n",
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Restore the most recent snapshot from the rollback directory
75
+ * back to their live positions.
76
+ */
77
+ export function restoreSnapshot(state: ControlPlaneState): void {
78
+ const rollbackDir = resolveRollbackDir();
79
+ if (!hasSnapshot()) {
80
+ throw new Error("No rollback snapshot available");
81
+ }
82
+
83
+ // Restore known files
84
+ for (const rel of SNAPSHOT_FILES) {
85
+ const src = join(rollbackDir, rel);
86
+ const dest = join(state.homeDir, rel);
87
+ safeCopy(src, dest);
88
+ }
89
+
90
+ // Restore stack/core.compose.yml
91
+ const srcCoreCompose = join(rollbackDir, "stack/core.compose.yml");
92
+ if (existsSync(srcCoreCompose)) {
93
+ safeCopy(srcCoreCompose, join(state.homeDir, "stack/core.compose.yml"));
94
+ }
95
+
96
+ // Restore stack/addons/*/compose.yml
97
+ const srcAddons = join(rollbackDir, "stack/addons");
98
+ if (existsSync(srcAddons)) {
99
+ for (const entry of readdirSync(srcAddons, { withFileTypes: true })) {
100
+ if (entry.isDirectory()) {
101
+ const srcAddonCompose = join(srcAddons, entry.name, "compose.yml");
102
+ if (existsSync(srcAddonCompose)) {
103
+ safeCopy(
104
+ srcAddonCompose,
105
+ join(state.homeDir, "stack/addons", entry.name, "compose.yml"),
106
+ );
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Check whether a rollback snapshot exists.
115
+ */
116
+ export function hasSnapshot(): boolean {
117
+ const rollbackDir = resolveRollbackDir();
118
+ return existsSync(join(rollbackDir, ".snapshot-ts"));
119
+ }
120
+
121
+ /**
122
+ * Read the timestamp of the most recent snapshot.
123
+ */
124
+ export function snapshotTimestamp(): string | null {
125
+ const tsFile = join(resolveRollbackDir(), ".snapshot-ts");
126
+ if (!existsSync(tsFile)) return null;
127
+ return readFileSync(tsFile, "utf-8").trim();
128
+ }