@thejoseki/clawform 0.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +95 -4
  2. package/README.md +87 -10
  3. package/bin/clawform.js +151 -16
  4. package/package.json +47 -11
  5. package/src/aws/catalog.js +116 -0
  6. package/src/aws/changeset.js +231 -0
  7. package/src/aws/drift.js +59 -0
  8. package/src/aws/exports.js +213 -0
  9. package/src/aws/inventory.js +156 -0
  10. package/src/commands/activate.js +105 -0
  11. package/src/commands/ci-init.js +212 -0
  12. package/src/commands/cost-report.js +166 -0
  13. package/src/commands/deactivate.js +36 -0
  14. package/src/commands/deploy.js +413 -0
  15. package/src/commands/diff.js +39 -0
  16. package/src/commands/drift.js +35 -0
  17. package/src/commands/init-plugin.js +168 -0
  18. package/src/commands/init.js +360 -0
  19. package/src/commands/license-status.js +28 -0
  20. package/src/commands/rollback.js +126 -0
  21. package/src/commands/status.js +150 -0
  22. package/src/commands/sync.js +53 -0
  23. package/src/commands/validate.js +349 -0
  24. package/src/hooks/block-dangerous-eval.js +160 -0
  25. package/src/hooks/block-dangerous.js +62 -0
  26. package/src/hooks/cfn-lint-after-edit-eval.js +30 -0
  27. package/src/hooks/cfn-lint-after-edit.js +81 -0
  28. package/src/hooks/check-catalog-fresh-eval.js +63 -0
  29. package/src/hooks/check-catalog-fresh.js +33 -0
  30. package/src/hooks/session-context-eval.js +81 -0
  31. package/src/hooks/session-context.js +41 -0
  32. package/src/hooks/subagent-context-eval.js +44 -0
  33. package/src/hooks/subagent-context.js +48 -0
  34. package/src/lib/audit-synthesis.js +24 -0
  35. package/src/lib/aws-client.js +15 -0
  36. package/src/lib/cfn-yaml.js +92 -0
  37. package/src/lib/config.js +76 -0
  38. package/src/lib/constants.js +85 -0
  39. package/src/lib/defaults.js +16 -0
  40. package/src/lib/fingerprint.js +65 -0
  41. package/src/lib/install-workflow.js +28 -0
  42. package/src/lib/kit-source.js +81 -0
  43. package/src/lib/license-client.js +84 -0
  44. package/src/lib/license-config.js +162 -0
  45. package/src/lib/license-store.js +107 -0
  46. package/src/lib/license.js +146 -0
  47. package/src/lib/lint-overrides.js +226 -0
  48. package/src/lib/logger.js +17 -0
  49. package/src/lib/payload.js +74 -0
  50. package/src/lib/project-config.js +145 -0
  51. package/src/lib/providers/polar.js +215 -0
  52. package/src/lib/rename-compat.js +50 -0
  53. package/src/lib/session-state.js +91 -0
  54. package/src/lib/trial-claim.js +65 -0
  55. package/src/lib/watermark.js +65 -0
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Clawform PostToolUse hook — CLI entry. Spawns cfn-lint after Edit/Write/MultiEdit
4
+ * on YAML files under cfn/ or templates/. Surfaces ERRORS back to Claude (exit 2 +
5
+ * stderr) so it can self-correct; WARNINGS go to stderr at exit 0 (advisory). When
6
+ * cfn-lint isn't installed, exits 0 silently — the hook is opt-in, not a gate.
7
+ *
8
+ * The pure decision logic lives in cfn-lint-after-edit-eval.js (unit-tested
9
+ * separately).
10
+ */
11
+
12
+ import { readFileSync } from 'node:fs';
13
+ import { spawnSync } from 'node:child_process';
14
+ import { shouldLint } from './cfn-lint-after-edit-eval.js';
15
+
16
+ let event;
17
+ try {
18
+ event = JSON.parse(readFileSync(0, 'utf8'));
19
+ } catch (err) {
20
+ // Fail-LOUD on parse error (allow the tool result through, but visibly warn).
21
+ process.stderr.write(`[clawform cfn-lint hook] event parse failed (skipping lint): ${err.message}\n`);
22
+ process.exit(0);
23
+ }
24
+
25
+ const decision = shouldLint(event);
26
+ if (!decision.lint) process.exit(0);
27
+
28
+ const r = spawnSync('cfn-lint', ['--format', 'json', decision.file], { encoding: 'utf8' });
29
+
30
+ // cfn-lint not installed → silent skip. Hook stays opt-in for boxes without Python.
31
+ if (r.error && (r.error.code === 'ENOENT' || r.error.errno === -2)) {
32
+ process.exit(0);
33
+ }
34
+
35
+ const stdout = (r.stdout ?? '').trim();
36
+ if (!stdout) {
37
+ // No findings — clean lint pass. Exit 0 quietly.
38
+ process.exit(0);
39
+ }
40
+
41
+ let findings;
42
+ try {
43
+ findings = JSON.parse(stdout);
44
+ } catch {
45
+ process.stderr.write(`[clawform cfn-lint hook] could not parse cfn-lint output for ${decision.file} (skipping)\n`);
46
+ process.exit(0);
47
+ }
48
+
49
+ let errors = 0;
50
+ let warnings = 0;
51
+ const lines = [];
52
+ for (const f of findings) {
53
+ const id = f.Rule?.Id ?? '?';
54
+ const loc = f.Location?.Start ? `L${f.Location.Start.LineNumber}` : '';
55
+ const msg = f.Message ?? '';
56
+ if (f.Level === 'Error') {
57
+ errors++;
58
+ lines.push(` ✖ ${id} ${loc}: ${msg}`);
59
+ } else if (f.Level === 'Warning') {
60
+ warnings++;
61
+ lines.push(` ⚠ ${id} ${loc}: ${msg}`);
62
+ }
63
+ }
64
+
65
+ if (errors > 0) {
66
+ // Errors get exit 2 + stderr so Claude sees them in the next turn and self-corrects.
67
+ process.stderr.write(
68
+ `[clawform cfn-lint hook] ${errors} error(s), ${warnings} warning(s) in ${decision.file}:\n` +
69
+ lines.join('\n') +
70
+ '\n',
71
+ );
72
+ process.exit(2);
73
+ }
74
+
75
+ if (warnings > 0) {
76
+ // Warnings stay advisory — user-visible, not blocking.
77
+ process.stderr.write(
78
+ `[clawform cfn-lint hook] ${warnings} warning(s) in ${decision.file}:\n` + lines.join('\n') + '\n',
79
+ );
80
+ process.exit(0);
81
+ }
@@ -0,0 +1,63 @@
1
+ // Pure decision logic for the PreToolUse `clawform deploy` catalog-freshness hook.
2
+ // Returns { warn|block, reason? } so the CLI shim handles all I/O.
3
+ //
4
+ // Rule:
5
+ // - If the command is `clawform deploy ...`, the consumer is about to import live exports.
6
+ // Block (exit 2) if `.clawform/catalog/outputs-catalog.md` doesn't exist — the user has never
7
+ // run `clawform sync`, so every Fn::ImportValue is unverifiable and changeset failure
8
+ // at deploy time is the predictable next step.
9
+ // - If the catalog file exists but is stale (>STALE_HOURS), warn (advisory) — the export
10
+ // set probably changed underneath. User decides.
11
+ // - Otherwise let it pass.
12
+ //
13
+ // Stays pure: takes (event, {cwd, now, stat}) and returns a verdict. The shim
14
+ // supplies real `cwd`, `Date.now()`, and `node:fs.statSync` — tests substitute fakes.
15
+
16
+ import { CATALOG_DIR_REL } from '../lib/constants.js';
17
+
18
+ const DEPLOY_RE = /(^|\s|;|&&|\|\|)clawform\s+deploy\b/;
19
+ // Derived, not restated: the gate must read exactly where `sync` writes, or it
20
+ // blocks every deploy with "run sync" even right after a successful sync.
21
+ export const CATALOG_REL = `${CATALOG_DIR_REL}/outputs-catalog.md`;
22
+ export const STALE_HOURS = 24;
23
+
24
+ // Resolve catalog path relative to a working directory. The path module's
25
+ // join is platform-aware; we accept either separator on input. Exported so
26
+ // other hooks (session-context-eval.js) can reuse the same resolution
27
+ // instead of re-deriving it (DRY — session-context reports the same
28
+ // freshness signal this hook gates on).
29
+ export function resolveCatalogPath(cwd) {
30
+ return cwd.endsWith('/') || cwd.endsWith('\\') ? cwd + CATALOG_REL : cwd + '/' + CATALOG_REL;
31
+ }
32
+
33
+ export function checkCatalog(event, { cwd, now, statSync }) {
34
+ if (event?.tool_name !== 'Bash') return { ok: true };
35
+ const cmd = event?.tool_input?.command;
36
+ if (typeof cmd !== 'string' || !DEPLOY_RE.test(cmd)) return { ok: true };
37
+
38
+ const path = resolveCatalogPath(cwd);
39
+
40
+ let stat;
41
+ try {
42
+ stat = statSync(path);
43
+ } catch {
44
+ return {
45
+ block: true,
46
+ reason:
47
+ `Catalog ${CATALOG_REL} is missing — run \`clawform sync\` before deploying so Fn::ImportValue references can be verified against live exports.`,
48
+ };
49
+ }
50
+
51
+ const ageMs = now - new Date(stat.mtime).getTime();
52
+ const ageHours = ageMs / 3_600_000;
53
+ if (ageHours > STALE_HOURS) {
54
+ return {
55
+ warn: true,
56
+ reason:
57
+ `Catalog ${CATALOG_REL} is ${Math.round(ageHours)}h old (>${STALE_HOURS}h). ` +
58
+ 'Consider `clawform sync` before deploy if any upstream stack changed recently.',
59
+ };
60
+ }
61
+
62
+ return { ok: true };
63
+ }
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Clawform PreToolUse hook — catalog-freshness guard for `clawform deploy`.
4
+ * Reads JSON event from stdin, delegates to pure `checkCatalog`, and:
5
+ * - exit 2 + stderr → catalog missing (blocks deploy until clawform sync runs)
6
+ * - exit 0 + stderr → catalog stale (>24h) — advisory only
7
+ * - exit 0 silent → no deploy command, or catalog fresh
8
+ *
9
+ * Fail-LOUD on parse error so a buggy hook never silently disables the guard.
10
+ */
11
+
12
+ import { readFileSync, statSync } from 'node:fs';
13
+ import { checkCatalog } from './check-catalog-fresh-eval.js';
14
+
15
+ let event;
16
+ try {
17
+ event = JSON.parse(readFileSync(0, 'utf8'));
18
+ } catch (err) {
19
+ process.stderr.write(`[clawform catalog hook] event parse failed (allowing tool call): ${err.message}\n`);
20
+ process.exit(0);
21
+ }
22
+
23
+ const verdict = checkCatalog(event, { cwd: process.cwd(), now: Date.now(), statSync });
24
+
25
+ if (verdict.block) {
26
+ process.stderr.write(`[clawform catalog hook] ${verdict.reason}\n`);
27
+ process.exit(2);
28
+ }
29
+
30
+ if (verdict.warn) {
31
+ process.stderr.write(`[clawform catalog hook] ${verdict.reason}\n`);
32
+ process.exit(0);
33
+ }
@@ -0,0 +1,81 @@
1
+ // Pure decision logic for the SessionStart context hook. Emits a single terse
2
+ // block (customer, envs, region, legacy-prefix count, catalog freshness) when
3
+ // the current project resolves an clawform.config.json; silent otherwise, so
4
+ // a non-clawform project never sees noise.
5
+ //
6
+ // Never includes account IDs or AWS profile — CLAUDE.md's identity rule says
7
+ // "Never echo the resolved values back in chat", and those live in
8
+ // clawform.config.json `accounts` / .claude/settings.local.json, not here.
9
+ //
10
+ // Stays pure: takes (deps) and returns { shouldEmit, text }. The shim
11
+ // supplies real findConfigPath/loadProjectConfig/statSync; tests inject fakes.
12
+
13
+ import { STALE_HOURS, resolveCatalogPath } from './check-catalog-fresh-eval.js';
14
+ import { STALE_CHANGESET_DAYS } from '../lib/session-state.js';
15
+
16
+ // readState defaults to a no-op empty state so existing callers/tests that
17
+ // don't care about in-flight state keep working; the shim injects the real one.
18
+ export function evaluate({
19
+ cwd,
20
+ now,
21
+ findConfigPath,
22
+ loadProjectConfig,
23
+ statSync,
24
+ readState = () => ({ pendingChangesets: [] }),
25
+ }) {
26
+ const configPath = findConfigPath(cwd);
27
+ if (!configPath) return { shouldEmit: false, text: '' };
28
+
29
+ const cfg = loadProjectConfig({ projectDir: cwd });
30
+ const customer = cfg.customer || '(unset)';
31
+ const envs = Array.isArray(cfg.envs) && cfg.envs.length ? cfg.envs.join(',') : '(none)';
32
+ const region = cfg.region || '(unset)';
33
+ const legacyCount = Array.isArray(cfg.legacy_prefixes) ? cfg.legacy_prefixes.length : 0;
34
+
35
+ const parts = [
36
+ `customer=${customer}`,
37
+ `envs=${envs}`,
38
+ `region=${region}`,
39
+ `legacy-prefixes=${legacyCount}`,
40
+ catalogFreshnessPart(cwd, now, statSync),
41
+ ];
42
+
43
+ const pending = pendingChangesetPart(cwd, now, readState);
44
+ if (pending) parts.push(pending);
45
+
46
+ return { shouldEmit: true, text: `[clawform] ${parts.join(' ')}` };
47
+ }
48
+
49
+ // Warn about any changeset created but not executed (survives /clear) — the
50
+ // next operator sees it and can execute or clean it up. Fail-open: a bad
51
+ // state file yields no line, never an exception.
52
+ function pendingChangesetPart(cwd, now, readState) {
53
+ let state;
54
+ try {
55
+ state = readState(cwd);
56
+ } catch {
57
+ return '';
58
+ }
59
+ const pending = Array.isArray(state?.pendingChangesets) ? state.pendingChangesets : [];
60
+ if (pending.length === 0) return '';
61
+ const descs = pending.map((e) => {
62
+ const ageDays = (now - new Date(e.createdAt).getTime()) / 86_400_000;
63
+ // A corrupt/hand-edited createdAt yields NaN — show it as stale rather than
64
+ // "(NaNd)" so the operator still gets a "clean this up" signal.
65
+ if (!Number.isFinite(ageDays)) return `${e.stack}/${e.changeSetName}(?!stale)`;
66
+ const stale = ageDays > STALE_CHANGESET_DAYS ? '!stale' : '';
67
+ return `${e.stack}/${e.changeSetName}(${Math.max(0, Math.round(ageDays))}d${stale})`;
68
+ });
69
+ return `pending-changesets=${descs.join(',')}`;
70
+ }
71
+
72
+ function catalogFreshnessPart(cwd, now, statSync) {
73
+ let stat;
74
+ try {
75
+ stat = statSync(resolveCatalogPath(cwd));
76
+ } catch {
77
+ return 'catalog=missing';
78
+ }
79
+ const ageHours = (now - new Date(stat.mtime).getTime()) / 3_600_000;
80
+ return ageHours > STALE_HOURS ? `catalog=stale(${Math.round(ageHours)}h)` : 'catalog=fresh';
81
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Clawform SessionStart hook — prints a terse config+catalog context block
4
+ * when the current project resolves an clawform.config.json; silent (exit 0,
5
+ * no output) for any other project, so a bare dir sees nothing.
6
+ *
7
+ * Resolves the project dir the same way block-dangerous.js does: prefer the
8
+ * hook event's own `cwd`, then CLAUDE_PROJECT_DIR, then process.cwd().
9
+ *
10
+ * Fail-LOUD on event-parse error, and on consumer-config load error (bad
11
+ * JSON / wrong-shaped keys — project-config.js throws loudly on both), both
12
+ * stderr + exit 0 — never blocks the session; SessionStart output is
13
+ * advisory only.
14
+ */
15
+
16
+ import { readFileSync, statSync } from 'node:fs';
17
+ import { evaluate } from './session-context-eval.js';
18
+ import { findConfigPath, loadProjectConfig } from '../lib/project-config.js';
19
+ import { readState } from '../lib/session-state.js';
20
+
21
+ let event;
22
+ try {
23
+ event = JSON.parse(readFileSync(0, 'utf8'));
24
+ } catch (err) {
25
+ process.stderr.write(`[clawform session-context] event parse failed (silent): ${err.message}\n`);
26
+ process.exit(0);
27
+ }
28
+
29
+ const cwd = (typeof event?.cwd === 'string' && event.cwd) || process.env.CLAUDE_PROJECT_DIR || process.cwd();
30
+
31
+ let shouldEmit = false;
32
+ let text = '';
33
+ try {
34
+ ({ shouldEmit, text } = evaluate({ cwd, now: Date.now(), findConfigPath, loadProjectConfig, statSync, readState }));
35
+ } catch (err) {
36
+ process.stderr.write(`[clawform session-context] consumer config load failed at ${cwd} (silent): ${err.message}\n`);
37
+ }
38
+
39
+ if (shouldEmit) {
40
+ console.log(text);
41
+ }
@@ -0,0 +1,44 @@
1
+ // Pure decision logic for the SubagentStart rule-injection hook. When the
2
+ // current project resolves an clawform.config.json, every spawned subagent
3
+ // receives Clawform's 6 hard rules + naming convention as injected context —
4
+ // so a subagent generating or reviewing CloudFormation inherits the same
5
+ // invariants the main session operates under, WITHOUT each workflow having to
6
+ // embed them in every prompt by hand.
7
+ //
8
+ // Silent when no config resolves (a non-clawform project's subagents see
9
+ // nothing). Never emits account IDs, AWS profile, or raw legacy-prefix values
10
+ // (per CLAUDE.md identity rule) — only the customer shortname, which is
11
+ // already public in every stack name.
12
+ //
13
+ // Stays pure: takes injected findConfigPath/loadProjectConfig; the shim
14
+ // supplies the real implementations, tests supply fakes.
15
+
16
+ // The 6 hard rules from CLAUDE.md, compressed to one line each. This is the
17
+ // canonical terse copy; workflows/audit-all-stacks.js keeps its own embedded
18
+ // copy as a defense-in-depth fallback (the hook can be disabled) — keep the
19
+ // two in rough sync, but this one is what subagents actually receive.
20
+ export const HARD_RULES_TERSE = `Clawform hard rules — all subagent work must comply:
21
+ 1. Never modify legacy stacks (frozen prefixes in clawform.config.json legacy_prefixes) without a "# FORCE LEGACY: <reason>" line.
22
+ 2. Never hardcode credentials in CFN Parameters — use {{resolve:secretsmanager:<name>:SecretString:<key>}}.
23
+ 3. Never "!ImportValue !Sub" (invalid YAML) — use long form Fn::ImportValue: !Sub "\${EnvName}-...".
24
+ 4. Stateful resources (RDS/S3/DynamoDB/EFS/ElastiCache/Logs::LogGroup/SecretsManager::Secret/KMS::Key/Cognito) need DeletionPolicy + UpdateReplacePolicy set to a non-destroy value.
25
+ 5. No direct deploy — CreateChangeSet -> describe -> confirm -> ExecuteChangeSet.
26
+ 6. No "aws cloudformation delete-stack" outside the /rollback command.
27
+ Full rules: CLAUDE.md.`;
28
+
29
+ function namingLine(customer, envs) {
30
+ const c = customer || '<customer>';
31
+ // Emit the consumer's actual env list (matching session-context's fidelity)
32
+ // so a subagent doesn't propose an env the consumer narrowed away.
33
+ const envList = Array.isArray(envs) && envs.length ? envs.join('/') : 'd/stg/p';
34
+ return `Naming: <env>-${c}-<pri|pub>-<service>-<project>-<feature>-[zone] (env ${envList}; service shortnames in rules/cfn-standards.md).`;
35
+ }
36
+
37
+ export function evaluate({ cwd, findConfigPath, loadProjectConfig }) {
38
+ const configPath = findConfigPath(cwd);
39
+ if (!configPath) return { shouldEmit: false, text: '' };
40
+
41
+ const cfg = loadProjectConfig({ projectDir: cwd });
42
+ const text = `${HARD_RULES_TERSE}\n${namingLine(cfg.customer, cfg.envs)}`;
43
+ return { shouldEmit: true, text };
44
+ }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Clawform SubagentStart hook — injects the 6 hard rules + naming convention
4
+ * into every subagent when the current project resolves an clawform.config.json;
5
+ * silent (exit 0, no output) for any other project.
6
+ *
7
+ * SubagentStart requires the structured envelope
8
+ * { hookSpecificOutput: { hookEventName: "SubagentStart", additionalContext } }
9
+ * on stdout — plain stdout (as SessionStart accepts) is NOT injected here.
10
+ *
11
+ * Resolves the project dir like the other hooks: prefer the event's own `cwd`,
12
+ * then CLAUDE_PROJECT_DIR, then process.cwd().
13
+ *
14
+ * Fail-LOUD on event-parse error, and on consumer-config load error (bad JSON /
15
+ * wrong-shaped keys — project-config.js throws on both), both stderr + exit 0 —
16
+ * never blocks the subagent; injected context is advisory only.
17
+ */
18
+
19
+ import { readFileSync } from 'node:fs';
20
+ import { evaluate } from './subagent-context-eval.js';
21
+ import { findConfigPath, loadProjectConfig } from '../lib/project-config.js';
22
+
23
+ let event;
24
+ try {
25
+ event = JSON.parse(readFileSync(0, 'utf8'));
26
+ } catch (err) {
27
+ process.stderr.write(`[clawform subagent-context] event parse failed (silent): ${err.message}\n`);
28
+ process.exit(0);
29
+ }
30
+
31
+ const cwd = (typeof event?.cwd === 'string' && event.cwd) || process.env.CLAUDE_PROJECT_DIR || process.cwd();
32
+
33
+ let shouldEmit = false;
34
+ let text = '';
35
+ try {
36
+ ({ shouldEmit, text } = evaluate({ cwd, findConfigPath, loadProjectConfig }));
37
+ } catch (err) {
38
+ process.stderr.write(`[clawform subagent-context] consumer config load failed at ${cwd} (silent): ${err.message}\n`);
39
+ }
40
+
41
+ if (shouldEmit) {
42
+ console.log(JSON.stringify({
43
+ hookSpecificOutput: {
44
+ hookEventName: 'SubagentStart',
45
+ additionalContext: text,
46
+ },
47
+ }));
48
+ }
@@ -0,0 +1,24 @@
1
+ // Pure merge/rank logic for the project-audit workflow (workflows/audit-all-stacks.js).
2
+ // Kept separate from the workflow body because agent-call orchestration isn't
3
+ // unit-testable — this is. Each stack's review-stack / iam-audit / cost-guard
4
+ // verdict rolls up to that resource's worst finding, matching the existing
5
+ // iam-audit convention ("Score the whole resource at its worst finding").
6
+
7
+ const SEVERITY_RANK = { '🔴': 3, '🟡': 2, '✅': 1 };
8
+
9
+ export function rollupStackSeverity(perStack) {
10
+ const severities = [perStack.review?.severity, perStack.iam?.severity, perStack.cost?.severity].filter(Boolean);
11
+ if (severities.length === 0) return '✅';
12
+ return severities.reduce((worst, s) => (SEVERITY_RANK[s] > SEVERITY_RANK[worst] ? s : worst), '✅');
13
+ }
14
+
15
+ export function synthesizeAudit(perStackResults) {
16
+ const stacks = perStackResults
17
+ .map((r) => ({ ...r, severity: rollupStackSeverity(r) }))
18
+ .sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] || a.stack.localeCompare(b.stack));
19
+
20
+ const summary = { total: stacks.length, '🔴': 0, '🟡': 0, '✅': 0 };
21
+ for (const s of stacks) summary[s.severity] += 1;
22
+
23
+ return { summary, stacks };
24
+ }
@@ -0,0 +1,15 @@
1
+ import { CloudFormationClient } from '@aws-sdk/client-cloudformation';
2
+ import { loadConfig } from './config.js';
3
+
4
+ /**
5
+ * Return a CloudFormation client bound to the user's profile & region.
6
+ * Profile is propagated via `AWS_PROFILE` env var so both the SDK's default
7
+ * credential chain AND any `execa`-spawned `aws` CLI child inherit it.
8
+ */
9
+ export function cfnClient(cliOverrides = {}) {
10
+ const cfg = loadConfig(cliOverrides);
11
+ // Mutate process.env so any execa-spawned `aws` CLI child inherits the same profile.
12
+ // Last-write-wins across callers; acceptable for a short-lived CLI process.
13
+ if (cfg.AWS_PROFILE) process.env.AWS_PROFILE = cfg.AWS_PROFILE;
14
+ return new CloudFormationClient({ region: cfg.AWS_REGION });
15
+ }
@@ -0,0 +1,92 @@
1
+ // Shared CloudFormation-YAML helpers.
2
+ //
3
+ // CloudFormation templates use short-tag intrinsics (`!Ref`, `!Sub`, `!GetAtt`,
4
+ // …) that stock js-yaml rejects. This module owns the one CFN-aware schema and
5
+ // the small extractors built on it, so every caller parses templates the same
6
+ // way instead of re-declaring tag tables.
7
+ //
8
+ // The schema is construct-only: `!Sub "x"` becomes `{ 'Fn::Sub': 'x' }`. We only
9
+ // ever READ the parsed shape (we never dump back through it here), so the long
10
+ // form is convenient and lossless for our purposes.
11
+ //
12
+ // Used by:
13
+ // - src/lib/lint-overrides.js (inject / detect cfn-guard suppressions)
14
+ // - src/aws/exports.js (export-lock + frozen-export pre-checks)
15
+
16
+ import yaml from 'js-yaml';
17
+
18
+ const CFN_TAGS = {
19
+ '!Ref': 'Ref', '!Sub': 'Fn::Sub', '!GetAtt': 'Fn::GetAtt',
20
+ '!ImportValue': 'Fn::ImportValue', '!Join': 'Fn::Join',
21
+ '!Select': 'Fn::Select', '!Split': 'Fn::Split',
22
+ '!FindInMap': 'Fn::FindInMap', '!If': 'Fn::If',
23
+ '!Equals': 'Fn::Equals', '!And': 'Fn::And', '!Or': 'Fn::Or',
24
+ '!Not': 'Fn::Not', '!Base64': 'Fn::Base64', '!Cidr': 'Fn::Cidr',
25
+ '!GetAZs': 'Fn::GetAZs', '!Condition': 'Condition',
26
+ '!Transform': 'Fn::Transform',
27
+ };
28
+
29
+ export const CFN_SCHEMA = yaml.DEFAULT_SCHEMA.extend(
30
+ Object.entries(CFN_TAGS).flatMap(([tag, long]) =>
31
+ ['scalar', 'sequence', 'mapping'].map(
32
+ (kind) => new yaml.Type(tag, { kind, construct: (data) => ({ [long]: data }) }),
33
+ ),
34
+ ),
35
+ );
36
+
37
+ // Best-effort parse of a CloudFormation template body. Returns the parsed doc,
38
+ // or null on any parse error (unknown short tag, malformed YAML). Callers treat
39
+ // null as "couldn't inspect" and fail open — the real linters / changeset still
40
+ // run against the original file and surface the parse problem with their own
41
+ // diagnostics. Mirrors the contract in lint-overrides.js.
42
+ export function loadCfnTemplate(body) {
43
+ try {
44
+ const doc = yaml.load(body, { schema: CFN_SCHEMA });
45
+ return doc && typeof doc === 'object' ? doc : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ // Set of Output logical keys (OutputKey) that still declare an `Export.Name`.
52
+ //
53
+ // Why keys, not export-name values: an Output's logical key is a plain YAML map
54
+ // key — exact string, intrinsic-free. The export *value* (`Export.Name`) is
55
+ // frequently an intrinsic (`!Sub "${EnvName}-..."`) we can't resolve offline.
56
+ // Live stack Outputs report both the OutputKey AND the resolved ExportName, so
57
+ // matching live→template by OutputKey lets us detect an export removal with zero
58
+ // false positives — see findRemovedExports() in src/aws/exports.js.
59
+ export function extractExportingOutputKeys(doc) {
60
+ const keys = new Set();
61
+ const outputs = doc && typeof doc === 'object' ? doc.Outputs : null;
62
+ if (!outputs || typeof outputs !== 'object') return keys;
63
+ for (const [key, out] of Object.entries(outputs)) {
64
+ if (!out || typeof out !== 'object') continue;
65
+ const exp = out.Export;
66
+ // Export.Name truthy → this output still exports. The Name may be a string
67
+ // or an intrinsic object (e.g. { 'Fn::Sub': '...' }); presence is all we need.
68
+ if (exp && typeof exp === 'object' && exp.Name != null && exp.Name !== '') {
69
+ keys.add(key);
70
+ }
71
+ }
72
+ return keys;
73
+ }
74
+
75
+ // Set of Export.Name values that are plain string literals (intrinsics like
76
+ // `!Sub "${EnvName}-…"` can't be resolved offline and are skipped). Two users:
77
+ // - export-lock pre-check: a live export whose name still appears as a literal
78
+ // Export.Name in the new template was merely moved to a different OutputKey,
79
+ // not removed — skipping it kills the false positive on OutputKey renames.
80
+ // - frozen-export introduction gate: a literal Export.Name matching a legacy
81
+ // prefix is refused before it ever goes live.
82
+ export function extractLiteralExportNames(doc) {
83
+ const names = new Set();
84
+ const outputs = doc && typeof doc === 'object' ? doc.Outputs : null;
85
+ if (!outputs || typeof outputs !== 'object') return names;
86
+ for (const out of Object.values(outputs)) {
87
+ if (!out || typeof out !== 'object') continue;
88
+ const name = out.Export?.Name;
89
+ if (typeof name === 'string' && name !== '') names.add(name);
90
+ }
91
+ return names;
92
+ }
@@ -0,0 +1,76 @@
1
+ const ENV_KEYS = ['AWS_PROFILE', 'AWS_REGION', 'ACCOUNT_DEV', 'ACCOUNT_STG', 'ACCOUNT_PROD', 'MCP_AWS_SERVER', 'OWNER', 'COST_CENTER'];
2
+ const REQUIRED = ['AWS_PROFILE', 'AWS_REGION'];
3
+
4
+ export function loadConfig(cliOverrides = {}) {
5
+ const fromEnv = {};
6
+ for (const key of ENV_KEYS) {
7
+ if (process.env[key]) fromEnv[key] = process.env[key];
8
+ }
9
+
10
+ const merged = { ...fromEnv, ...cliOverrides };
11
+
12
+ const missing = REQUIRED.filter((k) => !merged[k]);
13
+ if (missing.length) {
14
+ throw new Error(
15
+ `Missing required config: ${missing.join(', ')}. ` +
16
+ `Set via .claude/settings.local.json "env" block (copy .claude/settings.local.json.example) ` +
17
+ `or shell env vars (e.g. AWS_PROFILE=acme-admin AWS_REGION=ap-northeast-1).`
18
+ );
19
+ }
20
+
21
+ return merged;
22
+ }
23
+
24
+ // Resolve the per-stack tag values. Owner / CostCenter come from a two-source
25
+ // chain: shell env var (per-user, gitignored settings.local.json) wins over
26
+ // clawform.config.json tags_defaults (per-project, committed). Either source
27
+ // alone is sufficient; both unset is a hard error pointing at both options.
28
+ //
29
+ // Why env wins: a developer running locally with their own handle should not
30
+ // have to edit the committed project config. tags_defaults is the team default
31
+ // for CI / CodePipeline / any context without per-user env.
32
+ //
33
+ // Args:
34
+ // projectConfig — result of loadProjectConfig(). Reads .customer and
35
+ // .tags_defaults. Pure data, no I/O.
36
+ // envName — the env string ('d' | 'stg' | 'p') passed to deploy().
37
+ // Used as the Environment value only.
38
+ //
39
+ // Returns: { Owner, CostCenter, Environment, ManagedBy, Customer }.
40
+ // Callers map this into the stack-level Tags array; the key spelling there
41
+ // (Env vs Environment) is the caller's choice — rules/tagging.md pins it to "Env".
42
+ export function resolveStackTags(projectConfig, envName) {
43
+ const defaults = (projectConfig && projectConfig.tags_defaults) || {};
44
+ const envOwner = (process.env.OWNER || '').trim();
45
+ const envCost = (process.env.COST_CENTER || '').trim();
46
+ const cfgOwner = typeof defaults.Owner === 'string' ? defaults.Owner.trim() : '';
47
+ const cfgCost = typeof defaults.CostCenter === 'string' ? defaults.CostCenter.trim() : '';
48
+
49
+ const Owner = envOwner || cfgOwner;
50
+ if (!Owner) {
51
+ throw new Error(
52
+ 'Owner tag is unset. Set one of:\n' +
53
+ ' 1. OWNER env var (e.g. .claude/settings.local.json "env" block) — per-user override, wins.\n' +
54
+ ' 2. tags_defaults.Owner in clawform.config.json — per-project team default.\n' +
55
+ 'See docs/config-schema.md and rules/tagging.md.'
56
+ );
57
+ }
58
+
59
+ const CostCenter = envCost || cfgCost;
60
+ if (!CostCenter) {
61
+ throw new Error(
62
+ 'CostCenter tag is unset. Set one of:\n' +
63
+ ' 1. COST_CENTER env var (e.g. .claude/settings.local.json "env" block) — per-user override, wins.\n' +
64
+ ' 2. tags_defaults.CostCenter in clawform.config.json — per-project team default.\n' +
65
+ 'See docs/config-schema.md and rules/tagging.md.'
66
+ );
67
+ }
68
+
69
+ return {
70
+ Owner,
71
+ CostCenter,
72
+ Environment: envName,
73
+ ManagedBy: 'CloudFormation',
74
+ Customer: (projectConfig && projectConfig.customer) || '',
75
+ };
76
+ }