@solidnumber/cli 2.17.0 → 2.18.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.
@@ -10,8 +10,18 @@
10
10
  *
11
11
  * solid apply infra.yaml
12
12
  * solid apply infra.yaml --dry-run # plan only, no writes
13
+ * solid apply infra.yaml --strict # refuse if production moved since the last apply
13
14
  * solid apply infra.yaml --prune --yes # also delete unmanaged resources
14
15
  * solid apply --kinds # list supported kinds
16
+ *
17
+ * solid apply drift infra.yaml # three-way: manifest vs lock vs production
18
+ * solid apply rollback infra.yaml # revert the last run from its recorded pre-images
19
+ * solid apply history infra.yaml # the runs the lock remembers
20
+ * solid apply export business.yaml # write the live tenant as a manifest
21
+ *
22
+ * Every non-dry-run apply writes `<manifest>.lock.json` beside the manifest:
23
+ * server ids, the managed fields as applied, and a pre-image for every write.
24
+ * Commit it with the manifest — it is what drift and rollback read.
15
25
  */
16
26
  import { Command } from 'commander';
17
27
  export declare const applyCommand: Command;
@@ -1 +1 @@
1
- {"version":3,"file":"apply.d.ts","sourceRoot":"","sources":["../../src/commands/apply.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAuBpC,eAAO,MAAM,YAAY,SA+GrB,CAAC"}
1
+ {"version":3,"file":"apply.d.ts","sourceRoot":"","sources":["../../src/commands/apply.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA0IpC,eAAO,MAAM,YAAY,SAiHrB,CAAC"}
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
@@ -16,108 +49,230 @@ exports.applyCommand = void 0;
16
49
  *
17
50
  * solid apply infra.yaml
18
51
  * solid apply infra.yaml --dry-run # plan only, no writes
52
+ * solid apply infra.yaml --strict # refuse if production moved since the last apply
19
53
  * solid apply infra.yaml --prune --yes # also delete unmanaged resources
20
54
  * solid apply --kinds # list supported kinds
55
+ *
56
+ * solid apply drift infra.yaml # three-way: manifest vs lock vs production
57
+ * solid apply rollback infra.yaml # revert the last run from its recorded pre-images
58
+ * solid apply history infra.yaml # the runs the lock remembers
59
+ * solid apply export business.yaml # write the live tenant as a manifest
60
+ *
61
+ * Every non-dry-run apply writes `<manifest>.lock.json` beside the manifest:
62
+ * server ids, the managed fields as applied, and a pre-image for every write.
63
+ * Commit it with the manifest — it is what drift and rollback read.
21
64
  */
22
65
  const commander_1 = require("commander");
23
66
  const chalk_1 = __importDefault(require("chalk"));
24
- const node_fs_1 = require("node:fs");
67
+ const fs = __importStar(require("node:fs"));
68
+ const path = __importStar(require("node:path"));
69
+ const yaml = __importStar(require("js-yaml"));
25
70
  const api_client_1 = require("../lib/api-client");
71
+ const config_1 = require("../lib/config");
26
72
  const json_output_1 = require("../lib/json-output");
27
73
  const command_kit_1 = require("../lib/command-kit");
28
74
  const dry_run_1 = require("../lib/dry-run");
75
+ const tenant_guard_1 = require("../lib/tenant-guard");
29
76
  const engine_1 = require("../lib/apply/engine");
30
77
  const registry_1 = require("../lib/apply/registry");
78
+ const lock_1 = require("../lib/apply/lock");
31
79
  const ICON = {
32
80
  create: chalk_1.default.green('+ create'),
33
81
  update: chalk_1.default.yellow('~ update'),
34
82
  noop: chalk_1.default.dim('= unchanged'),
35
83
  prune: chalk_1.default.red('- prune'),
36
- unsupported: chalk_1.default.magenta('! immutable'),
84
+ unsupported: chalk_1.default.magenta('! unsupported'),
37
85
  };
38
- exports.applyCommand = new commander_1.Command('apply')
39
- .description('Reconcile the company to a declarative manifest (create/update/prune)')
40
- .argument('[file]', 'Manifest file (YAML or JSON). Use "-" for stdin.')
41
- .option('--dry-run', 'Show the plan without making any changes')
42
- .option('--prune', 'Delete managed resources that are absent from the manifest')
43
- .option('--yes', 'Skip the confirmation prompt (required for --prune in non-interactive use)')
44
- .option('--json', 'Machine-readable plan/result')
45
- .option('--kinds', 'List the resource kinds apply supports, then exit')
46
- .action(async (file, options) => {
47
- if (options.kinds) {
48
- const kinds = (0, registry_1.knownKinds)();
49
- if ((0, json_output_1.isJsonOutput)(options))
50
- return void (0, json_output_1.emitJson)({ kinds });
51
- console.log(chalk_1.default.bold('Supported kinds:'));
52
- for (const k of kinds)
53
- console.log(` ${k}`);
54
- return;
86
+ const DRIFT_ICON = {
87
+ in_sync: chalk_1.default.dim('= in sync'),
88
+ manifest_changed: chalk_1.default.yellow('~ manifest changed'),
89
+ live_changed: chalk_1.default.red('! production changed'),
90
+ conflict: chalk_1.default.red('!! conflict'),
91
+ missing: chalk_1.default.magenta('? missing on server'),
92
+ never_applied: chalk_1.default.dim('· never applied'),
93
+ removed_from_manifest: chalk_1.default.yellow('- removed from manifest'),
94
+ };
95
+ function cliVersion() {
96
+ try {
97
+ return JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')).version;
55
98
  }
56
- (0, command_kit_1.requireCompanyContext)();
57
- if (!file) {
58
- console.error(chalk_1.default.red('Missing manifest file. Usage: solid apply <file> (or "-" for stdin).'));
59
- process.exit(2);
99
+ catch {
100
+ return 'unknown';
60
101
  }
102
+ }
103
+ function readManifest(file) {
61
104
  let text;
62
105
  try {
63
- text = file === '-' ? (0, node_fs_1.readFileSync)(0, 'utf8') : (0, node_fs_1.readFileSync)(file, 'utf8');
106
+ text = file === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(file, 'utf8');
64
107
  }
65
108
  catch (e) {
66
109
  console.error(chalk_1.default.red(`Cannot read manifest: ${e instanceof Error ? e.message : String(e)}`));
67
110
  process.exit(2);
68
111
  }
69
- let resources;
70
112
  try {
71
- resources = (0, engine_1.parseManifest)(text);
113
+ return (0, engine_1.parseManifest)(text);
72
114
  }
73
115
  catch (e) {
74
116
  console.error(chalk_1.default.red(`Invalid manifest: ${e instanceof Error ? e.message : String(e)}`));
75
117
  process.exit(2);
76
118
  }
77
- if (resources.length === 0) {
78
- console.error(chalk_1.default.yellow('Manifest declares no resources.'));
79
- process.exit(0);
80
- }
81
- const dryRun = !!options.dryRun || (0, dry_run_1.isDryRun)();
82
- // Group by kind so we fetch each collection's current state once.
119
+ }
120
+ function groupByKind(resources) {
83
121
  const byKind = new Map();
84
122
  for (const r of resources) {
85
123
  const arr = byKind.get(r.kind) ?? [];
86
124
  arr.push(r);
87
125
  byKind.set(r.kind, arr);
88
126
  }
89
- const allActions = [];
90
- const planByKind = [];
127
+ return byKind;
128
+ }
129
+ async function fetchLive(kinds, json) {
130
+ const live = new Map();
91
131
  try {
92
- for (const [kind, desired] of byKind) {
93
- const recon = (0, registry_1.reconcilerFor)(kind); // parseManifest already validated
132
+ for (const kind of kinds) {
133
+ const recon = (0, registry_1.reconcilerFor)(kind);
94
134
  const resp = await api_client_1.apiClient.get(recon.list);
95
- const current = (0, engine_1.extractList)(resp.data, recon);
96
- const actions = (0, engine_1.planKind)(recon, desired, current, { prune: options.prune });
97
- planByKind.push({ kind, actions });
98
- allActions.push(...actions);
135
+ live.set(kind, { recon, items: (0, engine_1.extractList)(resp.data, recon) });
99
136
  }
100
137
  }
101
138
  catch (e) {
102
139
  const err = (0, api_client_1.handleApiError)(e);
103
- if ((0, json_output_1.isJsonOutput)(options)) {
140
+ if (json) {
104
141
  (0, json_output_1.emitJson)({ error: err.message });
105
142
  process.exit(1);
106
143
  }
107
144
  console.error(chalk_1.default.red(`Failed to read current state: ${err.message}`));
108
145
  process.exit(1);
109
146
  }
147
+ return live;
148
+ }
149
+ /** Lock handling for a manifest path: null when the manifest is stdin or --no-lock. */
150
+ function lockFor(file, useLock, json) {
151
+ if (!useLock || file === '-')
152
+ return null;
153
+ const lockPath = (0, lock_1.lockPathFor)(path.resolve(file));
154
+ if ((0, tenant_guard_1.isProtectedRoot)(path.dirname(lockPath))) {
155
+ // Never write tenant state into $HOME or the platform monorepo.
156
+ if (!json)
157
+ (0, tenant_guard_1.refuseProtectedRoot)(path.dirname(lockPath));
158
+ (0, json_output_1.emitJson)({ error: 'refusing to write a lockfile in a protected directory; use --no-lock or move the manifest' });
159
+ process.exit(1);
160
+ }
161
+ try {
162
+ return { path: lockPath, lock: (0, lock_1.readLock)(lockPath) };
163
+ }
164
+ catch (e) {
165
+ const msg = e instanceof Error ? e.message : String(e);
166
+ if (json) {
167
+ (0, json_output_1.emitJson)({ error: msg });
168
+ process.exit(1);
169
+ }
170
+ console.error(chalk_1.default.red(msg));
171
+ process.exit(1);
172
+ }
173
+ }
174
+ function printResults(results) {
175
+ for (const r of results) {
176
+ const tag = ICON[r.action] ?? r.action;
177
+ const suffix = r.status === 'failed' ? chalk_1.default.red(` FAILED: ${r.error}`)
178
+ : r.reason ? chalk_1.default.dim(` (${r.reason})`) : '';
179
+ console.log(` ${tag} ${chalk_1.default.cyan(r.kind)}/${r.identity}${suffix}`);
180
+ }
181
+ }
182
+ function printDrift(entries) {
183
+ for (const e of entries) {
184
+ console.log(` ${DRIFT_ICON[e.state]} ${chalk_1.default.cyan(e.kind)}/${e.identity}`);
185
+ for (const d of e.live_diff ?? []) {
186
+ console.log(chalk_1.default.dim(` ${d.field}: applied ${JSON.stringify(d.lock)} → production ${JSON.stringify(d.live)}`));
187
+ }
188
+ for (const d of e.manifest_diff ?? []) {
189
+ console.log(chalk_1.default.dim(` ${d.field}: applied ${JSON.stringify(d.lock)} → manifest ${JSON.stringify(d.manifest)}`));
190
+ }
191
+ }
192
+ }
193
+ // ── solid apply <file> ───────────────────────────────────────────────────────
194
+ exports.applyCommand = new commander_1.Command('apply')
195
+ .description('Reconcile the company to a declarative manifest (create/update/prune); drift, rollback, history, export')
196
+ // Parent flags (--json, --yes, --dry-run, --kinds) must not swallow the
197
+ // subcommands' own flags: `solid apply drift site.yaml --json` is drift's --json.
198
+ .enablePositionalOptions()
199
+ .argument('[file]', 'Manifest file (YAML or JSON). Use "-" for stdin.')
200
+ .option('--dry-run', 'Show the plan without making any changes')
201
+ .option('--prune', 'Delete managed resources that are absent from the manifest')
202
+ .option('--yes', 'Skip the prune confirmation')
203
+ .option('--strict', 'Refuse to write if production changed since the last apply (what `solid apply drift` reports)')
204
+ .option('--no-lock', 'Do not read or write <manifest>.lock.json')
205
+ .option('--json', 'Machine-readable plan/result')
206
+ .option('--kinds', 'List the resource kinds apply supports, then exit')
207
+ .action(async (file, options) => {
208
+ const json = (0, json_output_1.isJsonOutput)(options);
209
+ if (options.kinds) {
210
+ const kinds = (0, registry_1.knownKinds)();
211
+ if (json)
212
+ return void (0, json_output_1.emitJson)({ kinds });
213
+ console.log(chalk_1.default.bold('Supported kinds:'));
214
+ for (const k of kinds)
215
+ console.log(` ${k}`);
216
+ return;
217
+ }
218
+ (0, command_kit_1.requireCompanyContext)();
219
+ if (!file) {
220
+ console.error(chalk_1.default.red('Missing manifest file. Usage: solid apply <file> (or "-" for stdin).'));
221
+ process.exit(2);
222
+ }
223
+ const resources = readManifest(file);
224
+ if (resources.length === 0) {
225
+ console.error(chalk_1.default.yellow('Manifest declares no resources.'));
226
+ process.exit(0);
227
+ }
228
+ const dryRun = !!options.dryRun || (0, dry_run_1.isDryRun)();
229
+ const lockState = lockFor(file, options.lock !== false, json);
230
+ const byKind = groupByKind(resources);
231
+ const live = await fetchLive(byKind.keys(), json);
232
+ // --strict: production must be where the last apply left it, or we do nothing.
233
+ let drift;
234
+ if (options.strict) {
235
+ if (!lockState) {
236
+ const msg = '--strict needs a lockfile (manifest from a file, without --no-lock)';
237
+ if (json) {
238
+ (0, json_output_1.emitJson)({ error: msg });
239
+ process.exit(2);
240
+ }
241
+ console.error(chalk_1.default.red(msg));
242
+ process.exit(2);
243
+ }
244
+ drift = (0, lock_1.classifyDrift)(resources, lockState.lock, live);
245
+ const moved = drift.filter(lock_1.isProductionDrift);
246
+ if (moved.length > 0 && !dryRun) {
247
+ if (json) {
248
+ (0, json_output_1.emitJson)({ error: 'production changed since the last apply', drift: moved });
249
+ process.exit(3);
250
+ }
251
+ console.error(chalk_1.default.red(`Refusing to apply: ${moved.length} resource(s) changed in production since the last apply.`));
252
+ printDrift(moved);
253
+ console.error(chalk_1.default.dim('Re-apply without --strict to overwrite production, or update the manifest to match it.'));
254
+ process.exit(3);
255
+ }
256
+ }
257
+ const allActions = [];
258
+ const planByKind = [];
259
+ for (const [kind, desired] of byKind) {
260
+ const { recon, items } = live.get(kind);
261
+ const actions = (0, engine_1.planKind)(recon, desired, items, { prune: options.prune });
262
+ planByKind.push({ kind, actions });
263
+ allActions.push(...actions);
264
+ }
110
265
  const changes = allActions.filter((a) => a.action === 'create' || a.action === 'update' || a.action === 'prune');
111
266
  const prunes = allActions.filter((a) => a.action === 'prune');
112
267
  // Confirm destructive prunes before any write (unless --yes / --dry-run / --json).
113
- if (!dryRun && prunes.length > 0 && !options.yes && !(0, json_output_1.isJsonOutput)(options)) {
268
+ if (!dryRun && prunes.length > 0 && !options.yes && !json) {
114
269
  const ok = await (0, command_kit_1.confirm)(`Prune ${prunes.length} resource(s)? This deletes them.`);
115
270
  if (!ok) {
116
271
  console.error(chalk_1.default.yellow('Aborted.'));
117
272
  process.exit(1);
118
273
  }
119
274
  }
120
- if (!dryRun && prunes.length > 0 && !options.yes && (0, json_output_1.isJsonOutput)(options)) {
275
+ if (!dryRun && prunes.length > 0 && !options.yes && json) {
121
276
  (0, json_output_1.emitJson)({ error: 'Refusing to prune without --yes in non-interactive/json mode', prunes: prunes.length });
122
277
  process.exit(1);
123
278
  }
@@ -127,23 +282,253 @@ exports.applyCommand = new commander_1.Command('apply')
127
282
  results.push(...(await (0, engine_1.executePlan)(api_client_1.apiClient, recon, actions, dryRun)));
128
283
  }
129
284
  const counts = (0, engine_1.tallyResults)(results);
130
- if ((0, json_output_1.isJsonOutput)(options)) {
131
- return void (0, json_output_1.emitJson)({ dryRun, counts, actions: results });
285
+ let lockWritten;
286
+ let runId;
287
+ if (!dryRun && lockState) {
288
+ const base = lockState.lock ?? (0, lock_1.emptyLock)(config_1.config.companyId, file, cliVersion());
289
+ const merged = (0, lock_1.mergeRun)(base, results, { cliVersion: cliVersion() });
290
+ (0, lock_1.writeLock)(lockState.path, merged.lock);
291
+ lockWritten = lockState.path;
292
+ runId = merged.run.run_id;
132
293
  }
133
- for (const r of results) {
134
- const tag = ICON[r.action] ?? r.action;
135
- const suffix = r.status === 'failed' ? chalk_1.default.red(` FAILED: ${r.error}`)
136
- : r.reason ? chalk_1.default.dim(` (${r.reason})`) : '';
137
- console.log(` ${tag} ${chalk_1.default.cyan(r.kind)}/${r.identity}${suffix}`);
294
+ if (json) {
295
+ return void (0, json_output_1.emitJson)({ dryRun, counts, actions: results, lock: lockWritten ?? null, run_id: runId ?? null, drift: drift ?? undefined });
138
296
  }
297
+ printResults(results);
139
298
  const verb = dryRun ? 'Plan' : 'Applied';
140
299
  console.log(`\n${chalk_1.default.bold(verb)}: ${counts.create} to create, ${counts.update} to update, ` +
141
300
  `${counts.noop} unchanged, ${counts.prune} to prune` +
142
301
  (counts.failed ? chalk_1.default.red(`, ${counts.failed} failed`) : '') +
143
- (counts.unsupported ? chalk_1.default.magenta(`, ${counts.unsupported} immutable`) : ''));
302
+ (counts.unsupported ? chalk_1.default.magenta(`, ${counts.unsupported} unsupported`) : ''));
144
303
  if (dryRun && changes.length > 0)
145
304
  console.log(chalk_1.default.dim('Run without --dry-run to apply.'));
305
+ if (lockWritten)
306
+ console.log(chalk_1.default.dim(`Lock: ${path.relative(process.cwd(), lockWritten)} run ${runId}`));
146
307
  if (counts.failed > 0)
147
308
  process.exit(1);
148
309
  });
310
+ // ── solid apply drift <file> ─────────────────────────────────────────────────
311
+ exports.applyCommand
312
+ .command('drift')
313
+ .description('Three-way comparison: manifest (git) vs lock (last apply) vs production. Exit 1 when production moved.')
314
+ .argument('<file>', 'Manifest file')
315
+ .option('--fail-on-any', 'Also exit 1 for manifest changes, missing and never-applied resources')
316
+ .option('--json', 'Machine-readable report')
317
+ .action(async (file, options) => {
318
+ const json = (0, json_output_1.isJsonOutput)(options);
319
+ (0, command_kit_1.requireCompanyContext)();
320
+ const resources = readManifest(file);
321
+ const lockPath = (0, lock_1.lockPathFor)(path.resolve(file));
322
+ let lock = null;
323
+ try {
324
+ lock = (0, lock_1.readLock)(lockPath);
325
+ }
326
+ catch (e) {
327
+ const msg = e instanceof Error ? e.message : String(e);
328
+ if (json) {
329
+ (0, json_output_1.emitJson)({ error: msg });
330
+ process.exit(1);
331
+ }
332
+ console.error(chalk_1.default.red(msg));
333
+ process.exit(1);
334
+ }
335
+ const live = await fetchLive(groupByKind(resources).keys(), json);
336
+ const entries = (0, lock_1.classifyDrift)(resources, lock, live);
337
+ const production = entries.filter(lock_1.isProductionDrift);
338
+ const pending = entries.filter((e) => !(0, lock_1.isProductionDrift)(e) && e.state !== 'in_sync');
339
+ const code = production.length > 0 ? 1 : (options.failOnAny && pending.length > 0 ? 1 : 0);
340
+ if (json) {
341
+ (0, json_output_1.emitJson)({
342
+ lock: lock ? path.relative(process.cwd(), lockPath) : null,
343
+ last_applied: lock?.applied_at ?? null,
344
+ production_drift: production.length, pending: pending.length,
345
+ entries,
346
+ });
347
+ process.exit(code);
348
+ }
349
+ if (!lock)
350
+ console.log(chalk_1.default.yellow(`No lockfile at ${path.relative(process.cwd(), lockPath)} — nothing has been applied from this manifest yet, so there is no baseline.`));
351
+ else
352
+ console.log(chalk_1.default.dim(`Baseline: ${path.relative(process.cwd(), lockPath)} (applied ${lock.applied_at})`));
353
+ printDrift(entries);
354
+ console.log(`\n${chalk_1.default.bold('Drift')}: ${production.length} changed in production, ${pending.length} pending from git, ` +
355
+ `${entries.filter((e) => e.state === 'in_sync').length} in sync`);
356
+ if (production.length)
357
+ console.log(chalk_1.default.dim('Production moved since the last apply. `solid apply <file>` overwrites it; update the manifest to keep it.'));
358
+ process.exit(code);
359
+ });
360
+ // ── solid apply rollback <file> ──────────────────────────────────────────────
361
+ exports.applyCommand
362
+ .command('rollback')
363
+ .description('Revert a recorded run from its pre-images, most recent write first, then verify. Ordered, not atomic.')
364
+ .argument('<file>', 'Manifest file (its lock holds the runs)')
365
+ .option('--run <id>', 'Run to revert (default: the last apply run)')
366
+ .option('--dry-run', 'Show the revert plan without writing')
367
+ .option('--yes', 'Skip confirmation')
368
+ .option('--json', 'Machine-readable result')
369
+ .action(async (file, options) => {
370
+ const json = (0, json_output_1.isJsonOutput)(options);
371
+ (0, command_kit_1.requireCompanyContext)();
372
+ const lockState = lockFor(file, true, json);
373
+ if (!lockState.lock || lockState.lock.runs.length === 0) {
374
+ const msg = `No runs recorded in ${path.relative(process.cwd(), lockState.path)} — nothing to roll back.`;
375
+ if (json) {
376
+ (0, json_output_1.emitJson)({ error: msg });
377
+ process.exit(1);
378
+ }
379
+ console.error(chalk_1.default.yellow(msg));
380
+ process.exit(1);
381
+ }
382
+ const lock = lockState.lock;
383
+ const run = options.run
384
+ ? lock.runs.find((r) => r.run_id === options.run)
385
+ : [...lock.runs].reverse().find((r) => r.kind === 'apply' && r.actions.length > 0);
386
+ if (!run) {
387
+ const msg = options.run ? `Run ${options.run} not found; see \`solid apply history ${file}\`` : 'No apply run with writes to revert.';
388
+ if (json) {
389
+ (0, json_output_1.emitJson)({ error: msg });
390
+ process.exit(1);
391
+ }
392
+ console.error(chalk_1.default.red(msg));
393
+ process.exit(1);
394
+ }
395
+ const dryRun = !!options.dryRun || (0, dry_run_1.isDryRun)();
396
+ const { actions, skipped } = (0, lock_1.buildRollbackPlan)(run, registry_1.reconcilerFor);
397
+ if (!json) {
398
+ console.log(chalk_1.default.bold(`Reverting run ${run.run_id} (${run.at}) — ${actions.length} action(s)` + (skipped.length ? `, ${skipped.length} not revertible` : '')));
399
+ for (const a of actions)
400
+ console.log(` ${ICON[a.action]} ${chalk_1.default.cyan(a.kind)}/${a.identity}`);
401
+ for (const s of skipped)
402
+ console.log(` ${chalk_1.default.magenta('! skip')} ${chalk_1.default.cyan(s.kind)}/${s.identity} ${chalk_1.default.dim(`(${s.reason})`)}`);
403
+ }
404
+ if (actions.length === 0) {
405
+ if (json)
406
+ return void (0, json_output_1.emitJson)({ run_id: run.run_id, dryRun, actions: [], skipped });
407
+ console.log(chalk_1.default.yellow('\nNothing revertible in this run.'));
408
+ return;
409
+ }
410
+ if (!dryRun && !options.yes) {
411
+ if (json) {
412
+ (0, json_output_1.emitJson)({ error: 'Refusing to roll back without --yes in json mode', run_id: run.run_id, actions, skipped });
413
+ process.exit(1);
414
+ }
415
+ const ok = await (0, command_kit_1.confirm)(`Revert ${actions.length} write(s) on production?`);
416
+ if (!ok) {
417
+ console.error(chalk_1.default.yellow('Aborted.'));
418
+ process.exit(1);
419
+ }
420
+ }
421
+ // Execute per kind, preserving reverse order within the run.
422
+ const results = [];
423
+ for (const a of actions) {
424
+ const recon = (0, registry_1.reconcilerFor)(a.kind);
425
+ results.push(...(await (0, engine_1.executePlan)(api_client_1.apiClient, recon, [a], dryRun)));
426
+ }
427
+ // Verification pass: re-read and check each reverted update now matches its target.
428
+ const verified = [];
429
+ if (!dryRun) {
430
+ const live = await fetchLive(new Set(actions.map((a) => a.kind)), json);
431
+ for (const a of actions) {
432
+ if (a.action !== 'update' || !a.spec)
433
+ continue;
434
+ const { recon, items } = live.get(a.kind);
435
+ const item = items.find((i) => String(i[recon.identity]) === a.identity);
436
+ const ok = !!item && Object.keys(a.spec).every((k) => JSON.stringify(item[k] ?? null) === JSON.stringify(a.spec[k] ?? null));
437
+ verified.push({ kind: a.kind, identity: a.identity, ok });
438
+ }
439
+ const merged = (0, lock_1.mergeRun)(lock, results, { cliVersion: cliVersion(), kind: 'rollback', reverts: run.run_id });
440
+ (0, lock_1.writeLock)(lockState.path, merged.lock);
441
+ }
442
+ const counts = (0, engine_1.tallyResults)(results);
443
+ const failedVerify = verified.filter((v) => !v.ok);
444
+ if (json) {
445
+ (0, json_output_1.emitJson)({ reverted: run.run_id, dryRun, counts, actions: results, skipped, verified });
446
+ process.exit(counts.failed || failedVerify.length ? 1 : 0);
447
+ }
448
+ console.log();
449
+ printResults(results);
450
+ for (const v of verified)
451
+ console.log(` ${v.ok ? chalk_1.default.green('✓ verified') : chalk_1.default.red('✗ mismatch')} ${chalk_1.default.cyan(v.kind)}/${v.identity}`);
452
+ console.log(`\n${chalk_1.default.bold(dryRun ? 'Revert plan' : 'Reverted')}: ${counts.create} recreated, ${counts.update} restored, ${counts.prune} removed` +
453
+ (counts.failed ? chalk_1.default.red(`, ${counts.failed} failed`) : '') + (failedVerify.length ? chalk_1.default.red(`, ${failedVerify.length} did not verify`) : ''));
454
+ if (!dryRun)
455
+ console.log(chalk_1.default.dim('Rollback is ordered and verified, not atomic: a failure above leaves earlier reverts in place.'));
456
+ if (counts.failed || failedVerify.length)
457
+ process.exit(1);
458
+ });
459
+ // ── solid apply history <file> ───────────────────────────────────────────────
460
+ exports.applyCommand
461
+ .command('history')
462
+ .description('Runs recorded in the lockfile (what each apply or rollback wrote)')
463
+ .argument('<file>', 'Manifest file')
464
+ .option('--json', 'Machine-readable list')
465
+ .action((file, options) => {
466
+ const json = (0, json_output_1.isJsonOutput)(options);
467
+ const lockPath = (0, lock_1.lockPathFor)(path.resolve(file));
468
+ let lock = null;
469
+ try {
470
+ lock = (0, lock_1.readLock)(lockPath);
471
+ }
472
+ catch (e) {
473
+ const msg = e instanceof Error ? e.message : String(e);
474
+ if (json) {
475
+ (0, json_output_1.emitJson)({ error: msg });
476
+ process.exit(1);
477
+ }
478
+ console.error(chalk_1.default.red(msg));
479
+ process.exit(1);
480
+ }
481
+ if (json)
482
+ return void (0, json_output_1.emitJson)({ lock: lock ? path.relative(process.cwd(), lockPath) : null, runs: lock?.runs ?? [], resources: Object.keys(lock?.resources ?? {}).length });
483
+ if (!lock) {
484
+ console.log(chalk_1.default.yellow(`No lockfile at ${path.relative(process.cwd(), lockPath)}.`));
485
+ return;
486
+ }
487
+ console.log(chalk_1.default.dim(`${path.relative(process.cwd(), lockPath)} — ${Object.keys(lock.resources).length} resource(s) tracked, company ${lock.company_id}`));
488
+ for (const r of lock.runs) {
489
+ const c = { create: 0, update: 0, prune: 0 };
490
+ for (const a of r.actions)
491
+ c[a.action]++;
492
+ const tag = r.kind === 'rollback' ? chalk_1.default.yellow(`rollback of ${r.reverts}`) : chalk_1.default.green('apply');
493
+ console.log(` ${r.run_id} ${r.at} ${tag} +${c.create} ~${c.update} -${c.prune} ${chalk_1.default.dim(`cli ${r.cli_version}`)}`);
494
+ }
495
+ });
496
+ // ── solid apply export [file] ────────────────────────────────────────────────
497
+ exports.applyCommand
498
+ .command('export')
499
+ .description('Write the live tenant as an apply manifest (all kinds, or --kinds a,b)')
500
+ .argument('[file]', 'Output file (default: stdout)')
501
+ .option('--kinds <list>', 'Comma-separated kinds to export (default: all)')
502
+ .option('--json', 'Emit JSON instead of YAML')
503
+ .action(async (file, options) => {
504
+ const json = (0, json_output_1.isJsonOutput)(options);
505
+ (0, command_kit_1.requireCompanyContext)();
506
+ const kinds = options.kinds ? options.kinds.split(',').map((k) => k.trim()).filter(Boolean) : (0, registry_1.knownKinds)();
507
+ const unknown = kinds.filter((k) => !(0, registry_1.reconcilerFor)(k));
508
+ if (unknown.length) {
509
+ console.error(chalk_1.default.red(`Unknown kind(s): ${unknown.join(', ')} — see \`solid apply --kinds\``));
510
+ process.exit(2);
511
+ }
512
+ if (file)
513
+ (0, tenant_guard_1.refuseProtectedRoot)(path.dirname(path.resolve(file)));
514
+ const live = await fetchLive(kinds, json);
515
+ const docs = [];
516
+ for (const kind of kinds) {
517
+ const { recon, items } = live.get(kind);
518
+ for (const item of items)
519
+ docs.push({ kind, spec: (0, lock_1.shapeForExport)(recon, item) });
520
+ }
521
+ const text = json
522
+ ? JSON.stringify({ resources: docs.map((d) => ({ kind: d.kind, ...d.spec })) }, null, 2) + '\n'
523
+ : (0, lock_1.toManifestYaml)(docs, (o) => yaml.dump(o, { lineWidth: 120, noRefs: true }));
524
+ if (file) {
525
+ fs.writeFileSync(file, text);
526
+ if (json)
527
+ return void (0, json_output_1.emitJson)({ file, resources: docs.length, kinds });
528
+ console.log(chalk_1.default.green(`Wrote ${docs.length} resource(s) across ${kinds.length} kind(s) to ${file}`));
529
+ console.log(chalk_1.default.dim('Review before applying elsewhere: per-company ids (agent_id, site_id) do not travel between tenants.'));
530
+ return;
531
+ }
532
+ process.stdout.write(text);
533
+ });
149
534
  //# sourceMappingURL=apply.js.map