@thejoseki/clawform 2.3.0 → 2.3.2

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 (48) hide show
  1. package/README.md +141 -120
  2. package/bin/clawform.js +161 -151
  3. package/package.json +65 -65
  4. package/src/aws/catalog.js +116 -116
  5. package/src/aws/changeset.js +231 -231
  6. package/src/aws/drift.js +59 -59
  7. package/src/aws/exports.js +213 -213
  8. package/src/aws/inventory.js +156 -156
  9. package/src/commands/activate.js +34 -2
  10. package/src/commands/ci-init.js +393 -212
  11. package/src/commands/cost-report.js +163 -163
  12. package/src/commands/deactivate.js +9 -2
  13. package/src/commands/deploy.js +413 -413
  14. package/src/commands/diff.js +39 -39
  15. package/src/commands/drift.js +35 -35
  16. package/src/commands/init-plugin.js +6 -6
  17. package/src/commands/init.js +360 -360
  18. package/src/commands/rollback.js +126 -126
  19. package/src/commands/status.js +147 -147
  20. package/src/commands/sync.js +50 -50
  21. package/src/commands/update.js +24 -0
  22. package/src/commands/validate.js +349 -349
  23. package/src/hooks/block-dangerous.js +62 -62
  24. package/src/hooks/cfn-lint-after-edit-eval.js +30 -30
  25. package/src/hooks/cfn-lint-after-edit.js +81 -81
  26. package/src/hooks/check-catalog-fresh-eval.js +63 -63
  27. package/src/hooks/check-catalog-fresh.js +33 -33
  28. package/src/hooks/session-context-eval.js +81 -81
  29. package/src/hooks/session-context.js +41 -41
  30. package/src/hooks/subagent-context-eval.js +44 -44
  31. package/src/hooks/subagent-context.js +48 -48
  32. package/src/lib/audit-synthesis.js +24 -24
  33. package/src/lib/aws-client.js +15 -15
  34. package/src/lib/cfn-yaml.js +92 -92
  35. package/src/lib/config.js +76 -76
  36. package/src/lib/constants.js +85 -85
  37. package/src/lib/defaults.js +16 -16
  38. package/src/lib/install-workflow.js +28 -28
  39. package/src/lib/kit-source.js +43 -5
  40. package/src/lib/license-client.js +84 -84
  41. package/src/lib/license-config.js +162 -162
  42. package/src/lib/license-store.js +43 -8
  43. package/src/lib/license.js +15 -2
  44. package/src/lib/lint-overrides.js +226 -226
  45. package/src/lib/logger.js +16 -16
  46. package/src/lib/project-config.js +145 -145
  47. package/src/lib/providers/polar.js +215 -215
  48. package/src/lib/session-state.js +91 -91
