@thejoseki/clawform 2.3.1 → 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 +137 -105
  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 +163 -150
  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,126 +1,126 @@
1
- import {
2
- DescribeStacksCommand,
3
- DescribeStackResourcesCommand,
4
- } from '@aws-sdk/client-cloudformation';
5
- import chalk from 'chalk';
6
- import { logger } from '../lib/logger.js';
7
- import { cfnClient } from '../lib/aws-client.js';
8
- import { isDataResource } from '../lib/constants.js';
9
- import { loadProjectConfig, buildLegacyPrefixRegex, buildAnchoredLegacyPrefixRegex, resolveProjectDir } from '../lib/project-config.js';
10
- import { assertNoFrozenExports } from '../aws/exports.js';
11
-
12
- export default async function rollback({ stack }) {
13
- logger.step(`clawform rollback ${stack}`);
14
-
15
- // Legacy-freeze gate (mirrors deploy.js): the freeze applies to recovery,
16
- // not just edits. The CLI refuses outright — no override flag. The
17
- // documented escape hatch is running the raw aws CLI with a
18
- // `# FORCE LEGACY: <reason>` comment, which goes through the PreToolUse
19
- // hook, not this CLI. See rules/legacy-freeze.md.
20
- const projectCfg = loadProjectConfig({ projectDir: resolveProjectDir() });
21
- const legacyRe = buildLegacyPrefixRegex(projectCfg.legacy_prefixes);
22
- if (legacyRe && legacyRe.test(stack)) {
23
- const list = projectCfg.legacy_prefixes.map((p) => `${p}-*`).join(', ');
24
- throw new Error(
25
- `Stack "${stack}" matches a legacy prefix (${list}) from your clawform.config.json.\n` +
26
- `clawform rollback refuses to suggest recovery commands for legacy stacks. See rules/legacy-freeze.md.\n` +
27
- `If recovery is unavoidable, use the raw aws cloudformation CLI with a "# FORCE LEGACY: <reason>" comment on its own line.`,
28
- );
29
- }
30
-
31
- const client = cfnClient();
32
-
33
- const out = await client.send(new DescribeStacksCommand({ StackName: stack }));
34
- const s = out.Stacks?.[0];
35
- if (!s) throw new Error(`Stack ${stack} not found.`);
36
-
37
- // C2: frozen-export gate — shared with deploy.js (src/aws/exports.js). A
38
- // stack whose NAME isn't frozen may still emit legacy-prefixed EXPORT names;
39
- // the freeze applies to recovery too (rules/legacy-freeze.md). Anchored
40
- // regex: prefix must lead the export name.
41
- assertNoFrozenExports({
42
- liveOutputs: s.Outputs,
43
- legacyRe: buildAnchoredLegacyPrefixRegex(projectCfg.legacy_prefixes),
44
- stackName: stack,
45
- action: 'recovery',
46
- });
47
-
48
- logger.info(`Status: ${s.StackStatus}`);
49
-
50
- switch (s.StackStatus) {
51
- case 'UPDATE_ROLLBACK_FAILED':
52
- case 'ROLLBACK_FAILED':
53
- await suggestContinueRollback(client, stack);
54
- break;
55
- case 'CREATE_FAILED':
56
- case 'ROLLBACK_COMPLETE':
57
- await suggestDelete(client, stack);
58
- break;
59
- case 'UPDATE_FAILED':
60
- logger.info('CFN will normally auto-roll-back to UPDATE_ROLLBACK_COMPLETE. If stuck, wait then re-run /rollback.');
61
- break;
62
- case 'CREATE_COMPLETE':
63
- case 'UPDATE_COMPLETE':
64
- case 'UPDATE_ROLLBACK_COMPLETE':
65
- case 'IMPORT_COMPLETE':
66
- logger.ok('Stack is healthy — nothing to roll back.');
67
- break;
68
- default:
69
- if (/_IN_PROGRESS$/.test(s.StackStatus)) {
70
- logger.info('Stack is mid-operation. Wait for terminal status, then re-run /rollback.');
71
- } else {
72
- logger.warn(`Status ${s.StackStatus} has no automatic recovery suggestion.`);
73
- }
74
- }
75
- }
76
-
77
- async function suggestContinueRollback(client, stack) {
78
- const failed = await fetchFailedResources(client, stack);
79
- if (failed.length === 0) {
80
- logger.info(chalk.bold('Suggested:'));
81
- logger.info(` aws cloudformation continue-update-rollback --stack-name ${stack}`);
82
- return;
83
- }
84
- logger.info(`Failed resources (${failed.length}): ${failed.join(', ')}`);
85
- logger.info(chalk.bold('Suggested:'));
86
- logger.info(` aws cloudformation continue-update-rollback --stack-name ${stack} \\`);
87
- logger.info(` --resources-to-skip ${failed.join(' ')}`);
88
- logger.warn('Confirm each skipped resource is acceptable to leave in mixed state before running.');
89
- }
90
-
91
- async function suggestDelete(client, stack) {
92
- const out = await client.send(new DescribeStackResourcesCommand({ StackName: stack }));
93
- const dataResources = (out.StackResources ?? []).filter((r) => isDataResource(r.ResourceType));
94
- if (dataResources.length > 0) {
95
- logger.warn(`Stack contains ${dataResources.length} data resource(s):`);
96
- for (const r of dataResources) logger.warn(` ${r.LogicalResourceId} (${r.ResourceType}) → ${r.PhysicalResourceId ?? '(no physical)'}`);
97
- logger.info('Delete is unsafe. Options:');
98
- logger.info(' 1. Verify each data resource has DeletionPolicy: Retain → delete-stack is safe (physical resource survives)');
99
- logger.info(' 2. Import data resources into a new stack first (see rules/legacy-freeze.md "replace pattern")');
100
- return;
101
- }
102
- logger.ok('No data resources detected.');
103
- logger.info(chalk.bold('Suggested:'));
104
- logger.info(` aws cloudformation delete-stack --stack-name ${stack}`);
105
- logger.info('Then run: aws cloudformation wait stack-delete-complete --stack-name ' + stack);
106
- }
107
-
108
- export async function fetchFailedResources(client, stack) {
109
- // Use DescribeStackResources, not DescribeStackEvents. Two reasons:
110
- // 1. (bug_028) DescribeStackResources returns CURRENT per-resource status. The
111
- // previous DescribeStackEvents+filter walked event HISTORY and added any
112
- // resource that had ever-been-FAILED — so a resource that hit UPDATE_FAILED
113
- // early and was then auto-rolled-back to UPDATE_COMPLETE stayed in the
114
- // suggested --resources-to-skip list. AWS rejects continue-update-rollback
115
- // with "resource X is not in a state to be skipped" for those, defeating
116
- // the whole point of the suggestion.
117
- // 2. (merged_bug_008) DescribeStackEvents is paginated and capped ~100/page;
118
- // after a long forward update + rollback the original FAILED events scroll
119
- // off page 1. DescribeStackResources avoids the pagination question entirely.
120
- // The suggestDelete path already uses DescribeStackResources — this keeps the
121
- // file consistent.
122
- const out = await client.send(new DescribeStackResourcesCommand({ StackName: stack }));
123
- return (out.StackResources ?? [])
124
- .filter((r) => /_FAILED$/.test(r.ResourceStatus ?? '') && r.LogicalResourceId)
125
- .map((r) => r.LogicalResourceId);
126
- }
1
+ import {
2
+ DescribeStacksCommand,
3
+ DescribeStackResourcesCommand,
4
+ } from '@aws-sdk/client-cloudformation';
5
+ import chalk from 'chalk';
6
+ import { logger } from '../lib/logger.js';
7
+ import { cfnClient } from '../lib/aws-client.js';
8
+ import { isDataResource } from '../lib/constants.js';
9
+ import { loadProjectConfig, buildLegacyPrefixRegex, buildAnchoredLegacyPrefixRegex, resolveProjectDir } from '../lib/project-config.js';
10
+ import { assertNoFrozenExports } from '../aws/exports.js';
11
+
12
+ export default async function rollback({ stack }) {
13
+ logger.step(`clawform rollback ${stack}`);
14
+
15
+ // Legacy-freeze gate (mirrors deploy.js): the freeze applies to recovery,
16
+ // not just edits. The CLI refuses outright — no override flag. The
17
+ // documented escape hatch is running the raw aws CLI with a
18
+ // `# FORCE LEGACY: <reason>` comment, which goes through the PreToolUse
19
+ // hook, not this CLI. See rules/legacy-freeze.md.
20
+ const projectCfg = loadProjectConfig({ projectDir: resolveProjectDir() });
21
+ const legacyRe = buildLegacyPrefixRegex(projectCfg.legacy_prefixes);
22
+ if (legacyRe && legacyRe.test(stack)) {
23
+ const list = projectCfg.legacy_prefixes.map((p) => `${p}-*`).join(', ');
24
+ throw new Error(
25
+ `Stack "${stack}" matches a legacy prefix (${list}) from your clawform.config.json.\n` +
26
+ `clawform rollback refuses to suggest recovery commands for legacy stacks. See rules/legacy-freeze.md.\n` +
27
+ `If recovery is unavoidable, use the raw aws cloudformation CLI with a "# FORCE LEGACY: <reason>" comment on its own line.`,
28
+ );
29
+ }
30
+
31
+ const client = cfnClient();
32
+
33
+ const out = await client.send(new DescribeStacksCommand({ StackName: stack }));
34
+ const s = out.Stacks?.[0];
35
+ if (!s) throw new Error(`Stack ${stack} not found.`);
36
+
37
+ // C2: frozen-export gate — shared with deploy.js (src/aws/exports.js). A
38
+ // stack whose NAME isn't frozen may still emit legacy-prefixed EXPORT names;
39
+ // the freeze applies to recovery too (rules/legacy-freeze.md). Anchored
40
+ // regex: prefix must lead the export name.
41
+ assertNoFrozenExports({
42
+ liveOutputs: s.Outputs,
43
+ legacyRe: buildAnchoredLegacyPrefixRegex(projectCfg.legacy_prefixes),
44
+ stackName: stack,
45
+ action: 'recovery',
46
+ });
47
+
48
+ logger.info(`Status: ${s.StackStatus}`);
49
+
50
+ switch (s.StackStatus) {
51
+ case 'UPDATE_ROLLBACK_FAILED':
52
+ case 'ROLLBACK_FAILED':
53
+ await suggestContinueRollback(client, stack);
54
+ break;
55
+ case 'CREATE_FAILED':
56
+ case 'ROLLBACK_COMPLETE':
57
+ await suggestDelete(client, stack);
58
+ break;
59
+ case 'UPDATE_FAILED':
60
+ logger.info('CFN will normally auto-roll-back to UPDATE_ROLLBACK_COMPLETE. If stuck, wait then re-run /rollback.');
61
+ break;
62
+ case 'CREATE_COMPLETE':
63
+ case 'UPDATE_COMPLETE':
64
+ case 'UPDATE_ROLLBACK_COMPLETE':
65
+ case 'IMPORT_COMPLETE':
66
+ logger.ok('Stack is healthy — nothing to roll back.');
67
+ break;
68
+ default:
69
+ if (/_IN_PROGRESS$/.test(s.StackStatus)) {
70
+ logger.info('Stack is mid-operation. Wait for terminal status, then re-run /rollback.');
71
+ } else {
72
+ logger.warn(`Status ${s.StackStatus} has no automatic recovery suggestion.`);
73
+ }
74
+ }
75
+ }
76
+
77
+ async function suggestContinueRollback(client, stack) {
78
+ const failed = await fetchFailedResources(client, stack);
79
+ if (failed.length === 0) {
80
+ logger.info(chalk.bold('Suggested:'));
81
+ logger.info(` aws cloudformation continue-update-rollback --stack-name ${stack}`);
82
+ return;
83
+ }
84
+ logger.info(`Failed resources (${failed.length}): ${failed.join(', ')}`);
85
+ logger.info(chalk.bold('Suggested:'));
86
+ logger.info(` aws cloudformation continue-update-rollback --stack-name ${stack} \\`);
87
+ logger.info(` --resources-to-skip ${failed.join(' ')}`);
88
+ logger.warn('Confirm each skipped resource is acceptable to leave in mixed state before running.');
89
+ }
90
+
91
+ async function suggestDelete(client, stack) {
92
+ const out = await client.send(new DescribeStackResourcesCommand({ StackName: stack }));
93
+ const dataResources = (out.StackResources ?? []).filter((r) => isDataResource(r.ResourceType));
94
+ if (dataResources.length > 0) {
95
+ logger.warn(`Stack contains ${dataResources.length} data resource(s):`);
96
+ for (const r of dataResources) logger.warn(` ${r.LogicalResourceId} (${r.ResourceType}) → ${r.PhysicalResourceId ?? '(no physical)'}`);
97
+ logger.info('Delete is unsafe. Options:');
98
+ logger.info(' 1. Verify each data resource has DeletionPolicy: Retain → delete-stack is safe (physical resource survives)');
99
+ logger.info(' 2. Import data resources into a new stack first (see rules/legacy-freeze.md "replace pattern")');
100
+ return;
101
+ }
102
+ logger.ok('No data resources detected.');
103
+ logger.info(chalk.bold('Suggested:'));
104
+ logger.info(` aws cloudformation delete-stack --stack-name ${stack}`);
105
+ logger.info('Then run: aws cloudformation wait stack-delete-complete --stack-name ' + stack);
106
+ }
107
+
108
+ export async function fetchFailedResources(client, stack) {
109
+ // Use DescribeStackResources, not DescribeStackEvents. Two reasons:
110
+ // 1. (bug_028) DescribeStackResources returns CURRENT per-resource status. The
111
+ // previous DescribeStackEvents+filter walked event HISTORY and added any
112
+ // resource that had ever-been-FAILED — so a resource that hit UPDATE_FAILED
113
+ // early and was then auto-rolled-back to UPDATE_COMPLETE stayed in the
114
+ // suggested --resources-to-skip list. AWS rejects continue-update-rollback
115
+ // with "resource X is not in a state to be skipped" for those, defeating
116
+ // the whole point of the suggestion.
117
+ // 2. (merged_bug_008) DescribeStackEvents is paginated and capped ~100/page;
118
+ // after a long forward update + rollback the original FAILED events scroll
119
+ // off page 1. DescribeStackResources avoids the pagination question entirely.
120
+ // The suggestDelete path already uses DescribeStackResources — this keeps the
121
+ // file consistent.
122
+ const out = await client.send(new DescribeStackResourcesCommand({ StackName: stack }));
123
+ return (out.StackResources ?? [])
124
+ .filter((r) => /_FAILED$/.test(r.ResourceStatus ?? '') && r.LogicalResourceId)
125
+ .map((r) => r.LogicalResourceId);
126
+ }
@@ -1,150 +1,150 @@
1
- // clawform status — a one-command day-2 health board for a project's stacks.
2
- //
3
- // For each live stack belonging to the project: its CloudFormation status, when
4
- // it was last deployed, and its LAST-KNOWN drift status (from the ListStacks
5
- // summary — this does NOT trigger a fresh drift detection, which costs time and
6
- // API calls; run `clawform drift <stack>` for that). It also flags whether the
7
- // project has a monitoring stack covering it, since "no alarms" is the silent
8
- // gap [[observability]] exists to catch.
9
- //
10
- // Read-only. Pure helpers (filterProjectStacks / summarize / monitoringPresent /
11
- // renderStatus) are unit-tested without AWS; status() is the thin SDK wrapper.
12
-
13
- import { ListStacksCommand } from '@aws-sdk/client-cloudformation';
14
- import chalk from 'chalk';
15
- import { logger } from '../lib/logger.js';
16
- import { cfnClient } from '../lib/aws-client.js';
17
- import { ENVS } from '../lib/constants.js';
1
+ // clawform status — a one-command day-2 health board for a project's stacks.
2
+ //
3
+ // For each live stack belonging to the project: its CloudFormation status, when
4
+ // it was last deployed, and its LAST-KNOWN drift status (from the ListStacks
5
+ // summary — this does NOT trigger a fresh drift detection, which costs time and
6
+ // API calls; run `clawform drift <stack>` for that). It also flags whether the
7
+ // project has a monitoring stack covering it, since "no alarms" is the silent
8
+ // gap [[observability]] exists to catch.
9
+ //
10
+ // Read-only. Pure helpers (filterProjectStacks / summarize / monitoringPresent /
11
+ // renderStatus) are unit-tested without AWS; status() is the thin SDK wrapper.
12
+
13
+ import { ListStacksCommand } from '@aws-sdk/client-cloudformation';
14
+ import chalk from 'chalk';
15
+ import { logger } from '../lib/logger.js';
16
+ import { cfnClient } from '../lib/aws-client.js';
17
+ import { ENVS } from '../lib/constants.js';
18
18
  import { assertLicensed } from '../lib/license.js';
