@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,166 +1,166 @@
1
- // clawform cost-report — month-to-date AWS spend, grouped by the Project / Env /
2
- // CostCenter tag (or by service). The POST-deploy half of cost governance:
3
- // cost-guard estimates before you commit a resource; this reads Cost Explorer
4
- // for what actually got billed, so a team without a FinOps function can see the
5
- // real number broken down the same way their stacks are tagged.
6
- //
7
- // Cost Explorer is a GLOBAL service with only a us-east-1 endpoint — the client
8
- // is pinned there regardless of the project's deploy region, and (unlike the
9
- // CFN commands) no AWS_REGION/AWS_PROFILE is *required*: the SDK's default
10
- // credential chain (env keys, SSO, instance role) is enough, which matters for
11
- // CI/cron use. CE bills ~$0.01 per GetCostAndUsage request; one logical query
12
- // per invocation (followed across NextPageToken pages — truncating silently
13
- // would understate the TOTAL, the one number this command exists to get right).
14
- //
15
- // Pure helpers (monthRange / groupByDef / parseCostRows / renderRows) are
16
- // exported and unit-tested without AWS; costReport() is the thin SDK wrapper.
17
-
18
- import { CostExplorerClient, GetCostAndUsageCommand } from '@aws-sdk/client-cost-explorer';
19
- import { logger } from '../lib/logger.js';
1
+ // clawform cost-report — month-to-date AWS spend, grouped by the Project / Env /
2
+ // CostCenter tag (or by service). The POST-deploy half of cost governance:
3
+ // cost-guard estimates before you commit a resource; this reads Cost Explorer
4
+ // for what actually got billed, so a team without a FinOps function can see the
5
+ // real number broken down the same way their stacks are tagged.
6
+ //
7
+ // Cost Explorer is a GLOBAL service with only a us-east-1 endpoint — the client
8
+ // is pinned there regardless of the project's deploy region, and (unlike the
9
+ // CFN commands) no AWS_REGION/AWS_PROFILE is *required*: the SDK's default
10
+ // credential chain (env keys, SSO, instance role) is enough, which matters for
11
+ // CI/cron use. CE bills ~$0.01 per GetCostAndUsage request; one logical query
12
+ // per invocation (followed across NextPageToken pages — truncating silently
13
+ // would understate the TOTAL, the one number this command exists to get right).
14
+ //
15
+ // Pure helpers (monthRange / groupByDef / parseCostRows / renderRows) are
16
+ // exported and unit-tested without AWS; costReport() is the thin SDK wrapper.
17
+
18
+ import { CostExplorerClient, GetCostAndUsageCommand } from '@aws-sdk/client-cost-explorer';
19
+ import { logger } from '../lib/logger.js';
20
20
  import { assertLicensed } from '../lib/license.js';
21
21
  import { resolveClient as resolveLicenseClient } from '../lib/license-client.js';
