@occam-scaly/scaly-cli 0.1.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.
@@ -0,0 +1,392 @@
1
+ 'use strict';
2
+
3
+ const { stableStringify, sha256Prefixed } = require('./stable-json');
4
+ const api = require('./scaly-api');
5
+
6
+ function normalizeEnv(env) {
7
+ if (!env) return null;
8
+ const v = String(env).trim().toLowerCase();
9
+ if (!v) return null;
10
+ return v;
11
+ }
12
+
13
+ function pick(obj, keys) {
14
+ const out = {};
15
+ for (const k of keys) {
16
+ if (obj && typeof obj === 'object' && k in obj) out[k] = obj[k];
17
+ }
18
+ return out;
19
+ }
20
+
21
+ function stableOpSort(a, b) {
22
+ const kindOrder = { stack: 0, addon: 1, app: 2 };
23
+ const ao = kindOrder[a.kind] ?? 9;
24
+ const bo = kindOrder[b.kind] ?? 9;
25
+ if (ao !== bo) return ao - bo;
26
+ const ak = `${a.kind}:${a.resource?.name || ''}:${a.resource?.id || ''}`;
27
+ const bk = `${b.kind}:${b.resource?.name || ''}:${b.resource?.id || ''}`;
28
+ return ak.localeCompare(bk);
29
+ }
30
+
31
+ function mapAddOnType(type) {
32
+ const t = String(type || '').toLowerCase();
33
+ if (t === 'database') return 'DATABASE';
34
+ if (t === 'storage' || t === 'storage_s3' || t === 's3') return 'STORAGE_S3';
35
+ if (t === 'cognito' || t === 'userpool' || t === 'user_pool')
36
+ return 'COGNITO';
37
+ if (t === 'cname') return 'CNAME';
38
+ return null;
39
+ }
40
+
41
+ function mapDbEngine(engine) {
42
+ const e = String(engine || '').toUpperCase();
43
+ if (!e) return null;
44
+ if (e === 'POSTGRES_16') return 'POSTGRES_16';
45
+ if (e === 'POSTGRES_14') return 'POSTGRES_14';
46
+ if (e === 'MYSQL_80') return 'MYSQL_80';
47
+ return null;
48
+ }
49
+
50
+ async function resolveAccountId({ config }) {
51
+ const explicit = config?.account?.id;
52
+ if (explicit) return explicit;
53
+
54
+ const slug = config?.account?.slug;
55
+ if (slug && typeof api.findAccountBySlug === 'function') {
56
+ const acct = await api.findAccountBySlug(slug);
57
+ if (acct?.id) return acct.id;
58
+ }
59
+
60
+ const stacks = await api.listStacks({ take: 10 });
61
+ if (stacks?.[0]?.accountId) return stacks[0].accountId;
62
+
63
+ const e = new Error(
64
+ 'Unable to infer account ID. Add `account: { id: ... }` to `.scaly/config.yaml` or ensure you have at least one stack in the account.'
65
+ );
66
+ e.code = 'SCALY_ACCOUNT_UNRESOLVED';
67
+ throw e;
68
+ }
69
+
70
+ function diffFields({ current, desired, fields }) {
71
+ const diffs = [];
72
+ for (const field of fields) {
73
+ const c = current ? current[field] : undefined;
74
+ const d = desired ? desired[field] : undefined;
75
+ if (typeof d === 'undefined') continue;
76
+ if (c !== d) diffs.push({ field, current: c ?? null, desired: d ?? null });
77
+ }
78
+ return diffs;
79
+ }
80
+
81
+ async function buildPlan({ config, env, appName }) {
82
+ const warnings = [];
83
+ const drift = [];
84
+ const ops = [];
85
+ const normalizedEnv = normalizeEnv(env);
86
+
87
+ if (config?.stack?.region) {
88
+ warnings.push(
89
+ 'config.stack.region is currently informational only (Stack has no region field in the API).'
90
+ );
91
+ }
92
+
93
+ const desiredStack = {
94
+ name: config.stack.name,
95
+ size: config.stack.size,
96
+ minIdle:
97
+ typeof config.stack.minIdle === 'number'
98
+ ? config.stack.minIdle
99
+ : config.stack.minIdle != null
100
+ ? Number(config.stack.minIdle)
101
+ : undefined
102
+ };
103
+
104
+ const stacks = await api.findStackByName(desiredStack.name);
105
+ const currentStack = stacks?.[0] || null;
106
+ if (stacks.length > 1) {
107
+ warnings.push(
108
+ `Multiple stacks found named '${desiredStack.name}'. Using '${currentStack.id}'.`
109
+ );
110
+ }
111
+
112
+ let accountId = currentStack?.accountId || null;
113
+ if (!accountId) {
114
+ accountId = await resolveAccountId({ config });
115
+ }
116
+
117
+ let stackOpId = null;
118
+ if (!currentStack) {
119
+ stackOpId = 'op_stack_create';
120
+ ops.push({
121
+ id: stackOpId,
122
+ kind: 'stack',
123
+ action: 'create',
124
+ resource: { type: 'stack', name: desiredStack.name },
125
+ current: null,
126
+ desired: { ...desiredStack, accountId },
127
+ diff: [{ field: 'exists', current: false, desired: true }]
128
+ });
129
+ } else {
130
+ const diffs = diffFields({
131
+ current: currentStack,
132
+ desired: desiredStack,
133
+ fields: ['size', 'minIdle']
134
+ });
135
+ if (diffs.length) {
136
+ stackOpId = 'op_stack_update';
137
+ ops.push({
138
+ id: stackOpId,
139
+ kind: 'stack',
140
+ action: 'update',
141
+ resource: {
142
+ type: 'stack',
143
+ name: currentStack.name,
144
+ id: currentStack.id
145
+ },
146
+ current: pick(currentStack, [
147
+ 'id',
148
+ 'name',
149
+ 'accountId',
150
+ 'size',
151
+ 'minIdle'
152
+ ]),
153
+ desired: { ...desiredStack, accountId },
154
+ diff: diffs
155
+ });
156
+ } else {
157
+ ops.push({
158
+ id: 'op_stack_noop',
159
+ kind: 'stack',
160
+ action: 'noop',
161
+ resource: {
162
+ type: 'stack',
163
+ name: currentStack.name,
164
+ id: currentStack.id
165
+ },
166
+ current: pick(currentStack, [
167
+ 'id',
168
+ 'name',
169
+ 'accountId',
170
+ 'size',
171
+ 'minIdle'
172
+ ]),
173
+ desired: { ...desiredStack, accountId },
174
+ diff: []
175
+ });
176
+ }
177
+ }
178
+
179
+ const desiredApps = (config.apps || []).filter(Boolean);
180
+ const filteredApps = appName
181
+ ? desiredApps.filter((a) => a && a.name === appName)
182
+ : desiredApps;
183
+ if (appName && filteredApps.length === 0) {
184
+ const e = new Error(
185
+ `Unknown app '${appName}'. Available: ${(desiredApps || [])
186
+ .map((a) => a && a.name)
187
+ .filter(Boolean)
188
+ .join(', ')}`
189
+ );
190
+ e.code = 'SCALY_APP_NOT_FOUND_IN_CONFIG';
191
+ throw e;
192
+ }
193
+
194
+ if ((desiredApps || []).some((a) => a && a.path)) {
195
+ warnings.push(
196
+ 'apps[].path is currently informational only (plan/apply does not inspect your source tree yet).'
197
+ );
198
+ }
199
+
200
+ for (const desiredApp of filteredApps) {
201
+ const apps = await api.findAppByName(desiredApp.name);
202
+ const currentApp = apps?.[0] || null;
203
+ if (apps.length > 1) {
204
+ warnings.push(
205
+ `Multiple apps found named '${desiredApp.name}'. Using '${currentApp.id}'.`
206
+ );
207
+ }
208
+
209
+ if (!currentApp) {
210
+ ops.push({
211
+ id: `op_app_create_${desiredApp.name}`,
212
+ kind: 'app',
213
+ action: 'create',
214
+ depends_on: stackOpId ? [stackOpId] : undefined,
215
+ resource: { type: 'app', name: desiredApp.name },
216
+ current: null,
217
+ desired: {
218
+ name: desiredApp.name,
219
+ framework: desiredApp.framework,
220
+ accountId,
221
+ stackName: desiredStack.name
222
+ },
223
+ diff: [{ field: 'exists', current: false, desired: true }]
224
+ });
225
+ continue;
226
+ }
227
+
228
+ // For now, we only ensure the app exists. Framework is informational (API source/runtime varies by deploy method).
229
+ ops.push({
230
+ id: `op_app_noop_${desiredApp.name}`,
231
+ kind: 'app',
232
+ action: 'noop',
233
+ resource: { type: 'app', name: currentApp.name, id: currentApp.id },
234
+ current: pick(currentApp, [
235
+ 'id',
236
+ 'name',
237
+ 'accountId',
238
+ 'stackId',
239
+ 'minIdle'
240
+ ]),
241
+ desired: {
242
+ name: desiredApp.name,
243
+ framework: desiredApp.framework,
244
+ accountId,
245
+ stackName: desiredStack.name
246
+ },
247
+ diff: []
248
+ });
249
+ }
250
+
251
+ if (Array.isArray(config.addons) && config.addons.length) {
252
+ let addOnReadable = true;
253
+ for (const addOn of config.addons) {
254
+ if (!addOn || typeof addOn !== 'object') continue;
255
+ const apiType = mapAddOnType(addOn.type);
256
+ if (!apiType) {
257
+ warnings.push(
258
+ `Unknown addon type '${addOn.type}' for '${addOn.name}'.`
259
+ );
260
+ continue;
261
+ }
262
+ if (!addOn.name) {
263
+ warnings.push('addons[] entry is missing name.');
264
+ continue;
265
+ }
266
+
267
+ let currentAddOn = null;
268
+ try {
269
+ const list = await api.findAddOnByName(addOn.name);
270
+ currentAddOn = list?.[0] || null;
271
+ if (list.length > 1) {
272
+ warnings.push(
273
+ `Multiple add-ons found named '${addOn.name}'. Using '${currentAddOn.id}'.`
274
+ );
275
+ }
276
+ } catch (err) {
277
+ addOnReadable = false;
278
+ warnings.push(
279
+ 'Unable to read add-ons from the API (auth/permissions). Add-on planning is skipped for now.'
280
+ );
281
+ break;
282
+ }
283
+
284
+ if (!currentAddOn) {
285
+ const desired = { name: addOn.name, type: apiType, accountId };
286
+ if (apiType === 'DATABASE') {
287
+ const dbType = mapDbEngine(addOn.engine);
288
+ const dbSize = addOn.size ? String(addOn.size) : null;
289
+ if (!dbType || !dbSize) {
290
+ warnings.push(
291
+ `Database add-on '${addOn.name}' missing engine/size (engine like POSTGRES_16, size like Small).`
292
+ );
293
+ } else {
294
+ desired.database = { size: dbSize, type: dbType };
295
+ }
296
+ }
297
+
298
+ ops.push({
299
+ id: `op_addon_create_${addOn.name}`,
300
+ kind: 'addon',
301
+ action: 'create',
302
+ resource: { type: 'addon', name: addOn.name },
303
+ current: null,
304
+ desired,
305
+ diff: [{ field: 'exists', current: false, desired: true }]
306
+ });
307
+ } else if (currentAddOn.type !== apiType) {
308
+ warnings.push(
309
+ `Add-on '${addOn.name}' exists with type '${currentAddOn.type}', but config wants '${apiType}'. Type changes are not supported.`
310
+ );
311
+ } else {
312
+ ops.push({
313
+ id: `op_addon_noop_${addOn.name}`,
314
+ kind: 'addon',
315
+ action: 'noop',
316
+ resource: {
317
+ type: 'addon',
318
+ name: currentAddOn.name,
319
+ id: currentAddOn.id
320
+ },
321
+ current: pick(currentAddOn, ['id', 'name', 'type', 'accountId']),
322
+ desired: { name: addOn.name, type: apiType, accountId },
323
+ diff: []
324
+ });
325
+ }
326
+ }
327
+
328
+ if (addOnReadable) {
329
+ warnings.push(
330
+ 'Add-on updates (e.g., DB resize, Cognito policy) are not yet planned; only create/noop is supported.'
331
+ );
332
+ }
333
+ }
334
+ if (Array.isArray(config.domains) && config.domains.length) {
335
+ warnings.push(
336
+ 'domains are parsed but not yet included in plan/apply (Phase 4: custom domains).'
337
+ );
338
+ }
339
+ if (Array.isArray(config.jobs) && config.jobs.length) {
340
+ warnings.push(
341
+ 'jobs are parsed but not yet included in plan/apply (Phase 4: scheduled jobs).'
342
+ );
343
+ }
344
+ if (config.git) {
345
+ warnings.push(
346
+ 'git integration is parsed but not yet included in plan/apply (Phase 4: link repo + auto-deploy).'
347
+ );
348
+ }
349
+
350
+ const sortedOps = ops.slice().sort(stableOpSort);
351
+ const planForHash = {
352
+ version: 1,
353
+ env: normalizedEnv,
354
+ desired: {
355
+ account: pick(config.account, ['id', 'slug']),
356
+ stack: pick(desiredStack, ['name', 'size', 'minIdle']),
357
+ apps: (filteredApps || []).map((a) =>
358
+ pick(a, ['name', 'framework', 'path'])
359
+ )
360
+ },
361
+ operations: sortedOps.map((o) =>
362
+ pick(o, [
363
+ 'id',
364
+ 'kind',
365
+ 'action',
366
+ 'resource',
367
+ 'desired',
368
+ 'diff',
369
+ 'depends_on'
370
+ ])
371
+ )
372
+ };
373
+
374
+ const plan_hash = sha256Prefixed(stableStringify(planForHash));
375
+
376
+ const summary = {
377
+ create: sortedOps.filter((o) => o.action === 'create').length,
378
+ update: sortedOps.filter((o) => o.action === 'update').length,
379
+ noop: sortedOps.filter((o) => o.action === 'noop').length
380
+ };
381
+
382
+ return {
383
+ env: normalizedEnv,
384
+ plan_hash,
385
+ operations: sortedOps,
386
+ summary,
387
+ warnings,
388
+ drift
389
+ };
390
+ }
391
+
392
+ module.exports = { buildPlan };
@@ -0,0 +1,303 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { stableStringify, sha256Prefixed } = require('./stable-json');
6
+
7
+ function fileExists(p) {
8
+ try {
9
+ fs.accessSync(p, fs.constants.F_OK);
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ function findProjectRoot(startDir) {
17
+ let current = path.resolve(startDir || process.cwd());
18
+ // eslint-disable-next-line no-constant-condition
19
+ while (true) {
20
+ const candidate = path.join(current, '.scaly', 'config.yaml');
21
+ if (fileExists(candidate)) return current;
22
+ const parent = path.dirname(current);
23
+ if (parent === current) return null;
24
+ current = parent;
25
+ }
26
+ }
27
+
28
+ function requireYaml() {
29
+ try {
30
+ // eslint-disable-next-line global-require
31
+ return require('yaml');
32
+ } catch (err) {
33
+ const e = new Error(
34
+ "Missing dependency: 'yaml'. Install it with `npm i yaml` (repo) or add it to the CLI package."
35
+ );
36
+ e.cause = err;
37
+ throw e;
38
+ }
39
+ }
40
+
41
+ function readYamlFile(filePath) {
42
+ const YAML = requireYaml();
43
+ const text = fs.readFileSync(filePath, 'utf8');
44
+ const parsed = YAML.parse(text);
45
+ return parsed ?? {};
46
+ }
47
+
48
+ function writeYamlFile(filePath, data) {
49
+ const YAML = requireYaml();
50
+ const dir = path.dirname(filePath);
51
+ fs.mkdirSync(dir, { recursive: true });
52
+ const text = YAML.stringify(data);
53
+ fs.writeFileSync(filePath, text, 'utf8');
54
+ }
55
+
56
+ function asPlainObject(value) {
57
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
58
+ return value;
59
+ }
60
+
61
+ function mergeObjects(base, overlay) {
62
+ const b = asPlainObject(base) || {};
63
+ const o = asPlainObject(overlay) || {};
64
+ const out = { ...b };
65
+ for (const [key, value] of Object.entries(o)) {
66
+ if (value === null) {
67
+ out[key] = null;
68
+ continue;
69
+ }
70
+ const baseValue = out[key];
71
+ if (Array.isArray(value)) {
72
+ out[key] = value.slice();
73
+ continue;
74
+ }
75
+ if (asPlainObject(value) && asPlainObject(baseValue)) {
76
+ out[key] = mergeObjects(baseValue, value);
77
+ continue;
78
+ }
79
+ out[key] = value;
80
+ }
81
+ return out;
82
+ }
83
+
84
+ function mergeArrayByKey(baseArr, overlayArr, key) {
85
+ const base = Array.isArray(baseArr) ? baseArr : [];
86
+ const overlay = Array.isArray(overlayArr) ? overlayArr : [];
87
+
88
+ const out = [];
89
+ const seen = new Set();
90
+
91
+ const byKey = (item) => {
92
+ if (!item || typeof item !== 'object') return null;
93
+ const v = item[key];
94
+ return typeof v === 'string' && v.trim() ? v : null;
95
+ };
96
+
97
+ const overlayMap = new Map();
98
+ for (const item of overlay) {
99
+ const k = byKey(item);
100
+ if (!k) continue;
101
+ overlayMap.set(k, item);
102
+ }
103
+
104
+ for (const item of base) {
105
+ const k = byKey(item);
106
+ if (!k) continue;
107
+ const next = overlayMap.has(k)
108
+ ? mergeObjects(item, overlayMap.get(k))
109
+ : item;
110
+ out.push(next);
111
+ seen.add(k);
112
+ }
113
+
114
+ for (const item of overlay) {
115
+ const k = byKey(item);
116
+ if (!k) continue;
117
+ if (seen.has(k)) continue;
118
+ out.push(item);
119
+ seen.add(k);
120
+ }
121
+
122
+ return out;
123
+ }
124
+
125
+ function normalizeConfig(config) {
126
+ const c = asPlainObject(config) || {};
127
+
128
+ // Support app: sugar for apps: [app]
129
+ if (!c.apps && c.app) {
130
+ return { ...c, apps: [c.app], app: undefined };
131
+ }
132
+ return c;
133
+ }
134
+
135
+ function mergeScalyConfig(baseConfig, overlayConfig) {
136
+ const base = normalizeConfig(baseConfig);
137
+ const overlay = normalizeConfig(overlayConfig);
138
+
139
+ const merged = mergeObjects(base, overlay);
140
+
141
+ merged.addons = mergeArrayByKey(base.addons, overlay.addons, 'name');
142
+ merged.domains = mergeArrayByKey(base.domains, overlay.domains, 'hostname');
143
+ merged.jobs = mergeArrayByKey(base.jobs, overlay.jobs, 'name');
144
+ merged.apps = mergeArrayByKey(base.apps, overlay.apps, 'name');
145
+
146
+ // Secrets are names-only and frequently sourced from CI; treat overrides as replace.
147
+ if (overlay.app && overlay.app.secrets) merged.app = merged.app || {};
148
+ if (overlay.app && overlay.app.secrets)
149
+ merged.app.secrets = overlay.app.secrets;
150
+
151
+ if (Array.isArray(overlay.apps)) {
152
+ const overlayByName = new Map(
153
+ overlay.apps
154
+ .filter((a) => a && typeof a === 'object' && a.name)
155
+ .map((a) => [a.name, a])
156
+ );
157
+ merged.apps = (merged.apps || []).map((app) => {
158
+ if (!app || typeof app !== 'object' || !app.name) return app;
159
+ const o = overlayByName.get(app.name);
160
+ if (o && o.secrets) return { ...app, secrets: o.secrets };
161
+ return app;
162
+ });
163
+ }
164
+
165
+ return merged;
166
+ }
167
+
168
+ function validateConfig(config) {
169
+ const errors = [];
170
+ if (!config || typeof config !== 'object') {
171
+ return { ok: false, errors: ['Config must be a YAML object'] };
172
+ }
173
+
174
+ if (String(config.version || '') !== '1') {
175
+ errors.push("Missing/invalid 'version' (expected '1').");
176
+ }
177
+
178
+ if (!config.stack || typeof config.stack !== 'object') {
179
+ errors.push("Missing required 'stack' block.");
180
+ } else {
181
+ if (!config.stack.name) errors.push("Missing 'stack.name'.");
182
+ if (!config.stack.size) errors.push("Missing 'stack.size'.");
183
+ }
184
+
185
+ if (!Array.isArray(config.apps) || config.apps.length === 0) {
186
+ errors.push("Missing application definition: provide 'app:' or 'apps:'.");
187
+ } else {
188
+ for (const [idx, app] of config.apps.entries()) {
189
+ if (!app || typeof app !== 'object') {
190
+ errors.push(`apps[${idx}] must be an object.`);
191
+ continue;
192
+ }
193
+ if (!app.name) errors.push(`apps[${idx}].name is required.`);
194
+ if (!app.framework) errors.push(`apps[${idx}].framework is required.`);
195
+ }
196
+ }
197
+
198
+ return { ok: errors.length === 0, errors };
199
+ }
200
+
201
+ function loadScalyConfig({ cwd = process.cwd(), env } = {}) {
202
+ const root = findProjectRoot(cwd);
203
+ if (!root) {
204
+ const e = new Error(
205
+ "No Scaly project found (missing '.scaly/config.yaml' in this or any parent directory). Run `scaly init`."
206
+ );
207
+ e.code = 'SCALY_NO_PROJECT';
208
+ throw e;
209
+ }
210
+
211
+ const basePath = path.join(root, '.scaly', 'config.yaml');
212
+ const overlayPath = env
213
+ ? path.join(root, '.scaly', `config.${env}.yaml`)
214
+ : null;
215
+
216
+ const base = readYamlFile(basePath);
217
+ const overlay =
218
+ overlayPath && fileExists(overlayPath) ? readYamlFile(overlayPath) : {};
219
+
220
+ const merged = mergeScalyConfig(base, overlay);
221
+ const normalized = normalizeConfig(merged);
222
+ const validation = validateConfig(normalized);
223
+
224
+ return {
225
+ root,
226
+ basePath,
227
+ overlayPath: overlayPath && fileExists(overlayPath) ? overlayPath : null,
228
+ config: normalized,
229
+ validation
230
+ };
231
+ }
232
+
233
+ function normalizeEnv(value) {
234
+ if (!value) return null;
235
+ const s = String(value).trim().toLowerCase();
236
+ return s || null;
237
+ }
238
+
239
+ function pick(obj, keys) {
240
+ const out = {};
241
+ for (const k of keys) {
242
+ if (obj && typeof obj === 'object' && k in obj) out[k] = obj[k];
243
+ }
244
+ return out;
245
+ }
246
+
247
+ function computeConfigHash({ config, env, appName }) {
248
+ const apps = Array.isArray(config?.apps) ? config.apps : [];
249
+ const filteredApps = appName
250
+ ? apps.filter((a) => a && a.name === appName)
251
+ : apps;
252
+ const addons = Array.isArray(config?.addons) ? config.addons : [];
253
+
254
+ const input = {
255
+ version: 1,
256
+ env: normalizeEnv(env),
257
+ desired: {
258
+ account: pick(config?.account, ['id', 'slug']),
259
+ stack: pick(config?.stack, ['name', 'size', 'minIdle']),
260
+ apps: (filteredApps || [])
261
+ .filter(Boolean)
262
+ .map((a) => pick(a, ['name', 'framework', 'path'])),
263
+ addons: (addons || [])
264
+ .filter(Boolean)
265
+ .map((a) => pick(a, ['name', 'type', 'engine', 'size']))
266
+ }
267
+ };
268
+
269
+ return sha256Prefixed(stableStringify(input));
270
+ }
271
+
272
+ function getLastApplyPath(root) {
273
+ return path.join(root, '.scaly', 'last_apply.json');
274
+ }
275
+
276
+ function readLastApply(root) {
277
+ const p = getLastApplyPath(root);
278
+ try {
279
+ const text = fs.readFileSync(p, 'utf8');
280
+ const obj = JSON.parse(text);
281
+ return obj && typeof obj === 'object' ? obj : null;
282
+ } catch {
283
+ return null;
284
+ }
285
+ }
286
+
287
+ function writeLastApply(root, payload) {
288
+ const p = getLastApplyPath(root);
289
+ fs.mkdirSync(path.dirname(p), { recursive: true });
290
+ fs.writeFileSync(p, JSON.stringify(payload, null, 2) + '\n', 'utf8');
291
+ }
292
+
293
+ module.exports = {
294
+ findProjectRoot,
295
+ readYamlFile,
296
+ writeYamlFile,
297
+ mergeScalyConfig,
298
+ loadScalyConfig,
299
+ normalizeEnv,
300
+ computeConfigHash,
301
+ readLastApply,
302
+ writeLastApply
303
+ };