19
19
  import { resolveClient as resolveLicenseClient } from '../lib/license-client.js';
20
-
21
- // Every status a non-deleted stack can be in. ListStacks with StackStatusFilter
22
- // returns ONLY the listed statuses, so an omission makes that stack VANISH from
23
- // the board — and a board that hides DELETE_FAILED (the classic wedged-teardown
24
- // state) is worse than no board. Named EXISTING (not LIVE) deliberately:
25
- // src/aws/inventory.js has a different LIVE_STATUSES meaning "stable/deployable",
26
- // and the two must not be unified.
27
- const EXISTING_STATUSES = [
28
- 'CREATE_COMPLETE', 'UPDATE_COMPLETE', 'UPDATE_ROLLBACK_COMPLETE',
29
- 'IMPORT_COMPLETE', 'ROLLBACK_COMPLETE',
30
- 'CREATE_FAILED', 'UPDATE_FAILED', 'UPDATE_ROLLBACK_FAILED',
31
- 'ROLLBACK_FAILED', 'DELETE_FAILED', 'IMPORT_ROLLBACK_FAILED',
32
- 'CREATE_IN_PROGRESS', 'UPDATE_IN_PROGRESS', 'ROLLBACK_IN_PROGRESS',
33
- 'DELETE_IN_PROGRESS', 'UPDATE_ROLLBACK_IN_PROGRESS',
34
- 'UPDATE_COMPLETE_CLEANUP_IN_PROGRESS', 'UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS',
35
- 'IMPORT_IN_PROGRESS', 'IMPORT_ROLLBACK_IN_PROGRESS',
36
- 'REVIEW_IN_PROGRESS',
37
- ];
38
-
39
- // Hand-rolled pagination (ListStacksCommand + NextToken) instead of
40
- // paginateListStacks so a plain { send } stub mocks the seam in tests — the
41
- // generated paginator hard-checks `client instanceof CloudFormationClient`.
42
- export async function fetchStackSummaries(client) {
43
- const out = [];
44
- let nextToken;
45
- do {
46
- const resp = await client.send(new ListStacksCommand({
47
- StackStatusFilter: EXISTING_STATUSES,
48
- NextToken: nextToken,
49
- }));
50
- for (const s of resp.StackSummaries ?? []) {
51
- out.push({
52
- name: s.StackName,
53
- status: s.StackStatus,
54
- lastUpdatedTime: s.LastUpdatedTime ?? s.CreationTime ?? null,
55
- driftStatus: s.DriftInformation?.StackDriftStatus ?? 'NOT_CHECKED',
56
- });
57
- }
58
- nextToken = resp.NextToken;
59
- } while (nextToken);
60
- return out;
61
- }
62
-
63
- // Stacks belonging to a project: the `<project>` token appears as a hyphen-
64
- // delimited segment of the name (e.g. d-acme-pri-snet-myproj-zonea → 'myproj').
65
- // Optional env narrows to names starting `<env>-`.
66
- export function filterProjectStacks(summaries, { project, env } = {}) {
67
- return (summaries ?? []).filter((s) => {
68
- const segments = (s.name ?? '').split('-');
69
- if (project && !segments.includes(project)) return false;
70
- if (env && !(s.name ?? '').startsWith(`${env}-`)) return false;
71
- return true;
72
- });
73
- }
74
-
75
- // A monitoring stack is present if any stack in the set names the monitoring
76
- // archetype or carries the cwa (CloudWatch alarm) service token.
77
- export function monitoringPresent(stacks) {
78
- return (stacks ?? []).some((s) => {
79
- const segs = (s.name ?? '').split('-');
80
- return segs.includes('monitoring') || segs.includes('cwa');
81
- });
82
- }
83
-
84
- function healthOf(status) {
85
- if (/_FAILED$/.test(status)) return 'failed'; // incl. DELETE_FAILED — a wedged teardown is broken, not gone
86
- if (/_IN_PROGRESS$/.test(status)) return 'in-progress';
87
- if (status === 'ROLLBACK_COMPLETE') return 'failed';
88
- return 'ok';
89
- }
90
-
91
- export function summarize(stacks) {
92
- return [...(stacks ?? [])]
93
- .map((s) => ({
94
- name: s.name,
95
- status: s.status,
96
- health: healthOf(s.status),
97
- updated: s.lastUpdatedTime ? new Date(s.lastUpdatedTime).toISOString().slice(0, 10) : '—',
98
- drift: s.driftStatus,
99
- }))
100
- .sort((a, b) => a.name.localeCompare(b.name));
101
- }
102
-
103
- const HEALTH_COLOR = { ok: chalk.green, failed: chalk.red, 'in-progress': chalk.yellow };
104
- const DRIFT_COLOR = { DRIFTED: chalk.red, IN_SYNC: chalk.green };
105
-
106
- export function renderStatus(rows, meta) {
107
- const lines = [];
108
- const scope = [meta.env && `env ${meta.env}`, meta.project ? `project ${meta.project}` : 'all projects']
109
- .filter(Boolean).join(', ');
110
- lines.push(`Stack status — ${scope}`);
111
- lines.push('');
112
- if (rows.length === 0) {
113
- lines.push(' (no matching stacks)');
114
- return lines.join('\n');
115
- }
116
- const w = Math.max(20, ...rows.map((r) => r.name.length));
117
- for (const r of rows) {
118
- const statusColor = HEALTH_COLOR[r.health] ?? ((s) => s);
119
- const driftColor = DRIFT_COLOR[r.drift] ?? chalk.gray;
120
- lines.push(
121
- ` ${r.name.padEnd(w)} ${statusColor(r.status.padEnd(24))} ${r.updated} ${driftColor(r.drift)}`,
122
- );
123
- }
124
- lines.push('');
125
- const failed = rows.filter((r) => r.health === 'failed').length;
126
- const drifted = rows.filter((r) => r.drift === 'DRIFTED').length;
127
- lines.push(` ${rows.length} stack(s) · ${failed} failed · ${drifted} drifted`);
128
- if (!meta.hasMonitoring) {
129
- lines.push(chalk.yellow(' ⚠ No monitoring stack detected for this scope — see observability (templates/monitoring.yaml).'));
130
- }
131
- lines.push(chalk.gray(' Drift = last known (not re-checked). Run `clawform drift <stack>` for a fresh detection.'));
132
- return lines.join('\n');
133
- }
134
-
20
+
21
+ // Every status a non-deleted stack can be in. ListStacks with StackStatusFilter
22
+ // returns ONLY the listed statuses, so an omission makes that stack VANISH from
23
+ // the board — and a board that hides DELETE_FAILED (the classic wedged-teardown
24
+ // state) is worse than no board. Named EXISTING (not LIVE) deliberately:
25
+ // src/aws/inventory.js has a different LIVE_STATUSES meaning "stable/deployable",
26
+ // and the two must not be unified.
27
+ const EXISTING_STATUSES = [
28
+ 'CREATE_COMPLETE', 'UPDATE_COMPLETE', 'UPDATE_ROLLBACK_COMPLETE',
29
+ 'IMPORT_COMPLETE', 'ROLLBACK_COMPLETE',
30
+ 'CREATE_FAILED', 'UPDATE_FAILED', 'UPDATE_ROLLBACK_FAILED',
31
+ 'ROLLBACK_FAILED', 'DELETE_FAILED', 'IMPORT_ROLLBACK_FAILED',
32
+ 'CREATE_IN_PROGRESS', 'UPDATE_IN_PROGRESS', 'ROLLBACK_IN_PROGRESS',
33
+ 'DELETE_IN_PROGRESS', 'UPDATE_ROLLBACK_IN_PROGRESS',
34
+ 'UPDATE_COMPLETE_CLEANUP_IN_PROGRESS', 'UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS',
35
+ 'IMPORT_IN_PROGRESS', 'IMPORT_ROLLBACK_IN_PROGRESS',
36
+ 'REVIEW_IN_PROGRESS',
37
+ ];
38
+
39
+ // Hand-rolled pagination (ListStacksCommand + NextToken) instead of
40
+ // paginateListStacks so a plain { send } stub mocks the seam in tests — the
41
+ // generated paginator hard-checks `client instanceof CloudFormationClient`.
42
+ export async function fetchStackSummaries(client) {
43
+ const out = [];
44
+ let nextToken;
45
+ do {
46
+ const resp = await client.send(new ListStacksCommand({
47
+ StackStatusFilter: EXISTING_STATUSES,
48
+ NextToken: nextToken,
49
+ }));
50
+ for (const s of resp.StackSummaries ?? []) {
51
+ out.push({
52
+ name: s.StackName,
53
+ status: s.StackStatus,
54
+ lastUpdatedTime: s.LastUpdatedTime ?? s.CreationTime ?? null,
55
+ driftStatus: s.DriftInformation?.StackDriftStatus ?? 'NOT_CHECKED',
56
+ });
57
+ }
58
+ nextToken = resp.NextToken;
59
+ } while (nextToken);
60
+ return out;
61
+ }
62
+
63
+ // Stacks belonging to a project: the `<project>` token appears as a hyphen-
64
+ // delimited segment of the name (e.g. d-acme-pri-snet-myproj-zonea → 'myproj').
65
+ // Optional env narrows to names starting `<env>-`.
66
+ export function filterProjectStacks(summaries, { project, env } = {}) {
67
+ return (summaries ?? []).filter((s) => {
68
+ const segments = (s.name ?? '').split('-');
69
+ if (project && !segments.includes(project)) return false;
70
+ if (env && !(s.name ?? '').startsWith(`${env}-`)) return false;
71
+ return true;
72
+ });
73
+ }
74
+
75
+ // A monitoring stack is present if any stack in the set names the monitoring
76
+ // archetype or carries the cwa (CloudWatch alarm) service token.
77
+ export function monitoringPresent(stacks) {
78
+ return (stacks ?? []).some((s) => {
79
+ const segs = (s.name ?? '').split('-');
80
+ return segs.includes('monitoring') || segs.includes('cwa');
81
+ });
82
+ }
83
+
84
+ function healthOf(status) {
85
+ if (/_FAILED$/.test(status)) return 'failed'; // incl. DELETE_FAILED — a wedged teardown is broken, not gone
86
+ if (/_IN_PROGRESS$/.test(status)) return 'in-progress';
87
+ if (status === 'ROLLBACK_COMPLETE') return 'failed';
88
+ return 'ok';
89
+ }
90
+
91
+ export function summarize(stacks) {
92
+ return [...(stacks ?? [])]
93
+ .map((s) => ({
94
+ name: s.name,
95
+ status: s.status,
96
+ health: healthOf(s.status),
97
+ updated: s.lastUpdatedTime ? new Date(s.lastUpdatedTime).toISOString().slice(0, 10) : '—',
98
+ drift: s.driftStatus,
99
+ }))
100
+ .sort((a, b) => a.name.localeCompare(b.name));
101
+ }
102
+
103
+ const HEALTH_COLOR = { ok: chalk.green, failed: chalk.red, 'in-progress': chalk.yellow };
104
+ const DRIFT_COLOR = { DRIFTED: chalk.red, IN_SYNC: chalk.green };
105
+
106
+ export function renderStatus(rows, meta) {
107
+ const lines = [];
108
+ const scope = [meta.env && `env ${meta.env}`, meta.project ? `project ${meta.project}` : 'all projects']
109
+ .filter(Boolean).join(', ');
110
+ lines.push(`Stack status — ${scope}`);
111
+ lines.push('');
112
+ if (rows.length === 0) {
113
+ lines.push(' (no matching stacks)');
114
+ return lines.join('\n');
115
+ }
116
+ const w = Math.max(20, ...rows.map((r) => r.name.length));
117
+ for (const r of rows) {
118
+ const statusColor = HEALTH_COLOR[r.health] ?? ((s) => s);
119
+ const driftColor = DRIFT_COLOR[r.drift] ?? chalk.gray;
120
+ lines.push(
121
+ ` ${r.name.padEnd(w)} ${statusColor(r.status.padEnd(24))} ${r.updated} ${driftColor(r.drift)}`,
122
+ );
123
+ }
124
+ lines.push('');
125
+ const failed = rows.filter((r) => r.health === 'failed').length;
126
+ const drifted = rows.filter((r) => r.drift === 'DRIFTED').length;
127
+ lines.push(` ${rows.length} stack(s) · ${failed} failed · ${drifted} drifted`);
128
+ if (!meta.hasMonitoring) {
129
+ lines.push(chalk.yellow(' ⚠ No monitoring stack detected for this scope — see observability (templates/monitoring.yaml).'));
130
+ }
131
+ lines.push(chalk.gray(' Drift = last known (not re-checked). Run `clawform drift <stack>` for a fresh detection.'));
132
+ return lines.join('\n');
133
+ }
134
+
135
135
  export default async function status({ project, env, profile, region } = {}) {
136
- await assertLicensed({ command: 'status', client: resolveLicenseClient() });
137
- if (env && !ENVS.includes(env)) {
138
- throw new Error(`Invalid --env "${env}". Expected one of: ${ENVS.join(' | ')}`);
139
- }
140
- const client = cfnClient({
141
- ...(profile ? { AWS_PROFILE: profile } : {}),
142
- ...(region ? { AWS_REGION: region } : {}),
143
- });
144
- logger.step(`clawform status${project ? ` — ${project}` : ''}`);
145
-
146
- const all = await fetchStackSummaries(client);
147
- const mine = filterProjectStacks(all, { project, env });
148
- const rows = summarize(mine);
149
- console.log(renderStatus(rows, { project, env, hasMonitoring: monitoringPresent(mine) }));
150
- }
136
+ await assertLicensed({ command: 'status', client: resolveLicenseClient() });
137
+ if (env && !ENVS.includes(env)) {
138
+ throw new Error(`Invalid --env "${env}". Expected one of: ${ENVS.join(' | ')}`);
139
+ }
140
+ const client = cfnClient({
141
+ ...(profile ? { AWS_PROFILE: profile } : {}),
142
+ ...(region ? { AWS_REGION: region } : {}),
143
+ });
144
+ logger.step(`clawform status${project ? ` — ${project}` : ''}`);
145
+
146
+ const all = await fetchStackSummaries(client);
147
+ const mine = filterProjectStacks(all, { project, env });
148
+ const rows = summarize(mine);
149
+ console.log(renderStatus(rows, { project, env, hasMonitoring: monitoringPresent(mine) }));
150
+ }