@@ -1,413 +1,413 @@
1
- import { readFile } from 'node:fs/promises';
2
- import inquirer from 'inquirer';
3
- import chalk from 'chalk';
4
- import { readEnv } from '../lib/rename-compat.js';
5
- import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
6
- import { logger } from '../lib/logger.js';
7
- import { cfnClient } from '../lib/aws-client.js';
8
- import { loadConfig, resolveStackTags } from '../lib/config.js';
9
- import { loadProjectConfig, buildLegacyPrefixRegex, buildAnchoredLegacyPrefixRegex, resolveProjectDir } from '../lib/project-config.js';
10
- import { addPendingChangeset, clearPendingChangeset } from '../lib/session-state.js';
11
- import { ENVS, isDataResource } from '../lib/constants.js';
12
- import { loadCfnTemplate, extractExportingOutputKeys, extractLiteralExportNames } from '../lib/cfn-yaml.js';
13
- import {
14
- findRemovedExports,
15
- checkExportLock,
16
- assertNoFrozenExports,
17
- assertNoFrozenTemplateExports,
18
- } from '../aws/exports.js';
19
- import {
20
- stackStatus,
21
- decideChangeSetType,
22
- createAndWait,
23
- classifyChanges,
24
- executeAndPoll,
25
- } from '../aws/changeset.js';
26
-
27
- // Runtime account guard: refuse to deploy when the caller's AWS account does
28
- // not match the consumer's clawform.config.json `accounts[env]` mapping.
29
- //
30
- // Why a separate exported helper:
31
- // - Lets tests mock `@aws-sdk/client-sts` and drive the 4 branches
32
- // (match, mismatch, no-guard-configured, break-glass) without standing up
33
- // the rest of deploy().
34
- // - Keeps the deploy() body linear — one call, one place to land the guard.
35
- //
36
- // Args:
37
- // env — env string ('d' | 'stg' | 'p').
38
- // accounts — projectCfg.accounts (already loaded by deploy()). May be
39
- // missing, empty, or {env: ''} — all "no guard" cases.
40
- // region — AWS region for the STS client (matches the CFN region).
41
- // stsClientFactory — optional override; defaults to a fresh STSClient. Tests
42
- // use vi.mock('@aws-sdk/client-sts') instead and don't need
43
- // this. Kept for callers who want to inject a stub.
44
- //
45
- // Resolution order:
46
- // 1. CLAWFORM_SKIP_ACCOUNT_CHECK=1 → bypass with yellow warning (break-glass).
47
- // 2. accounts[env] empty / undefined → skip with yellow warning.
48
- // 3. STS get-caller-identity → compare → throw on mismatch.
49
- //
50
- // Throws on mismatch. Resolves with how it concluded — 'verified' (STS ran and
51
- // matched), 'skipped' (no account configured, non-prod), or 'bypassed'
52
- // (break-glass env var) — so callers can make the unattended-prod decision.
53
- export async function assertAccountMatches({ env, accounts, region, stsClientFactory } = {}) {
54
- if (readEnv('SKIP_ACCOUNT_CHECK') === '1') {
55
- logger.warn(
56
- `CLAWFORM_SKIP_ACCOUNT_CHECK=1 — bypassing account-match guard for env '${env}'` +
57
- `${env === 'p' ? ' (PRODUCTION)' : ''}. Break-glass only; unset to re-enable.`,
58
- );
59
- return 'bypassed';
60
- }
61
- const expectedRaw = accounts && typeof accounts === 'object' ? accounts[env] : undefined;
62
- const expected = typeof expectedRaw === 'string' ? expectedRaw.trim() : '';
63
- if (!expected) {
64
- // Production is the one env where an unset guard is refused rather than
65
- // warned past — a p deploy into the wrong account is the exact accident
66
- // this check exists for. d/stg stay lenient: an account not yet pinned is
67
- // a legitimate bootstrap state.
68
- if (env === 'p') {
69
- throw new Error(
70
- `Env 'p' requires accounts.p in clawform.config.json — a production deploy without ` +
71
- `an account guard is refused. Add the 12-digit account id (edit the file or rerun clawform init --force).`,
72
- );
73
- }
74
- logger.warn(`No account guard configured for env '${env}' — skipping account-match check.`);
75
- return 'skipped';
76
- }
77
- const client = stsClientFactory ? stsClientFactory() : new STSClient(region ? { region } : {});
78
- const resp = await client.send(new GetCallerIdentityCommand({}));
79
- const caller = resp && resp.Account ? String(resp.Account).trim() : '';
80
- if (caller !== expected) {
81
- throw new Error(
82
- `Account mismatch: caller is ${caller || '(unknown)'} but env '${env}' is configured for ${expected} ` +
83
- `in clawform.config.json. Refusing to deploy.`,
84
- );
85
- }
86
- return 'verified';
87
- }
88
-
89
- // Unattended production deploys are fine for CI — CodePipeline's manual
90
- // approval already puts a human in the loop — but only when the account guard
91
- // genuinely ran and matched. autoApprove combined with a bypassed guard on p
92
- // would mean nothing checked which account is being written to.
93
- export function assertProdDeployAllowed({ env, autoApprove, accountCheckMode } = {}) {
94
- if (env !== 'p' || !autoApprove) return;
95
- if (accountCheckMode !== 'verified') {
96
- throw new Error(
97
- `--auto-approve is refused for a production deploy unless the account guard verified the ` +
98
- `caller (guard was '${accountCheckMode}'). Configure accounts.p and unset CLAWFORM_SKIP_ACCOUNT_CHECK, ` +
99
- `or run interactively.`,
100
- );
101
- }
102
- }
103
-
104
- // C1 (export-lock pre-check): an UPDATE that removes (or un-exports) a live
105
- // export still imported elsewhere is guaranteed to fail at execute time with
106
- // "Export ... in use by" and roll back. Detect it BEFORE creating the changeset
107
- // (all inputs — live Outputs and the template body — exist by then) and hand
108
- // the operator the two-step migration instead of a wedged stack (rules/limits.md).
109
- //
110
- // Fail-open on our own inability to check (parse failure, ListImports denied
111
- // with no importer seen) — CloudFormation still refuses the real removal if
112
- // it's genuinely in use. Fail-closed on any confirmed importer, including
113
- // partial pagination proof (see checkExportLock).
114
- export async function assertExportLockClear({ client, liveOutputs, templateBody } = {}) {
115
- const exportsLive = (liveOutputs ?? []).filter((o) => o && o.ExportName);
116
- if (exportsLive.length === 0) return; // nothing exported → no lock possible
117
- const doc = loadCfnTemplate(templateBody);
118
- if (!doc) return; // unparseable → linters own the error
119
- const removed = findRemovedExports(
120
- liveOutputs,
121
- extractExportingOutputKeys(doc),
122
- extractLiteralExportNames(doc), // same export name under a new OutputKey = moved, not removed
123
- );
124
- if (removed.length === 0) return;
125
- const { blocked, unchecked } = await checkExportLock(client, removed);
126
- for (const u of unchecked) {
127
- logger.warn(
128
- `Could not verify importers for export "${u.exportName}" (${u.error}) — proceeding; ` +
129
- `CloudFormation will still refuse the removal if it's in use.`,
130
- );
131
- }
132
- if (blocked.length === 0) return;
133
- const lines = blocked.map((b) => ` • ${b.exportName} — imported by: ${b.importers.join(', ')}`);
134
- throw new Error(
135
- `Export-lock: this update removes ${blocked.length} export(s) still imported by other stack(s):\n` +
136
- `${lines.join('\n')}\n` +
137
- `CloudFormation will reject the changeset ("Export ... in use by") and roll back. ` +
138
- `Use the two-step migration (rules/limits.md):\n` +
139
- ` 1. Keep the old export; add the new export alongside it and deploy this producer first.\n` +
140
- ` 2. Migrate each consumer to the new import, deploy them in dependency order, then retire the\n` +
141
- ` old export in a final producer deploy once "aws cloudformation list-imports" is empty.`,
142
- );
143
- }
144
-
145
- export default async function deploy({ file, env, stackName, project, params, capabilities, autoApprove }) {
146
- if (!ENVS.includes(env)) {
147
- throw new Error(`Invalid env "${env}". Expected one of: ${ENVS.join(' | ')}`);
148
- }
149
- if (!stackName) throw new Error('--stack-name is required.');
150
- if (!project) throw new Error('--project is required (used as Project tag value).');
151
-
152
- const projectDir = resolveProjectDir();
153
- const projectCfg = loadProjectConfig({ projectDir });
154
- const legacyRe = buildLegacyPrefixRegex(projectCfg.legacy_prefixes);
155
- if (legacyRe && legacyRe.test(stackName)) {
156
- const list = projectCfg.legacy_prefixes.map((p) => `${p}-*`).join(', ');
157
- throw new Error(
158
- `Stack "${stackName}" matches a legacy prefix (${list}) from your clawform.config.json.\n` +
159
- `clawform deploy refuses to modify legacy stacks. See rules/legacy-freeze.md and docs/config-schema.md.\n` +
160
- `Use raw aws cloudformation CLI with a "# FORCE LEGACY: <reason>" comment if modification is unavoidable.`,
161
- );
162
- }
163
-
164
- // loadConfig() still gates AWS_PROFILE / AWS_REGION with the friendly error.
165
- // OWNER / COST_CENTER are no longer required here — resolveStackTags() owns
166
- // the env-var-then-tags_defaults chain and throws with a usable message
167
- // when both sources are empty.
168
- const cfg = loadConfig({});
169
- const stackTags = resolveStackTags(projectCfg, env);
170
-
171
- // Runtime account guard. Runs after loadConfig() so the friendly missing-creds
172
- // error fires first; runs before any CFN call so the engineer doesn't have to
173
- // wait on a changeset round-trip to discover they're pointed at the wrong AWS
174
- // account. See docs/config-schema.md "Wired today" for resolution rules.
175
- const accountCheckMode = await assertAccountMatches({
176
- env,
177
- accounts: projectCfg.accounts,
178
- region: cfg.AWS_REGION,
179
- });
180
- assertProdDeployAllowed({ env, autoApprove, accountCheckMode });
181
-
182
- const templateBody = await readFile(file, 'utf8');
183
-
184
- // C2 introduction gate: refuse a template whose literal Export.Name strings
185
- // would CREATE a legacy-prefixed export (covers CREATE deploys too — the
186
- // live-outputs gate below only sees exports that already exist). Anchored
187
- // regex: the prefix must lead the export name, so legacy tokens mid-name in
188
- // convention-compliant exports don't false-positive.
189
- const anchoredLegacyRe = buildAnchoredLegacyPrefixRegex(projectCfg.legacy_prefixes);
190
- assertNoFrozenTemplateExports({
191
- templateExportNames: extractLiteralExportNames(loadCfnTemplate(templateBody)),
192
- legacyRe: anchoredLegacyRe,
193
- stackName,
194
- });
195
-
196
- let parameters = [];
197
- if (params) {
198
- const raw = await readFile(params, 'utf8');
199
- parameters = JSON.parse(raw);
200
- if (!Array.isArray(parameters)) {
201
- throw new Error(`${params}: expected JSON array of {ParameterKey, ParameterValue}.`);
202
- }
203
- }
204
-
205
- // Tag key spelling pinned by rules/tagging.md — Env (not Environment), and
206
- // the 5 mandatory keys (Project, Env, Owner, CostCenter, ManagedBy). Customer
207
- // from resolveStackTags is informational and not emitted as a tag here.
208
- const tags = [
209
- { Key: 'Project', Value: project },
210
- { Key: 'Env', Value: stackTags.Environment },
211
- { Key: 'Owner', Value: stackTags.Owner },
212
- { Key: 'CostCenter', Value: stackTags.CostCenter },
213
- { Key: 'ManagedBy', Value: stackTags.ManagedBy },
214
- ];
215
-
216
- const caps = capabilities ? capabilities.split(',').map((s) => s.trim()).filter(Boolean) : [];
217
-
218
- const client = cfnClient();
219
- const status = await stackStatus(client, stackName);
220
-
221
- if (status.exists) {
222
- // C2: frozen-export gate — refuse a stack emitting legacy-prefixed exports
223
- // even when its name doesn't match. Anchored regex (leading segment only).
224
- assertNoFrozenExports({ liveOutputs: status.stack?.Outputs, legacyRe: anchoredLegacyRe, stackName });
225
-
226
- // C1: export-lock pre-check. Runs BEFORE the changeset — both inputs (live
227
- // Outputs, template body) already exist, and a blocked deploy should not
228
- // pay a CreateChangeSet round-trip or leave an orphaned changeset behind.
229
- await assertExportLockClear({ client, liveOutputs: status.stack?.Outputs, templateBody });
230
- }
231
-
232
- const changeSetType = decideChangeSetType(status);
233
- logger.info(`Stack ${stackName}: ${status.exists ? status.status : '(does not exist)'} → ${changeSetType}`);
234
-
235
- logger.step(`Creating change-set on ${stackName}…`);
236
- const cs = await createAndWait(client, {
237
- stackName,
238
- changeSetType,
239
- templateBody,
240
- parameters,
241
- tags,
242
- capabilities: caps,
243
- });
244
-
245
- if (cs.isEmpty) {
246
- logger.ok(`No changes (${cs.statusReason}). Empty change-set deleted.`);
247
- return;
248
- }
249
-
250
- // In-flight state: the changeset now exists in AWS but hasn't executed. Record
251
- // it so that if the operator aborts at the confirm prompt (or runs /clear),
252
- // the next session's context hook warns a changeset is sitting unexecuted.
253
- // Cleared after execute below. Fail-open — a state-write problem must never
254
- // block the deploy.
255
- try {
256
- addPendingChangeset(projectDir, {
257
- stack: stackName,
258
- changeSetName: cs.name,
259
- env,
260
- createdAt: new Date().toISOString(),
261
- });
262
- } catch (err) {
263
- logger.warn(`Could not record pending changeset state (continuing): ${err.message}`);
264
- }
265
-
266
- const buckets = classifyChanges(cs.changes);
267
- renderChangeTable(buckets);
268
-
269
- const dataRemove = buckets.remove.filter((r) => isDataResource(r.resourceType));
270
- const needsRetype = buckets.dataResourceReplace.length > 0 || dataRemove.length > 0;
271
-
272
- if (autoApprove) {
273
- if (buckets.replace.length > 0 || needsRetype) {
274
- throw new Error('--auto-approve refused: change-set includes Replace or data-resource removal.');
275
- }
276
- logger.info('Proceeding (--auto-approve, no Replace / data removal).');
277
- } else {
278
- // Production always earns the retype gate, whatever the changeset holds —
279
- // the extra keystroke is the point. Folded into the same prompt as the
280
- // data-resource retype so an operator never types the name twice.
281
- const prodRetype = env === 'p';
282
- if (prodRetype && !needsRetype) {
283
- logger.warn(`Production deploy to '${stackName}' — confirm by retyping the stack name.`);
284
- }
285
- if (needsRetype || prodRetype) {
286
- if (buckets.dataResourceReplace.length > 0) {
287
- logger.warn(`${buckets.dataResourceReplace.length} data resource(s) will be REPLACED — physical resource recreated.`);
288
- // A4 (plain language): spell out the consequence so a non-DevOps operator
289
- // understands what "Replace" costs before they retype the stack name.
290
- logger.warn(' In plain terms: a brand-new resource is created and the old one is destroyed. Live data');
291
- logger.warn(' on the old resource is NOT migrated — it survives only as a snapshot if DeletionPolicy/');
292
- logger.warn(' UpdateReplacePolicy is Snapshot (RDS/Aurora) or Retain. Verify that before continuing.');
293
- }
294
- if (dataRemove.length > 0) {
295
- logger.warn(`${dataRemove.length} data resource(s) will be REMOVED from the stack — physical fate depends on DeletionPolicy (change-set does not expose it).`);
296
- for (const r of dataRemove) logger.warn(` ${r.logicalId} (${r.resourceType})`);
297
- logger.warn(' In plain terms: CloudFormation stops managing these. If DeletionPolicy is Retain/Snapshot the');
298
- logger.warn(' underlying data survives; otherwise it is deleted. Confirm the policy before continuing.');
299
- }
300
- const { typed } = await inquirer.prompt([{
301
- type: 'input',
302
- name: 'typed',
303
- message: `Type the stack name EXACTLY to confirm:`,
304
- validate: (v) => v.trim() === stackName ? true : `Type "${stackName}" exactly.`,
305
- }]);
306
- if (typed.trim() !== stackName) throw new Error('Aborted: stack name did not match.');
307
- }
308
- const { ok } = await inquirer.prompt([{
309
- type: 'confirm',
310
- name: 'ok',
311
- message: `Execute change-set ${cs.name} on ${stackName}?`,
312
- default: false,
313
- }]);
314
- if (!ok) {
315
- logger.warn('Aborted by user. Change-set kept (delete via AWS console or rerun).');
316
- return;
317
- }
318
- }
319
-
320
- logger.step(`Executing change-set ${cs.name}…`);
321
- const result = await executeAndPoll(client, {
322
- stackName,
323
- changeSetName: cs.name,
324
- onEvent: (ev) => {
325
- const color = EVENT_COLOR[ev.ResourceStatus] ?? ((s) => s);
326
- const reason = ev.ResourceStatusReason ? ` ${chalk.gray('—')} ${ev.ResourceStatusReason}` : '';
327
- logger.info(` ${(ev.LogicalResourceId ?? '').padEnd(30)} ${color(ev.ResourceStatus)}${reason}`);
328
- },
329
- });
330
-
331
- // The changeset has been executed (success or failure) — no longer pending.
332
- try {
333
- clearPendingChangeset(projectDir, { stack: stackName, changeSetName: cs.name });
334
- } catch { /* fail-open — never let a state-write problem mask the deploy result */ }
335
-
336
- if (/COMPLETE$/.test(result.stackStatus) && !/ROLLBACK/.test(result.stackStatus)) {
337
- logger.ok(`Stack ${stackName} → ${result.stackStatus}`);
338
- const outs = result.stack?.Outputs ?? [];
339
- if (outs.length) {
340
- logger.info('Outputs:');
341
- for (const o of outs) {
342
- logger.info(` ${(o.OutputKey ?? '').padEnd(30)} = ${o.OutputValue ?? ''}`);
343
- }
344
- highlightEndpoints(outs);
345
- }
346
- } else {
347
- logger.error(`Stack ${stackName} → ${result.stackStatus}. Use /rollback to investigate.`);
348
- process.exit(1);
349
- }
350
- }
351
-
352
- // D2 (post-deploy verification): a clean COMPLETE means CloudFormation finished,
353
- // not that the app works. Surface endpoint-like outputs so the operator has
354
- // something concrete to hit before calling the deploy done. Heuristic on the
355
- // OutputKey name — intentionally loose; a false highlight costs nothing.
356
- // Keep in sync with the canonical export suffixes in rules/cfn-standards.md
357
- // (-id, -arn, -name, -dns, -uri) — 'uri' in particular is the convention's own
358
- // endpoint suffix.
359
- const ENDPOINT_KEY_RE = /(url|uri|endpoint|dns|domain|address|hostname|fqdn)/i;
360
-
361
- export function highlightEndpoints(outs) {
362
- const endpoints = (outs ?? []).filter((o) => ENDPOINT_KEY_RE.test(o.OutputKey ?? '') && o.OutputValue);
363
- if (endpoints.length === 0) return;
364
- logger.info('');
365
- logger.info('Endpoints to verify:');
366
- for (const o of endpoints) logger.info(` ${chalk.cyan(o.OutputValue)} ${chalk.gray(`(${o.OutputKey})`)}`);
367
- logger.info(chalk.gray('A clean stack status means CloudFormation finished — confirm each endpoint actually responds before considering the deploy done.'));
368
- }
369
-
370
- const EVENT_COLOR = {
371
- CREATE_COMPLETE: chalk.green,
372
- UPDATE_COMPLETE: chalk.green,
373
- CREATE_IN_PROGRESS: chalk.cyan,
374
- UPDATE_IN_PROGRESS: chalk.cyan,
375
- DELETE_COMPLETE: chalk.gray,
376
- DELETE_IN_PROGRESS: chalk.gray,
377
- CREATE_FAILED: chalk.red,
378
- UPDATE_FAILED: chalk.red,
379
- DELETE_FAILED: chalk.red,
380
- ROLLBACK_IN_PROGRESS: chalk.yellow,
381
- ROLLBACK_COMPLETE: chalk.yellow,
382
- };
383
-
384
- function renderChangeTable(buckets) {
385
- logger.info('');
386
- logger.info('Change-set summary:');
387
- logger.info(` ${chalk.green('+ Add')} ${buckets.add.length}`);
388
- logger.info(` ${chalk.cyan('~ Modify')} ${buckets.modify.length}`);
389
- logger.info(` ${chalk.yellow('! Replace')} ${buckets.replace.length}`);
390
- logger.info(` ${chalk.red('- Remove')} ${buckets.remove.length}`);
391
-
392
- if (buckets.dataResourceReplace.length > 0) {
393
- logger.info('');
394
- logger.warn('Data-resource Replace (DESTRUCTIVE):');
395
- for (const r of buckets.dataResourceReplace) logger.warn(` ${r.logicalId} (${r.resourceType})`);
396
- }
397
- printBucket('Add', buckets.add, chalk.green, '+');
398
- printBucket('Modify', buckets.modify, chalk.cyan, '~', true);
399
- printBucket('Replace', buckets.replace, chalk.yellow, '!');
400
- printBucket('Remove', buckets.remove, chalk.red, '-');
401
- logger.info('');
402
- }
403
-
404
- function printBucket(label, items, color, marker, showProps = false) {
405
- if (items.length === 0) return;
406
- logger.info('');
407
- logger.info(`${label} (${items.length}):`);
408
- for (const r of items) {
409
- const props = showProps && r.detailsTargets.length ? ` props=[${r.detailsTargets.join(', ')}]` : '';
410
- const repl = r.replacement && r.replacement !== 'False' ? ` replacement=${r.replacement}` : '';
411
- logger.info(` ${color(marker)} ${r.logicalId} (${r.resourceType})${repl}${props}`);
412
- }
413
- }
1
+ import { readFile } from 'node:fs/promises';
2
+ import inquirer from 'inquirer';
3
+ import chalk from 'chalk';
4
+ import { readEnv } from '../lib/rename-compat.js';
5
+ import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
6
+ import { logger } from '../lib/logger.js';
7
+ import { cfnClient } from '../lib/aws-client.js';
8
+ import { loadConfig, resolveStackTags } from '../lib/config.js';
9
+ import { loadProjectConfig, buildLegacyPrefixRegex, buildAnchoredLegacyPrefixRegex, resolveProjectDir } from '../lib/project-config.js';
10
+ import { addPendingChangeset, clearPendingChangeset } from '../lib/session-state.js';
11
+ import { ENVS, isDataResource } from '../lib/constants.js';
12
+ import { loadCfnTemplate, extractExportingOutputKeys, extractLiteralExportNames } from '../lib/cfn-yaml.js';
13
+ import {
14
+ findRemovedExports,
15
+ checkExportLock,
16
+ assertNoFrozenExports,
17
+ assertNoFrozenTemplateExports,
18
+ } from '../aws/exports.js';
19
+ import {
20
+ stackStatus,
21
+ decideChangeSetType,
22
+ createAndWait,
23
+ classifyChanges,
24
+ executeAndPoll,
25
+ } from '../aws/changeset.js';
26
+
27
+ // Runtime account guard: refuse to deploy when the caller's AWS account does
28
+ // not match the consumer's clawform.config.json `accounts[env]` mapping.
29
+ //
30
+ // Why a separate exported helper:
31
+ // - Lets tests mock `@aws-sdk/client-sts` and drive the 4 branches
32
+ // (match, mismatch, no-guard-configured, break-glass) without standing up
33
+ // the rest of deploy().
34
+ // - Keeps the deploy() body linear — one call, one place to land the guard.
35
+ //
36
+ // Args:
37
+ // env — env string ('d' | 'stg' | 'p').
38
+ // accounts — projectCfg.accounts (already loaded by deploy()). May be
39
+ // missing, empty, or {env: ''} — all "no guard" cases.
40
+ // region — AWS region for the STS client (matches the CFN region).
41
+ // stsClientFactory — optional override; defaults to a fresh STSClient. Tests
42
+ // use vi.mock('@aws-sdk/client-sts') instead and don't need
43
+ // this. Kept for callers who want to inject a stub.
44
+ //
45
+ // Resolution order:
46
+ // 1. CLAWFORM_SKIP_ACCOUNT_CHECK=1 → bypass with yellow warning (break-glass).
47
+ // 2. accounts[env] empty / undefined → skip with yellow warning.
48
+ // 3. STS get-caller-identity → compare → throw on mismatch.
49
+ //
50
+ // Throws on mismatch. Resolves with how it concluded — 'verified' (STS ran and
51
+ // matched), 'skipped' (no account configured, non-prod), or 'bypassed'
52
+ // (break-glass env var) — so callers can make the unattended-prod decision.
53
+ export async function assertAccountMatches({ env, accounts, region, stsClientFactory } = {}) {
54
+ if (readEnv('SKIP_ACCOUNT_CHECK') === '1') {
55
+ logger.warn(
56
+ `CLAWFORM_SKIP_ACCOUNT_CHECK=1 — bypassing account-match guard for env '${env}'` +
57
+ `${env === 'p' ? ' (PRODUCTION)' : ''}. Break-glass only; unset to re-enable.`,
58
+ );
59
+ return 'bypassed';
60
+ }
61
+ const expectedRaw = accounts && typeof accounts === 'object' ? accounts[env] : undefined;
62
+ const expected = typeof expectedRaw === 'string' ? expectedRaw.trim() : '';
63
+ if (!expected) {
64
+ // Production is the one env where an unset guard is refused rather than
65
+ // warned past — a p deploy into the wrong account is the exact accident
66
+ // this check exists for. d/stg stay lenient: an account not yet pinned is
67
+ // a legitimate bootstrap state.
68
+ if (env === 'p') {
69
+ throw new Error(
70
+ `Env 'p' requires accounts.p in clawform.config.json — a production deploy without ` +
71
+ `an account guard is refused. Add the 12-digit account id (edit the file or rerun clawform init --force).`,
72
+ );
73
+ }
74
+ logger.warn(`No account guard configured for env '${env}' — skipping account-match check.`);
75
+ return 'skipped';
76
+ }
77
+ const client = stsClientFactory ? stsClientFactory() : new STSClient(region ? { region } : {});
78
+ const resp = await client.send(new GetCallerIdentityCommand({}));
79
+ const caller = resp && resp.Account ? String(resp.Account).trim() : '';
80
+ if (caller !== expected) {
81
+ throw new Error(
82
+ `Account mismatch: caller is ${caller || '(unknown)'} but env '${env}' is configured for ${expected} ` +
83
+ `in clawform.config.json. Refusing to deploy.`,
84
+ );
85
+ }
86
+ return 'verified';
87
+ }
88
+
89
+ // Unattended production deploys are fine for CI — CodePipeline's manual
90
+ // approval already puts a human in the loop — but only when the account guard
91
+ // genuinely ran and matched. autoApprove combined with a bypassed guard on p
92
+ // would mean nothing checked which account is being written to.
93
+ export function assertProdDeployAllowed({ env, autoApprove, accountCheckMode } = {}) {
94
+ if (env !== 'p' || !autoApprove) return;
95
+ if (accountCheckMode !== 'verified') {
96
+ throw new Error(
97
+ `--auto-approve is refused for a production deploy unless the account guard verified the ` +
98
+ `caller (guard was '${accountCheckMode}'). Configure accounts.p and unset CLAWFORM_SKIP_ACCOUNT_CHECK, ` +
99
+ `or run interactively.`,
100
+ );
101
+ }
102
+ }
103
+
104
+ // C1 (export-lock pre-check): an UPDATE that removes (or un-exports) a live
105
+ // export still imported elsewhere is guaranteed to fail at execute time with
106
+ // "Export ... in use by" and roll back. Detect it BEFORE creating the changeset
107
+ // (all inputs — live Outputs and the template body — exist by then) and hand
108
+ // the operator the two-step migration instead of a wedged stack (rules/limits.md).
109
+ //
110
+ // Fail-open on our own inability to check (parse failure, ListImports denied
111
+ // with no importer seen) — CloudFormation still refuses the real removal if
112
+ // it's genuinely in use. Fail-closed on any confirmed importer, including
113
+ // partial pagination proof (see checkExportLock).
114
+ export async function assertExportLockClear({ client, liveOutputs, templateBody } = {}) {
115
+ const exportsLive = (liveOutputs ?? []).filter((o) => o && o.ExportName);
116
+ if (exportsLive.length === 0) return; // nothing exported → no lock possible
117
+ const doc = loadCfnTemplate(templateBody);
118
+ if (!doc) return; // unparseable → linters own the error
119
+ const removed = findRemovedExports(
120
+ liveOutputs,
121
+ extractExportingOutputKeys(doc),
122
+ extractLiteralExportNames(doc), // same export name under a new OutputKey = moved, not removed
123
+ );
124
+ if (removed.length === 0) return;
125
+ const { blocked, unchecked } = await checkExportLock(client, removed);
126
+ for (const u of unchecked) {
127
+ logger.warn(
128
+ `Could not verify importers for export "${u.exportName}" (${u.error}) — proceeding; ` +
129
+ `CloudFormation will still refuse the removal if it's in use.`,
130
+ );
131
+ }
132
+ if (blocked.length === 0) return;
133
+ const lines = blocked.map((b) => ` • ${b.exportName} — imported by: ${b.importers.join(', ')}`);
134
+ throw new Error(
135
+ `Export-lock: this update removes ${blocked.length} export(s) still imported by other stack(s):\n` +
136
+ `${lines.join('\n')}\n` +
137
+ `CloudFormation will reject the changeset ("Export ... in use by") and roll back. ` +
138
+ `Use the two-step migration (rules/limits.md):\n` +
139
+ ` 1. Keep the old export; add the new export alongside it and deploy this producer first.\n` +
140
+ ` 2. Migrate each consumer to the new import, deploy them in dependency order, then retire the\n` +
141
+ ` old export in a final producer deploy once "aws cloudformation list-imports" is empty.`,
142
+ );
143
+ }
144
+
145
+ export default async function deploy({ file, env, stackName, project, params, capabilities, autoApprove }) {
146
+ if (!ENVS.includes(env)) {
147
+ throw new Error(`Invalid env "${env}". Expected one of: ${ENVS.join(' | ')}`);
148
+ }
149
+ if (!stackName) throw new Error('--stack-name is required.');
150
+ if (!project) throw new Error('--project is required (used as Project tag value).');
151
+
152
+ const projectDir = resolveProjectDir();
153
+ const projectCfg = loadProjectConfig({ projectDir });
154
+ const legacyRe = buildLegacyPrefixRegex(projectCfg.legacy_prefixes);
155
+ if (legacyRe && legacyRe.test(stackName)) {
156
+ const list = projectCfg.legacy_prefixes.map((p) => `${p}-*`).join(', ');
157
+ throw new Error(
158
+ `Stack "${stackName}" matches a legacy prefix (${list}) from your clawform.config.json.\n` +
159
+ `clawform deploy refuses to modify legacy stacks. See rules/legacy-freeze.md and docs/config-schema.md.\n` +
160
+ `Use raw aws cloudformation CLI with a "# FORCE LEGACY: <reason>" comment if modification is unavoidable.`,
161
+ );
162
+ }
163
+
164
+ // loadConfig() still gates AWS_PROFILE / AWS_REGION with the friendly error.
165
+ // OWNER / COST_CENTER are no longer required here — resolveStackTags() owns
166
+ // the env-var-then-tags_defaults chain and throws with a usable message
167
+ // when both sources are empty.
168
+ const cfg = loadConfig({});
169
+ const stackTags = resolveStackTags(projectCfg, env);
170
+
171
+ // Runtime account guard. Runs after loadConfig() so the friendly missing-creds
172
+ // error fires first; runs before any CFN call so the engineer doesn't have to
173
+ // wait on a changeset round-trip to discover they're pointed at the wrong AWS
174
+ // account. See docs/config-schema.md "Wired today" for resolution rules.
175
+ const accountCheckMode = await assertAccountMatches({
176
+ env,
177
+ accounts: projectCfg.accounts,
178
+ region: cfg.AWS_REGION,
179
+ });
180
+ assertProdDeployAllowed({ env, autoApprove, accountCheckMode });
181
+
182
+ const templateBody = await readFile(file, 'utf8');
183
+
184
+ // C2 introduction gate: refuse a template whose literal Export.Name strings
185
+ // would CREATE a legacy-prefixed export (covers CREATE deploys too — the
186
+ // live-outputs gate below only sees exports that already exist). Anchored
187
+ // regex: the prefix must lead the export name, so legacy tokens mid-name in
188
+ // convention-compliant exports don't false-positive.
189
+ const anchoredLegacyRe = buildAnchoredLegacyPrefixRegex(projectCfg.legacy_prefixes);
190
+ assertNoFrozenTemplateExports({
191
+ templateExportNames: extractLiteralExportNames(loadCfnTemplate(templateBody)),
192
+ legacyRe: anchoredLegacyRe,
193
+ stackName,
194
+ });
195
+
196
+ let parameters = [];
197
+ if (params) {
198
+ const raw = await readFile(params, 'utf8');
199
+ parameters = JSON.parse(raw);
200
+ if (!Array.isArray(parameters)) {
201
+ throw new Error(`${params}: expected JSON array of {ParameterKey, ParameterValue}.`);
202
+ }
203
+ }
204
+
205
+ // Tag key spelling pinned by rules/tagging.md — Env (not Environment), and
206
+ // the 5 mandatory keys (Project, Env, Owner, CostCenter, ManagedBy). Customer
207
+ // from resolveStackTags is informational and not emitted as a tag here.
208
+ const tags = [
209
+ { Key: 'Project', Value: project },
210
+ { Key: 'Env', Value: stackTags.Environment },
211
+ { Key: 'Owner', Value: stackTags.Owner },
212
+ { Key: 'CostCenter', Value: stackTags.CostCenter },
213
+ { Key: 'ManagedBy', Value: stackTags.ManagedBy },
214
+ ];
215
+
216
+ const caps = capabilities ? capabilities.split(',').map((s) => s.trim()).filter(Boolean) : [];
217
+
218
+ const client = cfnClient();
219
+ const status = await stackStatus(client, stackName);
220
+
221
+ if (status.exists) {
222
+ // C2: frozen-export gate — refuse a stack emitting legacy-prefixed exports
223
+ // even when its name doesn't match. Anchored regex (leading segment only).
224
+ assertNoFrozenExports({ liveOutputs: status.stack?.Outputs, legacyRe: anchoredLegacyRe, stackName });
225
+
226
+ // C1: export-lock pre-check. Runs BEFORE the changeset — both inputs (live
227
+ // Outputs, template body) already exist, and a blocked deploy should not
228
+ // pay a CreateChangeSet round-trip or leave an orphaned changeset behind.
229
+ await assertExportLockClear({ client, liveOutputs: status.stack?.Outputs, templateBody });
230
+ }
231
+
232
+ const changeSetType = decideChangeSetType(status);
233
+ logger.info(`Stack ${stackName}: ${status.exists ? status.status : '(does not exist)'} → ${changeSetType}`);
234
+
235
+ logger.step(`Creating change-set on ${stackName}…`);
236
+ const cs = await createAndWait(client, {
237
+ stackName,
238
+ changeSetType,
239
+ templateBody,
240
+ parameters,
241
+ tags,
242
+ capabilities: caps,
243
+ });
244
+
245
+ if (cs.isEmpty) {
246
+ logger.ok(`No changes (${cs.statusReason}). Empty change-set deleted.`);
247
+ return;
248
+ }
249
+
250
+ // In-flight state: the changeset now exists in AWS but hasn't executed. Record
251
+ // it so that if the operator aborts at the confirm prompt (or runs /clear),
252
+ // the next session's context hook warns a changeset is sitting unexecuted.
253
+ // Cleared after execute below. Fail-open — a state-write problem must never
254
+ // block the deploy.
255
+ try {
256
+ addPendingChangeset(projectDir, {
257
+ stack: stackName,
258
+ changeSetName: cs.name,
259
+ env,
260
+ createdAt: new Date().toISOString(),
261
+ });
262
+ } catch (err) {
263
+ logger.warn(`Could not record pending changeset state (continuing): ${err.message}`);
264
+ }
265
+
266
+ const buckets = classifyChanges(cs.changes);
267
+ renderChangeTable(buckets);
268
+
269
+ const dataRemove = buckets.remove.filter((r) => isDataResource(r.resourceType));
270
+ const needsRetype = buckets.dataResourceReplace.length > 0 || dataRemove.length > 0;
271
+
272
+ if (autoApprove) {
273
+ if (buckets.replace.length > 0 || needsRetype) {
274
+ throw new Error('--auto-approve refused: change-set includes Replace or data-resource removal.');
275
+ }
276
+ logger.info('Proceeding (--auto-approve, no Replace / data removal).');
277
+ } else {
278
+ // Production always earns the retype gate, whatever the changeset holds —
279
+ // the extra keystroke is the point. Folded into the same prompt as the
280
+ // data-resource retype so an operator never types the name twice.
281
+ const prodRetype = env === 'p';
282
+ if (prodRetype && !needsRetype) {
283
+ logger.warn(`Production deploy to '${stackName}' — confirm by retyping the stack name.`);
284
+ }
285
+ if (needsRetype || prodRetype) {
286
+ if (buckets.dataResourceReplace.length > 0) {
287
+ logger.warn(`${buckets.dataResourceReplace.length} data resource(s) will be REPLACED — physical resource recreated.`);
288
+ // A4 (plain language): spell out the consequence so a non-DevOps operator
289
+ // understands what "Replace" costs before they retype the stack name.
290
+ logger.warn(' In plain terms: a brand-new resource is created and the old one is destroyed. Live data');
291
+ logger.warn(' on the old resource is NOT migrated — it survives only as a snapshot if DeletionPolicy/');
292
+ logger.warn(' UpdateReplacePolicy is Snapshot (RDS/Aurora) or Retain. Verify that before continuing.');
293
+ }
294
+ if (dataRemove.length > 0) {
295
+ logger.warn(`${dataRemove.length} data resource(s) will be REMOVED from the stack — physical fate depends on DeletionPolicy (change-set does not expose it).`);
296
+ for (const r of dataRemove) logger.warn(` ${r.logicalId} (${r.resourceType})`);
297
+ logger.warn(' In plain terms: CloudFormation stops managing these. If DeletionPolicy is Retain/Snapshot the');
298
+ logger.warn(' underlying data survives; otherwise it is deleted. Confirm the policy before continuing.');
299
+ }
300
+ const { typed } = await inquirer.prompt([{
301
+ type: 'input',
302
+ name: 'typed',
303
+ message: `Type the stack name EXACTLY to confirm:`,
304
+ validate: (v) => v.trim() === stackName ? true : `Type "${stackName}" exactly.`,
305
+ }]);
306
+ if (typed.trim() !== stackName) throw new Error('Aborted: stack name did not match.');
307
+ }
308
+ const { ok } = await inquirer.prompt([{
309
+ type: 'confirm',
310
+ name: 'ok',
311
+ message: `Execute change-set ${cs.name} on ${stackName}?`,
312
+ default: false,
313
+ }]);
314
+ if (!ok) {
315
+ logger.warn('Aborted by user. Change-set kept (delete via AWS console or rerun).');
316
+ return;
317
+ }
318
+ }
319
+
320
+ logger.step(`Executing change-set ${cs.name}…`);
321
+ const result = await executeAndPoll(client, {
322
+ stackName,
323
+ changeSetName: cs.name,
324
+ onEvent: (ev) => {
325
+ const color = EVENT_COLOR[ev.ResourceStatus] ?? ((s) => s);
326
+ const reason = ev.ResourceStatusReason ? ` ${chalk.gray('—')} ${ev.ResourceStatusReason}` : '';
327
+ logger.info(` ${(ev.LogicalResourceId ?? '').padEnd(30)} ${color(ev.ResourceStatus)}${reason}`);
328
+ },
329
+ });
330
+
331
+ // The changeset has been executed (success or failure) — no longer pending.
332
+ try {
333
+ clearPendingChangeset(projectDir, { stack: stackName, changeSetName: cs.name });
334
+ } catch { /* fail-open — never let a state-write problem mask the deploy result */ }
335
+
336
+ if (/COMPLETE$/.test(result.stackStatus) && !/ROLLBACK/.test(result.stackStatus)) {
337
+ logger.ok(`Stack ${stackName} → ${result.stackStatus}`);
338
+ const outs = result.stack?.Outputs ?? [];
339
+ if (outs.length) {
340
+ logger.info('Outputs:');
341
+ for (const o of outs) {
342
+ logger.info(` ${(o.OutputKey ?? '').padEnd(30)} = ${o.OutputValue ?? ''}`);
343
+ }
344
+ highlightEndpoints(outs);
345
+ }
346
+ } else {
347
+ logger.error(`Stack ${stackName} → ${result.stackStatus}. Use /rollback to investigate.`);
348
+ process.exit(1);
349
+ }
350
+ }
351
+
352
+ // D2 (post-deploy verification): a clean COMPLETE means CloudFormation finished,
353
+ // not that the app works. Surface endpoint-like outputs so the operator has
354
+ // something concrete to hit before calling the deploy done. Heuristic on the
355
+ // OutputKey name — intentionally loose; a false highlight costs nothing.
356
+ // Keep in sync with the canonical export suffixes in rules/cfn-standards.md
357
+ // (-id, -arn, -name, -dns, -uri) — 'uri' in particular is the convention's own
358
+ // endpoint suffix.
359
+ const ENDPOINT_KEY_RE = /(url|uri|endpoint|dns|domain|address|hostname|fqdn)/i;
360
+
361
+ export function highlightEndpoints(outs) {
362
+ const endpoints = (outs ?? []).filter((o) => ENDPOINT_KEY_RE.test(o.OutputKey ?? '') && o.OutputValue);
363
+ if (endpoints.length === 0) return;
364
+ logger.info('');
365
+ logger.info('Endpoints to verify:');
366
+ for (const o of endpoints) logger.info(` ${chalk.cyan(o.OutputValue)} ${chalk.gray(`(${o.OutputKey})`)}`);
367
+ logger.info(chalk.gray('A clean stack status means CloudFormation finished — confirm each endpoint actually responds before considering the deploy done.'));
368
+ }
369
+
370
+ const EVENT_COLOR = {
371
+ CREATE_COMPLETE: chalk.green,
372
+ UPDATE_COMPLETE: chalk.green,
373
+ CREATE_IN_PROGRESS: chalk.cyan,
374
+ UPDATE_IN_PROGRESS: chalk.cyan,
375
+ DELETE_COMPLETE: chalk.gray,
376
+ DELETE_IN_PROGRESS: chalk.gray,
377
+ CREATE_FAILED: chalk.red,
378
+ UPDATE_FAILED: chalk.red,
379
+ DELETE_FAILED: chalk.red,
380
+ ROLLBACK_IN_PROGRESS: chalk.yellow,
381
+ ROLLBACK_COMPLETE: chalk.yellow,
382
+ };
383
+
384
+ function renderChangeTable(buckets) {
385
+ logger.info('');
386
+ logger.info('Change-set summary:');
387
+ logger.info(` ${chalk.green('+ Add')} ${buckets.add.length}`);
388
+ logger.info(` ${chalk.cyan('~ Modify')} ${buckets.modify.length}`);
389
+ logger.info(` ${chalk.yellow('! Replace')} ${buckets.replace.length}`);
390
+ logger.info(` ${chalk.red('- Remove')} ${buckets.remove.length}`);
391
+
392
+ if (buckets.dataResourceReplace.length > 0) {
393
+ logger.info('');
394
+ logger.warn('Data-resource Replace (DESTRUCTIVE):');
395
+ for (const r of buckets.dataResourceReplace) logger.warn(` ${r.logicalId} (${r.resourceType})`);
396
+ }
397
+ printBucket('Add', buckets.add, chalk.green, '+');
398
+ printBucket('Modify', buckets.modify, chalk.cyan, '~', true);
399
+ printBucket('Replace', buckets.replace, chalk.yellow, '!');
400
+ printBucket('Remove', buckets.remove, chalk.red, '-');
401
+ logger.info('');
402
+ }
403
+
404
+ function printBucket(label, items, color, marker, showProps = false) {
405
+ if (items.length === 0) return;
406
+ logger.info('');
407
+ logger.info(`${label} (${items.length}):`);
408
+ for (const r of items) {
409
+ const props = showProps && r.detailsTargets.length ? ` props=[${r.detailsTargets.join(', ')}]` : '';
410
+ const repl = r.replacement && r.replacement !== 'False' ? ` replacement=${r.replacement}` : '';
411
+ logger.info(` ${color(marker)} ${r.logicalId} (${r.resourceType})${repl}${props}`);
412
+ }
413
+ }