@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.
- package/README.md +141 -120
- package/bin/clawform.js +161 -151
- package/package.json +65 -65
- package/src/aws/catalog.js +116 -116
- package/src/aws/changeset.js +231 -231
- package/src/aws/drift.js +59 -59
- package/src/aws/exports.js +213 -213
- package/src/aws/inventory.js +156 -156
- package/src/commands/activate.js +34 -2
- package/src/commands/ci-init.js +393 -212
- package/src/commands/cost-report.js +163 -163
- package/src/commands/deactivate.js +9 -2
- package/src/commands/deploy.js +413 -413
- package/src/commands/diff.js +39 -39
- package/src/commands/drift.js +35 -35
- package/src/commands/init-plugin.js +6 -6
- package/src/commands/init.js +360 -360
- package/src/commands/rollback.js +126 -126
- package/src/commands/status.js +147 -147
- package/src/commands/sync.js +50 -50
- package/src/commands/update.js +24 -0
- package/src/commands/validate.js +349 -349
- package/src/hooks/block-dangerous.js +62 -62
- package/src/hooks/cfn-lint-after-edit-eval.js +30 -30
- package/src/hooks/cfn-lint-after-edit.js +81 -81
- package/src/hooks/check-catalog-fresh-eval.js +63 -63
- package/src/hooks/check-catalog-fresh.js +33 -33
- package/src/hooks/session-context-eval.js +81 -81
- package/src/hooks/session-context.js +41 -41
- package/src/hooks/subagent-context-eval.js +44 -44
- package/src/hooks/subagent-context.js +48 -48
- package/src/lib/audit-synthesis.js +24 -24
- package/src/lib/aws-client.js +15 -15
- package/src/lib/cfn-yaml.js +92 -92
- package/src/lib/config.js +76 -76
- package/src/lib/constants.js +85 -85
- package/src/lib/defaults.js +16 -16
- package/src/lib/install-workflow.js +28 -28
- package/src/lib/kit-source.js +43 -5
- package/src/lib/license-client.js +84 -84
- package/src/lib/license-config.js +162 -162
- package/src/lib/license-store.js +43 -8
- package/src/lib/license.js +15 -2
- package/src/lib/lint-overrides.js +226 -226
- package/src/lib/logger.js +16 -16
- package/src/lib/project-config.js +145 -145
- package/src/lib/providers/polar.js +215 -215
- package/src/lib/session-state.js +91 -91
package/src/commands/rollback.js
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/status.js
CHANGED
|
@@ -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
|
+
}
|