22
-
23
- const GROUP_BY = {
24
- project: { Type: 'TAG', Key: 'Project' },
25
- env: { Type: 'TAG', Key: 'Env' },
26
- costcenter: { Type: 'TAG', Key: 'CostCenter' },
27
- service: { Type: 'DIMENSION', Key: 'SERVICE' },
28
- };
29
-
30
- // Resolve the CE GroupBy definition. Defaults to the Project tag — the grouping
31
- // that matches how every clawform deploy tags its stacks (rules/tagging.md).
32
- export function groupByDef(groupBy = 'project') {
33
- const key = String(groupBy).toLowerCase();
34
- const def = GROUP_BY[key];
35
- if (!def) {
36
- throw new Error(`Unknown --group-by "${groupBy}". Expected one of: ${Object.keys(GROUP_BY).join(' | ')}.`);
37
- }
38
- return { key, def };
39
- }
40
-
41
- function pad2(n) { return String(n).padStart(2, '0'); }
42
- function ymd(d) { return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`; }
43
-
44
- // Compute the CE TimePeriod for a month. `month` is 'YYYY-MM' (defaults to the
45
- // month containing `today`). CE's End is EXCLUSIVE and may not be in the future,
46
- // so for the current month we clamp End to tomorrow (month-to-date); for a past
47
- // month End is the first of the next month (the full month).
48
- //
49
- // `today` is injected so the computation is deterministic in tests.
50
- export function monthRange(month, today) {
51
- const now = today instanceof Date ? today : new Date();
52
- let year, mon; // mon is 1-12
53
- if (month) {
54
- const m = /^(\d{4})-(\d{2})$/.exec(String(month).trim());
55
- if (!m) throw new Error(`Invalid --month "${month}". Expected YYYY-MM.`);
56
- year = Number(m[1]);
57
- mon = Number(m[2]);
58
- if (mon < 1 || mon > 12) throw new Error(`Invalid month number in "${month}".`);
59
- } else {
60
- year = now.getUTCFullYear();
61
- mon = now.getUTCMonth() + 1;
62
- }
63
-
64
- const start = new Date(Date.UTC(year, mon - 1, 1));
65
- const nextMonthFirst = new Date(Date.UTC(year, mon, 1));
66
- const tomorrow = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
67
-
68
- if (start >= tomorrow) {
69
- throw new Error(`Month ${year}-${pad2(mon)} is in the future — no cost data yet.`);
70
- }
71
- const end = nextMonthFirst < tomorrow ? nextMonthFirst : tomorrow;
72
- // "partial" = the reported month is still accruing charges, i.e. it's the
73
- // current UTC month — NOT "end was clamped": on the month's last day the
74
- // clamp is a no-op (tomorrow == nextMonthFirst) but today's spend is still
75
- // incomplete, and the label must say so. All date math is UTC because Cost
76
- // Explorer's day boundaries are UTC.
77
- const partial = year === now.getUTCFullYear() && mon === now.getUTCMonth() + 1;
78
- return { Start: ymd(start), End: ymd(end), label: `${year}-${pad2(mon)}`, partial };
79
- }
80
-
81
- // Flatten a CE GetCostAndUsage response into sorted rows. Sums each group key
82
- // across every period returned (we use MONTHLY granularity, so usually one).
83
- // TAG group keys arrive as "Project$value" — strip to the value; an empty value
84
- // becomes "(untagged)" so unallocated spend is visible, not hidden.
85
- export function parseCostRows(response) {
86
- const totals = new Map();
87
- let unit = 'USD';
88
- for (const period of response?.ResultsByTime ?? []) {
89
- for (const g of period.Groups ?? []) {
90
- const rawKey = g.Keys?.[0] ?? '';
91
- const value = rawKey.includes('$') ? rawKey.slice(rawKey.indexOf('$') + 1) : rawKey;
92
- const key = value === '' ? '(untagged)' : value;
93
- const metric = g.Metrics?.UnblendedCost ?? {};
94
- const amount = Number(metric.Amount ?? 0);
95
- if (metric.Unit) unit = metric.Unit;
96
- totals.set(key, (totals.get(key) ?? 0) + (Number.isFinite(amount) ? amount : 0));
97
- }
98
- }
99
- const rows = [...totals.entries()]
100
- .map(([key, amount]) => ({ key, amount, unit }))
101
- .sort((a, b) => b.amount - a.amount);
102
- const total = rows.reduce((s, r) => s + r.amount, 0);
103
- return { rows, total, unit };
104
- }
105
-
106
- export function renderRows({ rows, total, unit }, meta) {
107
- const lines = [];
108
- const span = meta.partial ? `${meta.label} (month-to-date)` : meta.label;
109
- lines.push(`Cost by ${meta.groupBy} — ${span} — ${meta.Start} → ${meta.End} (exclusive)`);
110
- lines.push('');
111
- if (rows.length === 0) {
112
- lines.push(' (no cost data for this period)');
113
- return lines.join('\n');
114
- }
115
- const width = Math.max(8, ...rows.map((r) => r.key.length));
116
- for (const r of rows) {
117
- lines.push(` ${r.key.padEnd(width)} ${r.amount.toFixed(2).padStart(10)} ${r.unit}`);
118
- }
119
- lines.push('');
120
- lines.push(` ${'TOTAL'.padEnd(width)} ${total.toFixed(2).padStart(10)} ${unit}`);
121
-
122
- const untagged = rows.find((r) => r.key === '(untagged)');
123
- if (untagged && total > 0 && untagged.amount / total > 0.5 && meta.def.Type === 'TAG') {
124
- lines.push('');
125
- lines.push(
126
- ` Note: >50% of spend is (untagged) for tag "${meta.def.Key}". Activate it under ` +
127
- `Billing → Cost allocation tags so per-${meta.groupBy} breakdown becomes meaningful ` +
128
- `(activation is not retroactive).`,
129
- );
130
- }
131
- return lines.join('\n');
132
- }
133
-
22
+
23
+ const GROUP_BY = {
24
+ project: { Type: 'TAG', Key: 'Project' },
25
+ env: { Type: 'TAG', Key: 'Env' },
26
+ costcenter: { Type: 'TAG', Key: 'CostCenter' },
27
+ service: { Type: 'DIMENSION', Key: 'SERVICE' },
28
+ };
29
+
30
+ // Resolve the CE GroupBy definition. Defaults to the Project tag — the grouping
31
+ // that matches how every clawform deploy tags its stacks (rules/tagging.md).
32
+ export function groupByDef(groupBy = 'project') {
33
+ const key = String(groupBy).toLowerCase();
34
+ const def = GROUP_BY[key];
35
+ if (!def) {
36
+ throw new Error(`Unknown --group-by "${groupBy}". Expected one of: ${Object.keys(GROUP_BY).join(' | ')}.`);
37
+ }
38
+ return { key, def };
39
+ }
40
+
41
+ function pad2(n) { return String(n).padStart(2, '0'); }
42
+ function ymd(d) { return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`; }
43
+
44
+ // Compute the CE TimePeriod for a month. `month` is 'YYYY-MM' (defaults to the
45
+ // month containing `today`). CE's End is EXCLUSIVE and may not be in the future,
46
+ // so for the current month we clamp End to tomorrow (month-to-date); for a past
47
+ // month End is the first of the next month (the full month).
48
+ //
49
+ // `today` is injected so the computation is deterministic in tests.
50
+ export function monthRange(month, today) {
51
+ const now = today instanceof Date ? today : new Date();
52
+ let year, mon; // mon is 1-12
53
+ if (month) {
54
+ const m = /^(\d{4})-(\d{2})$/.exec(String(month).trim());
55
+ if (!m) throw new Error(`Invalid --month "${month}". Expected YYYY-MM.`);
56
+ year = Number(m[1]);
57
+ mon = Number(m[2]);
58
+ if (mon < 1 || mon > 12) throw new Error(`Invalid month number in "${month}".`);
59
+ } else {
60
+ year = now.getUTCFullYear();
61
+ mon = now.getUTCMonth() + 1;
62
+ }
63
+
64
+ const start = new Date(Date.UTC(year, mon - 1, 1));
65
+ const nextMonthFirst = new Date(Date.UTC(year, mon, 1));
66
+ const tomorrow = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
67
+
68
+ if (start >= tomorrow) {
69
+ throw new Error(`Month ${year}-${pad2(mon)} is in the future — no cost data yet.`);
70
+ }
71
+ const end = nextMonthFirst < tomorrow ? nextMonthFirst : tomorrow;
72
+ // "partial" = the reported month is still accruing charges, i.e. it's the
73
+ // current UTC month — NOT "end was clamped": on the month's last day the
74
+ // clamp is a no-op (tomorrow == nextMonthFirst) but today's spend is still
75
+ // incomplete, and the label must say so. All date math is UTC because Cost
76
+ // Explorer's day boundaries are UTC.
77
+ const partial = year === now.getUTCFullYear() && mon === now.getUTCMonth() + 1;
78
+ return { Start: ymd(start), End: ymd(end), label: `${year}-${pad2(mon)}`, partial };
79
+ }
80
+
81
+ // Flatten a CE GetCostAndUsage response into sorted rows. Sums each group key
82
+ // across every period returned (we use MONTHLY granularity, so usually one).
83
+ // TAG group keys arrive as "Project$value" — strip to the value; an empty value
84
+ // becomes "(untagged)" so unallocated spend is visible, not hidden.
85
+ export function parseCostRows(response) {
86
+ const totals = new Map();
87
+ let unit = 'USD';
88
+ for (const period of response?.ResultsByTime ?? []) {
89
+ for (const g of period.Groups ?? []) {
90
+ const rawKey = g.Keys?.[0] ?? '';
91
+ const value = rawKey.includes('$') ? rawKey.slice(rawKey.indexOf('$') + 1) : rawKey;
92
+ const key = value === '' ? '(untagged)' : value;
93
+ const metric = g.Metrics?.UnblendedCost ?? {};
94
+ const amount = Number(metric.Amount ?? 0);
95
+ if (metric.Unit) unit = metric.Unit;
96
+ totals.set(key, (totals.get(key) ?? 0) + (Number.isFinite(amount) ? amount : 0));
97
+ }
98
+ }
99
+ const rows = [...totals.entries()]
100
+ .map(([key, amount]) => ({ key, amount, unit }))
101
+ .sort((a, b) => b.amount - a.amount);
102
+ const total = rows.reduce((s, r) => s + r.amount, 0);
103
+ return { rows, total, unit };
104
+ }
105
+
106
+ export function renderRows({ rows, total, unit }, meta) {
107
+ const lines = [];
108
+ const span = meta.partial ? `${meta.label} (month-to-date)` : meta.label;
109
+ lines.push(`Cost by ${meta.groupBy} — ${span} — ${meta.Start} → ${meta.End} (exclusive)`);
110
+ lines.push('');
111
+ if (rows.length === 0) {
112
+ lines.push(' (no cost data for this period)');
113
+ return lines.join('\n');
114
+ }
115
+ const width = Math.max(8, ...rows.map((r) => r.key.length));
116
+ for (const r of rows) {
117
+ lines.push(` ${r.key.padEnd(width)} ${r.amount.toFixed(2).padStart(10)} ${r.unit}`);
118
+ }
119
+ lines.push('');
120
+ lines.push(` ${'TOTAL'.padEnd(width)} ${total.toFixed(2).padStart(10)} ${unit}`);
121
+
122
+ const untagged = rows.find((r) => r.key === '(untagged)');
123
+ if (untagged && total > 0 && untagged.amount / total > 0.5 && meta.def.Type === 'TAG') {
124
+ lines.push('');
125
+ lines.push(
126
+ ` Note: >50% of spend is (untagged) for tag "${meta.def.Key}". Activate it under ` +
127
+ `Billing → Cost allocation tags so per-${meta.groupBy} breakdown becomes meaningful ` +
128
+ `(activation is not retroactive).`,
129
+ );
130
+ }
131
+ return lines.join('\n');
132
+ }
133
+
134
134
  export default async function costReport({ month, groupBy, profile, today } = {}) {
135
- await assertLicensed({ command: 'cost-report', client: resolveLicenseClient() });
136
- const { key, def } = groupByDef(groupBy);
137
- const range = monthRange(month, today);
138
-
139
- // Unlike the CFN commands, no profile/region is REQUIRED here: CE is global
140
- // (us-east-1) and the SDK's default credential chain covers env keys / SSO /
141
- // instance roles — the common CI shapes. An explicit --profile still wins.
142
- if (profile) process.env.AWS_PROFILE = profile;
143
-
144
- const client = new CostExplorerClient({ region: 'us-east-1' });
145
-
146
- logger.step(`clawform cost-report — ${range.label} by ${key}`);
147
-
148
- // GetCostAndUsage paginates via NextPageToken; follow it — a truncated group
149
- // list silently understates the TOTAL.
150
- const results = [];
151
- let nextPageToken;
152
- do {
153
- const resp = await client.send(new GetCostAndUsageCommand({
154
- TimePeriod: { Start: range.Start, End: range.End },
155
- Granularity: 'MONTHLY',
156
- Metrics: ['UnblendedCost'],
157
- GroupBy: [def],
158
- ...(nextPageToken ? { NextPageToken: nextPageToken } : {}),
159
- }));
160
- results.push(...(resp.ResultsByTime ?? []));
161
- nextPageToken = resp.NextPageToken;
162
- } while (nextPageToken);
163
-
164
- const parsed = parseCostRows({ ResultsByTime: results });
165
- console.log(renderRows(parsed, { ...range, groupBy: key, def }));
166
- }
135
+ await assertLicensed({ command: 'cost-report', client: resolveLicenseClient() });
136
+ const { key, def } = groupByDef(groupBy);
137
+ const range = monthRange(month, today);
138
+
139
+ // Unlike the CFN commands, no profile/region is REQUIRED here: CE is global
140
+ // (us-east-1) and the SDK's default credential chain covers env keys / SSO /
141
+ // instance roles — the common CI shapes. An explicit --profile still wins.
142
+ if (profile) process.env.AWS_PROFILE = profile;
143
+
144
+ const client = new CostExplorerClient({ region: 'us-east-1' });
145
+
146
+ logger.step(`clawform cost-report — ${range.label} by ${key}`);
147
+
148
+ // GetCostAndUsage paginates via NextPageToken; follow it — a truncated group
149
+ // list silently understates the TOTAL.
150
+ const results = [];
151
+ let nextPageToken;
152
+ do {
153
+ const resp = await client.send(new GetCostAndUsageCommand({
154
+ TimePeriod: { Start: range.Start, End: range.End },
155
+ Granularity: 'MONTHLY',
156
+ Metrics: ['UnblendedCost'],
157
+ GroupBy: [def],
158
+ ...(nextPageToken ? { NextPageToken: nextPageToken } : {}),
159
+ }));
160
+ results.push(...(resp.ResultsByTime ?? []));
161
+ nextPageToken = resp.NextPageToken;
162
+ } while (nextPageToken);
163
+
164
+ const parsed = parseCostRows({ ResultsByTime: results });
165
+ console.log(renderRows(parsed, { ...range, groupBy: key, def }));
166
+ }
@@ -6,11 +6,18 @@
6
6
 
7
7
  import { logger } from '../lib/logger.js';
8
8
  import { resolveClient } from '../lib/license-client.js';
9
- import { readLicense, clearLicense } from '../lib/license-store.js';
9
+ import { readLicenseRaw, clearLicense } from '../lib/license-store.js';
10
10
 
11
11
  export default async function deactivate(_opts = {}, {
12
12
  client = resolveClient(),
13
- read = readLicense,
13
+ // Raw read on purpose: a record invalidated by a schema/version change still
14
+ // points at a live Polar slot. Reading it unsigned is what lets the owner
15
+ // reclaim that orphaned slot — the alternative strands it until support acts.
16
+ // Safe: deactivating grants nothing (see readLicenseRaw), and the vendor call
17
+ // is KEY-SCOPED — Polar only releases an instanceId that belongs to the key in
18
+ // the same record, so a hand-written record can only release activations of a
19
+ // key its author already holds, which is already full control of that licence.
20
+ read = readLicenseRaw,
14
21
  clear = clearLicense,
15
22
  log = (m) => logger.ok(m),
16
23
  warn = (m) => logger.warn(m),