@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,146 @@
1
+ // License enforcement decision — the one function value commands call before
2
+ // doing work. Everything impure is injected (store, clock, validation client,
3
+ // env) so the state matrix tests deterministically; production call sites pass
4
+ // nothing and get the real store + adapter.
5
+ //
6
+ // The client seam is vendor-agnostic on purpose: whichever vendor survives the
7
+ // payout test implements `validate(key) → { status, reason? }` with statuses
8
+ // 'valid' — vendor confirmed the key
9
+ // 'invalid' — vendor answered and said no (revoked / refunded / not found)
10
+ // 'error' — vendor unreachable (network down, vendor outage)
11
+ // The distinction between 'invalid' and 'error' is the whole design: outages
12
+ // get the offline grace window, revocations do not.
13
+
14
+ import { readLicense as storeRead, writeLicense as storeWrite } from './license-store.js';
15
+ import { readEnv } from './rename-compat.js';
16
+ import { logger } from './logger.js';
17
+ import { GRACE_DAYS, REVALIDATE_HOURS, CLOCK_SKEW_HOURS } from './license-config.js';
18
+
19
+ // Emergency operations are never license-gated: an expired card must not be
20
+ // the reason a stuck production stack cannot be rolled back. Additions to this
21
+ // list are a product decision, not a convenience — a test pins its contents.
22
+ export const NEVER_GATED = new Set(['deploy', 'rollback']);
23
+
24
+ const HOUR = 3_600_000;
25
+ const DAY = 24 * HOUR;
26
+
27
+ function hoursSince(iso, nowMs) {
28
+ const t = Date.parse(iso ?? '');
29
+ return Number.isFinite(t) ? (nowMs - t) / HOUR : Infinity;
30
+ }
31
+
32
+ export async function assertLicensed({
33
+ command,
34
+ readLicense = storeRead,
35
+ writeLicense = storeWrite,
36
+ client,
37
+ now = Date.now,
38
+ env = process.env,
39
+ warn = (m) => logger.warn(m),
40
+ } = {}) {
41
+ if (NEVER_GATED.has(command)) return;
42
+
43
+ // Dormant gate: until a vendor adapter ships, there is no way to activate a
44
+ // key at all, so enforcement would lock every user out. A client that marks
45
+ // itself configured:false keeps the gate open; the adapter flips this, and a
46
+ // pinned test makes that flip a reviewed decision.
47
+ if (client?.configured === false) return;
48
+
49
+ // CI path: a key in the environment is validated on every run and nothing is
50
+ // persisted — CI boxes are ephemeral, so a local cache (and with it the
51
+ // offline grace window) would be meaningless there.
52
+ const envKey = readEnv('LICENSE_KEY', env);
53
+ if (envKey) {
54
+ const res = await client.validate(envKey);
55
+ if (res.status === 'valid') return;
56
+ if (res.status === 'invalid') {
57
+ throw new Error(
58
+ `CLAWFORM_LICENSE_KEY was rejected (${res.reason ?? 'invalid'}). ` +
59
+ 'Check the key, or buy a seat — see the README for where.',
60
+ );
61
+ }
62
+ throw new Error(
63
+ `Could not verify CLAWFORM_LICENSE_KEY (${res.reason ?? 'network error'}) and CI has no ` +
64
+ 'offline grace. Retry when the license service is reachable.',
65
+ );
66
+ }
67
+
68
+ const record = readLicense();
69
+ if (record.status !== 'active' || !record.key) {
70
+ throw new Error(
71
+ `'clawform ${command}' needs an activated license. Run: clawform activate <key> ` +
72
+ '(the key is in your purchase email).',
73
+ );
74
+ }
75
+
76
+ // A trial carries its own end date, handed over by the vendor at activation
77
+ // and covered by the record's signature so it cannot be edited outward.
78
+ //
79
+ // Checked BEFORE the freshness shortcut below, which returns without touching
80
+ // the network: a recently-validated record would otherwise sail past its own
81
+ // expiry for another day.
82
+ //
83
+ // Expiry is a property of the licence, not of connectivity, so the offline
84
+ // grace never reaches it — and the message says "trial", never "offline". A
85
+ // buyer told their connection failed will go looking for a fault that is not
86
+ // there.
87
+ //
88
+ // A record with no expiresAt is a perpetual seat, not one that expired at the
89
+ // epoch. Absence has to be checked explicitly.
90
+ if (record.expiresAt) {
91
+ const endsAt = Date.parse(record.expiresAt);
92
+ if (Number.isFinite(endsAt) && now() >= endsAt) {
93
+ throw new Error(
94
+ `Your Clawform trial ended on ${record.expiresAt.slice(0, 10)}, so 'clawform ${command}' `
95
+ + 'is no longer available. Buy a seat at https://clawform.thejoseki.com and run '
96
+ + '`clawform activate <key>`. Note that `clawform deploy` and `clawform rollback` keep '
97
+ + 'working either way — an expired licence never strands a stack.',
98
+ );
99
+ }
100
+ }
101
+
102
+ // Freshness has a floor as well as a ceiling. A record stamped in the future
103
+ // yields a negative age, which would read as "verified moments ago" forever —
104
+ // a permanent bypass that never contacts the vendor again. Below the floor we
105
+ // revalidate rather than refuse, so a genuinely fast clock costs a network
106
+ // call instead of an accusation.
107
+ const age = hoursSince(record.lastValidatedAt, now());
108
+ if (age >= -CLOCK_SKEW_HOURS && age < REVALIDATE_HOURS) return; // fresh verdict — no network
109
+
110
+ const res = await client.validate(record.key);
111
+
112
+ if (res.status === 'valid') {
113
+ // Carry a refreshed expiry through: the vendor is authoritative about it,
114
+ // and an extended trial should take effect without a re-activation.
115
+ writeLicense({
116
+ ...record,
117
+ lastValidatedAt: new Date(now()).toISOString(),
118
+ ...(res.expiresAt ? { expiresAt: res.expiresAt } : {}),
119
+ });
120
+ return;
121
+ }
122
+
123
+ if (res.status === 'invalid') {
124
+ throw new Error(
125
+ `This license is no longer valid (${res.reason ?? 'revoked'}). ` +
126
+ 'If you believe this is a mistake, contact support with your purchase email.',
127
+ );
128
+ }
129
+
130
+ // Vendor unreachable — fall back on the grace window measured from the last
131
+ // SUCCESSFUL validation, so a machine that keeps failing to validate does not
132
+ // keep re-arming its own grace.
133
+ const graceLeftDays = GRACE_DAYS - age / 24;
134
+ if (graceLeftDays > 0) {
135
+ warn(
136
+ `Could not reach the license service to verify (${res.reason ?? 'offline'}). ` +
137
+ `Working offline — ${Math.ceil(graceLeftDays)} day(s) of grace remaining.`,
138
+ );
139
+ return;
140
+ }
141
+
142
+ throw new Error(
143
+ `The license could not be verified for more than ${GRACE_DAYS} days (offline or the ` +
144
+ 'license service was unreachable). Reconnect once and the grace window resets.',
145
+ );
146
+ }
@@ -0,0 +1,226 @@
1
+ // Lint-override loader + cfn-guard SuppressedRules wrapper.
2
+ //
3
+ // Reads rules/lint-overrides.md (the single, audited allowlist), turns its
4
+ // Markdown table into structured overrides, partitions expired rows, injects
5
+ // the active ones into a CFN template's Metadata.guard.SuppressedRules so
6
+ // cfn-guard skips them, and detects in-template suppressions that lack an
7
+ // overrides-file row (anti-rot — "suppression without justification").
8
+ //
9
+ // Functions are pure where possible: every time-sensitive call (filterExpired)
10
+ // takes the current Date as an argument so tests are deterministic. Callers
11
+ // (src/commands/validate.js) supply `new Date()` at the call site.
12
+ //
13
+ // YAML round-trip uses the shared CFN short-tag schema in src/lib/cfn-yaml.js.
14
+ // iam-audit / graph.js still carry local copies — they COULD import src/lib via
15
+ // the same upward-probe graph.js already uses for project-config.js; folding
16
+ // them onto cfn-yaml.js is a worthwhile follow-up (the copies have drifted
17
+ // before: graph.js was missing `!Transform` until 1.2.0). The schema is
18
+ // construct-only, so `!Sub "x"` round-trips to `Fn::Sub: x` — both forms are
19
+ // valid CloudFormation and cfn-guard accepts either.
20
+
21
+ import { readFileSync, existsSync } from 'node:fs';
22
+ import yaml from 'js-yaml';
23
+ import { CFN_SCHEMA } from './cfn-yaml.js';
24
+
25
+ // --- loadOverrides ---------------------------------------------------------
26
+ // Parse the Markdown table in rules/lint-overrides.md and return an array of
27
+ // { ruleId, scopeGlob, reason, expires, owner }
28
+ //
29
+ // Header-name driven: we normalize `rule-id` → `ruleId`, `scope` → `scopeGlob`.
30
+ // The doc's `tool` column is read but discarded (cfn-guard SuppressedRules
31
+ // don't distinguish source). `owner` is optional and defaults to null when the
32
+ // column is absent or empty.
33
+ //
34
+ // Placeholder rows (empty rule-id, or markdown-italic markers like `_none yet_`)
35
+ // are skipped — the bundled doc ships with one such row by design.
36
+ //
37
+ // Missing file: returns [] (a fresh consumer with no overrides is a valid
38
+ // state, mirroring the legacy_prefixes contract).
39
+ export function loadOverrides(mdPath) {
40
+ if (!existsSync(mdPath)) return [];
41
+ const text = readFileSync(mdPath, 'utf8');
42
+ return parseOverridesMarkdown(text);
43
+ }
44
+
45
+ export function parseOverridesMarkdown(text) {
46
+ const lines = text.split(/\r?\n/);
47
+ // Find the first table whose header includes a "rule-id" column.
48
+ const out = [];
49
+ let i = 0;
50
+ while (i < lines.length) {
51
+ const line = lines[i];
52
+ if (isTableRow(line)) {
53
+ const header = splitRow(line);
54
+ const norm = header.map(normalizeHeader);
55
+ const idIdx = norm.indexOf('ruleId');
56
+ if (idIdx === -1) { i++; continue; }
57
+ // Next line should be the separator (|---|---|...). Skip it.
58
+ const sep = lines[i + 1];
59
+ if (!sep || !/^\s*\|?\s*:?-{2,}/.test(sep.trim())) { i++; continue; }
60
+ i += 2;
61
+ while (i < lines.length && isTableRow(lines[i])) {
62
+ const cells = splitRow(lines[i]);
63
+ const row = {};
64
+ for (let c = 0; c < norm.length; c++) row[norm[c]] = (cells[c] ?? '').trim();
65
+ const entry = rowToOverride(row);
66
+ if (entry) out.push(entry);
67
+ i++;
68
+ }
69
+ return out;
70
+ }
71
+ i++;
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function isTableRow(line) {
77
+ return typeof line === 'string' && line.trim().startsWith('|') && line.trim().endsWith('|');
78
+ }
79
+
80
+ function splitRow(line) {
81
+ // Strip leading/trailing pipes then split on remaining pipes.
82
+ const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
83
+ return trimmed.split('|').map((s) => s.trim());
84
+ }
85
+
86
+ function normalizeHeader(h) {
87
+ const k = h.trim().toLowerCase();
88
+ if (k === 'rule-id' || k === 'rule id' || k === 'ruleid') return 'ruleId';
89
+ if (k === 'scope' || k === 'scope-glob' || k === 'scopeglob') return 'scopeGlob';
90
+ return k;
91
+ }
92
+
93
+ function rowToOverride(row) {
94
+ const ruleId = row.ruleId;
95
+ if (!ruleId) return null;
96
+ // Skip markdown placeholder markers like `_none yet_`, `_pending_`, `n/a`.
97
+ if (/^_.*_$/.test(ruleId) || /^n\/a$/i.test(ruleId)) return null;
98
+ const scopeGlob = row.scopeGlob || '*';
99
+ const reason = row.reason || '';
100
+ const owner = row.owner ? row.owner : null;
101
+ const expires = parseExpires(row.expires);
102
+ return { ruleId, scopeGlob, reason, expires, owner };
103
+ }
104
+
105
+ function parseExpires(raw) {
106
+ if (!raw) return null;
107
+ const v = raw.trim();
108
+ if (!v || /^_.*_$/.test(v) || /^n\/a$/i.test(v) || v === '-') return null;
109
+ const d = new Date(v);
110
+ if (Number.isNaN(d.getTime())) return null;
111
+ return d;
112
+ }
113
+
114
+ // --- filterExpired ---------------------------------------------------------
115
+ // Pure partition. `now` is REQUIRED — callers pass `new Date()` in production
116
+ // and a fixed Date instance in tests. Entries with expires: null never expire.
117
+ export function filterExpired(overrides, now) {
118
+ if (!(now instanceof Date) || Number.isNaN(now.getTime())) {
119
+ throw new TypeError('filterExpired(overrides, now): `now` must be a valid Date instance');
120
+ }
121
+ const active = [];
122
+ const expired = [];
123
+ for (const o of overrides) {
124
+ if (o.expires && o.expires.getTime() < now.getTime()) expired.push(o);
125
+ else active.push(o);
126
+ }
127
+ return { active, expired };
128
+ }
129
+
130
+ // --- glob matching for logical IDs ----------------------------------------
131
+ // Tiny `*` → `.*` translator; `?` → `.`. `global` and `*` both mean match-all.
132
+ // We intentionally don't pull a glob lib — overrides scope is a single token
133
+ // against a CFN logical ID (alphanumeric), so the surface is small.
134
+ export function matchesGlob(glob, value) {
135
+ if (!glob || glob === '*' || glob.toLowerCase() === 'global') return true;
136
+ const re = globToRegex(glob);
137
+ return re.test(value);
138
+ }
139
+
140
+ function globToRegex(glob) {
141
+ let out = '^';
142
+ for (const ch of glob) {
143
+ if (ch === '*') out += '.*';
144
+ else if (ch === '?') out += '.';
145
+ else out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
146
+ }
147
+ out += '$';
148
+ return new RegExp(out);
149
+ }
150
+
151
+ // --- injectSuppressions ----------------------------------------------------
152
+ // For each resource in the template, append (deduped) any overrides whose
153
+ // scopeGlob matches the logical ID to Metadata.guard.SuppressedRules. Does
154
+ // NOT clobber pre-existing inline entries — a resource may legitimately carry
155
+ // one (and detectUnauthorizedSuppressions decides if that's allowed).
156
+ //
157
+ // Returns the modified YAML string (long-form intrinsics — see schema note
158
+ // at top of file).
159
+ export function injectSuppressions(templateYaml, activeOverrides) {
160
+ let doc;
161
+ try {
162
+ doc = yaml.load(templateYaml, { schema: CFN_SCHEMA });
163
+ } catch {
164
+ // Unknown short tag (e.g. `!Length`, language-extension tags we don't
165
+ // have a construct for), or any other YAML parse error. Returning the
166
+ // input unchanged preserves the contract "injection is best-effort, never
167
+ // a deploy-gate crash" — the real linters (cfn-lint / cfn-nag / cfn-guard)
168
+ // still run against the original file and will surface the parse problem
169
+ // with their own diagnostics.
170
+ return templateYaml;
171
+ }
172
+ if (!doc || typeof doc !== 'object' || !doc.Resources || typeof doc.Resources !== 'object') {
173
+ return templateYaml;
174
+ }
175
+ for (const [logicalId, resource] of Object.entries(doc.Resources)) {
176
+ if (!resource || typeof resource !== 'object') continue;
177
+ const ruleIds = [];
178
+ for (const o of activeOverrides) {
179
+ if (matchesGlob(o.scopeGlob, logicalId)) ruleIds.push(o.ruleId);
180
+ }
181
+ if (ruleIds.length === 0) continue;
182
+ resource.Metadata = resource.Metadata || {};
183
+ resource.Metadata.guard = resource.Metadata.guard || {};
184
+ const existing = Array.isArray(resource.Metadata.guard.SuppressedRules)
185
+ ? resource.Metadata.guard.SuppressedRules
186
+ : [];
187
+ const merged = Array.from(new Set([...existing, ...ruleIds]));
188
+ resource.Metadata.guard.SuppressedRules = merged;
189
+ }
190
+ return yaml.dump(doc, { schema: CFN_SCHEMA, lineWidth: 200, noRefs: true });
191
+ }
192
+
193
+ // --- detectUnauthorizedSuppressions ---------------------------------------
194
+ // Walk the ORIGINAL template (not the injected copy) and return any inline
195
+ // Metadata.guard.SuppressedRules entries that are NOT covered by an active
196
+ // override row. "Covered" = some active override matches BOTH the resource's
197
+ // logical ID (scopeGlob) AND the suppressed rule ID. This is the anti-rot
198
+ // check — a hand-edited suppression in YAML must trace back to an audited row.
199
+ export function detectUnauthorizedSuppressions(templateYaml, activeOverrides) {
200
+ const findings = [];
201
+ let doc;
202
+ try {
203
+ doc = yaml.load(templateYaml, { schema: CFN_SCHEMA });
204
+ } catch {
205
+ // Same rationale as injectSuppressions: an unknown tag must not turn the
206
+ // anti-rot check into a Node crash that takes down the whole validate
207
+ // pipeline. The linters will report the parse problem; we report no
208
+ // unauthorized suppressions because we couldn't see them.
209
+ return findings;
210
+ }
211
+ if (!doc || typeof doc !== 'object' || !doc.Resources || typeof doc.Resources !== 'object') {
212
+ return findings;
213
+ }
214
+ for (const [logicalId, resource] of Object.entries(doc.Resources)) {
215
+ if (!resource || typeof resource !== 'object') continue;
216
+ const suppressed = resource?.Metadata?.guard?.SuppressedRules;
217
+ if (!Array.isArray(suppressed)) continue;
218
+ for (const ruleId of suppressed) {
219
+ const covered = activeOverrides.some(
220
+ (o) => o.ruleId === ruleId && matchesGlob(o.scopeGlob, logicalId),
221
+ );
222
+ if (!covered) findings.push({ resourceId: logicalId, ruleId });
223
+ }
224
+ }
225
+ return findings;
226
+ }
@@ -0,0 +1,17 @@
1
+ import chalk from 'chalk';
2
+ import { readEnv } from './rename-compat.js';
3
+
4
+ const ts = () => new Date().toISOString().slice(11, 19);
5
+
6
+ export const logger = {
7
+ info: (msg) => console.log(`${chalk.gray(ts())} ${chalk.cyan('•')} ${msg}`),
8
+ ok: (msg) => console.log(`${chalk.gray(ts())} ${chalk.green('✓')} ${msg}`),
9
+ warn: (msg) => console.warn(`${chalk.gray(ts())} ${chalk.yellow('!')} ${msg}`),
10
+ error: (msg) => console.error(`${chalk.gray(ts())} ${chalk.red('✗')} ${msg}`),
11
+ step: (msg) => console.log(`${chalk.gray(ts())} ${chalk.magenta('→')} ${chalk.bold(msg)}`),
12
+ debug: (msg) => {
13
+ if (readEnv('DEBUG')) {
14
+ console.log(`${chalk.gray(ts())} ${chalk.gray('debug')} ${chalk.gray(msg)}`);
15
+ }
16
+ },
17
+ };
@@ -0,0 +1,74 @@
1
+ // Plugin payload codec: a bundle of text files → one compressed, encrypted
2
+ // blob shipped inside the public npm tarball, decrypted at `init plugin` time
3
+ // so a non-buyer cannot read the plugin content.
4
+ //
5
+ // Deterrence, not secrecy: the decrypt key ships in the CLI (the accepted "1a"
6
+ // model). What this module guarantees is a lossless round-trip and that
7
+ // tampering or a wrong key is CAUGHT (AES-256-GCM auth tag), never silently
8
+ // turned into garbage. Uses only node:crypto + node:zlib — no dependency.
9
+ //
10
+ // The bundle is JSON {v, files:{path:content}} because every plugin file is
11
+ // text (md/yaml/json/js/py). If a binary asset ever needs shipping, switch the
12
+ // per-file encoding — the format version guards that migration.
13
+
14
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
15
+ import { gzipSync, gunzipSync } from 'node:zlib';
16
+
17
+ const BUNDLE_VERSION = 1;
18
+ const ALGO = 'aes-256-gcm';
19
+ const IV_LEN = 12; // GCM standard nonce length
20
+ const TAG_LEN = 16; // GCM auth tag length
21
+ const KEY_LEN = 32; // AES-256
22
+
23
+ export function packFiles(entries) {
24
+ const files = {};
25
+ for (const { path, content } of entries) files[path] = content;
26
+ const json = JSON.stringify({ v: BUNDLE_VERSION, files });
27
+ return gzipSync(Buffer.from(json, 'utf8'));
28
+ }
29
+
30
+ export function unpackFiles(buffer) {
31
+ const obj = JSON.parse(gunzipSync(buffer).toString('utf8'));
32
+ if (obj.v !== BUNDLE_VERSION) {
33
+ throw new Error(`Unsupported payload bundle version ${obj.v} (expected ${BUNDLE_VERSION}).`);
34
+ }
35
+ // Preserve insertion order of the original entries — Object key order is
36
+ // stable for string keys, which is enough for a lossless round-trip.
37
+ return Object.entries(obj.files).map(([path, content]) => ({ path, content }));
38
+ }
39
+
40
+ function assertKey(key) {
41
+ if (!Buffer.isBuffer(key) || key.length !== KEY_LEN) {
42
+ throw new Error(`Encryption key must be a ${KEY_LEN}-byte Buffer.`);
43
+ }
44
+ }
45
+
46
+ // Output layout: iv (12) ‖ authTag (16) ‖ ciphertext. Self-describing so
47
+ // decrypt needs only the key.
48
+ export function encrypt(data, key) {
49
+ assertKey(key);
50
+ const iv = randomBytes(IV_LEN);
51
+ const cipher = createCipheriv(ALGO, key, iv);
52
+ const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]);
53
+ return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]);
54
+ }
55
+
56
+ export function decrypt(blob, key) {
57
+ assertKey(key);
58
+ const iv = blob.subarray(0, IV_LEN);
59
+ const tag = blob.subarray(IV_LEN, IV_LEN + TAG_LEN);
60
+ const ciphertext = blob.subarray(IV_LEN + TAG_LEN);
61
+ const decipher = createDecipheriv(ALGO, key, iv);
62
+ decipher.setAuthTag(tag);
63
+ // .final() throws if the tag does not verify — a wrong key or a tampered
64
+ // blob both land here, so callers never see corrupt plaintext.
65
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
66
+ }
67
+
68
+ export function packAndEncrypt(entries, key) {
69
+ return encrypt(packFiles(entries), key);
70
+ }
71
+
72
+ export function decryptAndUnpack(blob, key) {
73
+ return unpackFiles(decrypt(blob, key));
74
+ }
@@ -0,0 +1,145 @@
1
+ // Consumer-config loader for Clawform. Reads <projectDir>/clawform.config.json,
2
+ // merges it onto the built-in defaults, returns a frozen object. Pure I/O — no
3
+ // validation of "is customer set"; callers that need it (naming.js) check at
4
+ // the point they need a value, with a focused error message.
5
+ //
6
+ // Merge order (low → high):
7
+ // 1. DEFAULT_CONFIG (src/lib/defaults.js)
8
+ // 2. <projectDir>/clawform.config.json (consumer-committed, may be missing)
9
+ // 3. caller-supplied overrides (CLI flags)
10
+ //
11
+ // Missing file → silently skipped (a fresh consumer is a valid state).
12
+ // Malformed JSON → throws with the file path and the parse error.
13
+ // Wrong shape (e.g. → throws — we'd rather fail loud than ship Acme's defaults under
14
+ // legacy_prefixes: a wrong key.
15
+ // "OLDNET")
16
+
17
+ import { readFileSync, existsSync } from 'node:fs';
18
+ import { join, basename } from 'node:path';
19
+ import { DEFAULT_CONFIG } from './defaults.js';
20
+ import {
21
+ CONFIG_FILENAMES,
22
+ isDeprecatedConfigFilename,
23
+ warnDeprecatedConfigFilename,
24
+ } from './rename-compat.js';
25
+
26
+ const ARRAY_KEYS = new Set(['envs', 'legacy_prefixes', 'service_shortnames_extra']);
27
+ const OBJECT_KEYS = new Set(['accounts', 'tags_defaults']);
28
+ const STRING_KEYS = new Set(['customer', 'region']);
29
+
30
+ // First match wins, so a project mid-rename that still has both files gets the
31
+ // new one. Silent about the old name here — the warning belongs on the read,
32
+ // not on every existence probe.
33
+ export function findConfigPath(projectDir) {
34
+ if (!projectDir) return null;
35
+ for (const name of CONFIG_FILENAMES) {
36
+ const p = join(projectDir, name);
37
+ if (existsSync(p)) return p;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ export function readProjectConfig(projectDir) {
43
+ const p = findConfigPath(projectDir);
44
+ if (!p) return {};
45
+ if (isDeprecatedConfigFilename(basename(p))) warnDeprecatedConfigFilename(p);
46
+ let raw;
47
+ try {
48
+ raw = readFileSync(p, 'utf8');
49
+ } catch (err) {
50
+ throw new Error(`Failed to read ${p}: ${err.message}`);
51
+ }
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(raw);
55
+ } catch (err) {
56
+ throw new Error(`Malformed JSON in ${p}: ${err.message}`);
57
+ }
58
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
59
+ throw new Error(`Expected JSON object in ${p}, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`);
60
+ }
61
+ return validateShape(parsed, p);
62
+ }
63
+
64
+ function validateShape(cfg, sourcePath) {
65
+ const out = {};
66
+ for (const [k, v] of Object.entries(cfg)) {
67
+ if (k.startsWith('$')) continue;
68
+ if (STRING_KEYS.has(k)) {
69
+ if (typeof v !== 'string') throw new Error(`${sourcePath}: "${k}" must be a string`);
70
+ out[k] = v;
71
+ } else if (ARRAY_KEYS.has(k)) {
72
+ if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) {
73
+ throw new Error(`${sourcePath}: "${k}" must be an array of strings`);
74
+ }
75
+ out[k] = [...v];
76
+ } else if (OBJECT_KEYS.has(k)) {
77
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) {
78
+ throw new Error(`${sourcePath}: "${k}" must be an object`);
79
+ }
80
+ out[k] = { ...v };
81
+ } else {
82
+ // Unknown key — keep verbatim so the consumer can add metadata, but don't
83
+ // act on it. This trades strictness for forward-compatibility.
84
+ out[k] = v;
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+
90
+ export function loadProjectConfig({ projectDir, overrides = {} } = {}) {
91
+ const fromFile = readProjectConfig(projectDir);
92
+ const merged = mergeShallow(DEFAULT_CONFIG, fromFile, overrides);
93
+ return Object.freeze(merged);
94
+ }
95
+
96
+ function mergeShallow(...sources) {
97
+ const out = {
98
+ ...DEFAULT_CONFIG,
99
+ accounts: { ...DEFAULT_CONFIG.accounts },
100
+ tags_defaults: { ...DEFAULT_CONFIG.tags_defaults },
101
+ envs: [...DEFAULT_CONFIG.envs],
102
+ legacy_prefixes: [...DEFAULT_CONFIG.legacy_prefixes],
103
+ service_shortnames_extra: [...DEFAULT_CONFIG.service_shortnames_extra],
104
+ };
105
+ for (const src of sources) {
106
+ if (!src) continue;
107
+ for (const [k, v] of Object.entries(src)) {
108
+ if (OBJECT_KEYS.has(k) && v && typeof v === 'object' && !Array.isArray(v)) {
109
+ out[k] = { ...out[k], ...v };
110
+ } else if (ARRAY_KEYS.has(k) && Array.isArray(v)) {
111
+ out[k] = [...v];
112
+ } else {
113
+ out[k] = v;
114
+ }
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ export function resolveProjectDir(explicit) {
121
+ return explicit || process.env.CLAUDE_PROJECT_DIR || process.cwd();
122
+ }
123
+
124
+ // Build the LEGACY_PREFIX_RE used by the hook from a runtime list. Returns null
125
+ // when the list is empty — the hook treats null as "no legacy-freeze gate".
126
+ //
127
+ // Unanchored (\b...) on purpose: the hook applies it to a whole COMMAND LINE,
128
+ // where the stack name appears mid-string. Do NOT reuse this against a bare
129
+ // export/stack name — `\b` treats every hyphen as a boundary, so a legacy token
130
+ // appearing mid-name (`p-acme-…-OLDAPP-arn`) would false-positive. Use the anchored
131
+ // builder below for whole-name checks.
132
+ export function buildLegacyPrefixRegex(prefixes) {
133
+ if (!Array.isArray(prefixes) || prefixes.length === 0) return null;
134
+ const escaped = prefixes.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
135
+ return new RegExp(`\\b(${escaped.join('|')})-[A-Za-z0-9-]+`);
136
+ }
137
+
138
+ // Anchored variant for matching a WHOLE name (export name, stack name) rather
139
+ // than a substring of a command line: the legacy prefix must be the name's
140
+ // leading segment. `OLDNET-VPC` matches; `p-acme-pri-lmd-payroll-OLDAPP-arn` does not.
141
+ export function buildAnchoredLegacyPrefixRegex(prefixes) {
142
+ if (!Array.isArray(prefixes) || prefixes.length === 0) return null;
143
+ const escaped = prefixes.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
144
+ return new RegExp(`^(${escaped.join('|')})-`);
145
+ }