@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.
- package/LICENSE +95 -4
- package/README.md +87 -10
- package/bin/clawform.js +151 -16
- package/package.json +47 -11
- package/src/aws/catalog.js +116 -0
- package/src/aws/changeset.js +231 -0
- package/src/aws/drift.js +59 -0
- package/src/aws/exports.js +213 -0
- package/src/aws/inventory.js +156 -0
- package/src/commands/activate.js +105 -0
- package/src/commands/ci-init.js +212 -0
- package/src/commands/cost-report.js +166 -0
- package/src/commands/deactivate.js +36 -0
- package/src/commands/deploy.js +413 -0
- package/src/commands/diff.js +39 -0
- package/src/commands/drift.js +35 -0
- package/src/commands/init-plugin.js +168 -0
- package/src/commands/init.js +360 -0
- package/src/commands/license-status.js +28 -0
- package/src/commands/rollback.js +126 -0
- package/src/commands/status.js +150 -0
- package/src/commands/sync.js +53 -0
- package/src/commands/validate.js +349 -0
- package/src/hooks/block-dangerous-eval.js +160 -0
- package/src/hooks/block-dangerous.js +62 -0
- package/src/hooks/cfn-lint-after-edit-eval.js +30 -0
- package/src/hooks/cfn-lint-after-edit.js +81 -0
- package/src/hooks/check-catalog-fresh-eval.js +63 -0
- package/src/hooks/check-catalog-fresh.js +33 -0
- package/src/hooks/session-context-eval.js +81 -0
- package/src/hooks/session-context.js +41 -0
- package/src/hooks/subagent-context-eval.js +44 -0
- package/src/hooks/subagent-context.js +48 -0
- package/src/lib/audit-synthesis.js +24 -0
- package/src/lib/aws-client.js +15 -0
- package/src/lib/cfn-yaml.js +92 -0
- package/src/lib/config.js +76 -0
- package/src/lib/constants.js +85 -0
- package/src/lib/defaults.js +16 -0
- package/src/lib/fingerprint.js +65 -0
- package/src/lib/install-workflow.js +28 -0
- package/src/lib/kit-source.js +81 -0
- package/src/lib/license-client.js +84 -0
- package/src/lib/license-config.js +162 -0
- package/src/lib/license-store.js +107 -0
- package/src/lib/license.js +146 -0
- package/src/lib/lint-overrides.js +226 -0
- package/src/lib/logger.js +17 -0
- package/src/lib/payload.js +74 -0
- package/src/lib/project-config.js +145 -0
- package/src/lib/providers/polar.js +215 -0
- package/src/lib/rename-compat.js +50 -0
- package/src/lib/session-state.js +91 -0
- package/src/lib/trial-claim.js +65 -0
- package/src/lib/watermark.js +65 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { readEnv } from '../lib/rename-compat.js';
|
|
5
|
+
import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
|
|
6
|
+
import { logger } from '../lib/logger.js';
|
|
7
|
+
import { cfnClient } from '../lib/aws-client.js';
|
|
8
|
+
import { loadConfig, resolveStackTags } from '../lib/config.js';
|
|
9
|
+
import { loadProjectConfig, buildLegacyPrefixRegex, buildAnchoredLegacyPrefixRegex, resolveProjectDir } from '../lib/project-config.js';
|
|
10
|
+
import { addPendingChangeset, clearPendingChangeset } from '../lib/session-state.js';
|
|
11
|
+
import { ENVS, isDataResource } from '../lib/constants.js';
|
|
12
|
+
import { loadCfnTemplate, extractExportingOutputKeys, extractLiteralExportNames } from '../lib/cfn-yaml.js';
|
|
13
|
+
import {
|
|
14
|
+
findRemovedExports,
|
|
15
|
+
checkExportLock,
|
|
16
|
+
assertNoFrozenExports,
|
|
17
|
+
assertNoFrozenTemplateExports,
|
|
18
|
+
} from '../aws/exports.js';
|
|
19
|
+
import {
|
|
20
|
+
stackStatus,
|
|
21
|
+
decideChangeSetType,
|
|
22
|
+
createAndWait,
|
|
23
|
+
classifyChanges,
|
|
24
|
+
executeAndPoll,
|
|
25
|
+
} from '../aws/changeset.js';
|
|
26
|
+
|
|
27
|
+
// Runtime account guard: refuse to deploy when the caller's AWS account does
|
|
28
|
+
// not match the consumer's clawform.config.json `accounts[env]` mapping.
|
|
29
|
+
//
|
|
30
|
+
// Why a separate exported helper:
|
|
31
|
+
// - Lets tests mock `@aws-sdk/client-sts` and drive the 4 branches
|
|
32
|
+
// (match, mismatch, no-guard-configured, break-glass) without standing up
|
|
33
|
+
// the rest of deploy().
|
|
34
|
+
// - Keeps the deploy() body linear — one call, one place to land the guard.
|
|
35
|
+
//
|
|
36
|
+
// Args:
|
|
37
|
+
// env — env string ('d' | 'stg' | 'p').
|
|
38
|
+
// accounts — projectCfg.accounts (already loaded by deploy()). May be
|
|
39
|
+
// missing, empty, or {env: ''} — all "no guard" cases.
|
|
40
|
+
// region — AWS region for the STS client (matches the CFN region).
|
|
41
|
+
// stsClientFactory — optional override; defaults to a fresh STSClient. Tests
|
|
42
|
+
// use vi.mock('@aws-sdk/client-sts') instead and don't need
|
|
43
|
+
// this. Kept for callers who want to inject a stub.
|
|
44
|
+
//
|
|
45
|
+
// Resolution order:
|
|
46
|
+
// 1. CLAWFORM_SKIP_ACCOUNT_CHECK=1 → bypass with yellow warning (break-glass).
|
|
47
|
+
// 2. accounts[env] empty / undefined → skip with yellow warning.
|
|
48
|
+
// 3. STS get-caller-identity → compare → throw on mismatch.
|
|
49
|
+
//
|
|
50
|
+
// Throws on mismatch. Resolves with how it concluded — 'verified' (STS ran and
|
|
51
|
+
// matched), 'skipped' (no account configured, non-prod), or 'bypassed'
|
|
52
|
+
// (break-glass env var) — so callers can make the unattended-prod decision.
|
|
53
|
+
export async function assertAccountMatches({ env, accounts, region, stsClientFactory } = {}) {
|
|
54
|
+
if (readEnv('SKIP_ACCOUNT_CHECK') === '1') {
|
|
55
|
+
logger.warn(
|
|
56
|
+
`CLAWFORM_SKIP_ACCOUNT_CHECK=1 — bypassing account-match guard for env '${env}'` +
|
|
57
|
+
`${env === 'p' ? ' (PRODUCTION)' : ''}. Break-glass only; unset to re-enable.`,
|
|
58
|
+
);
|
|
59
|
+
return 'bypassed';
|
|
60
|
+
}
|
|
61
|
+
const expectedRaw = accounts && typeof accounts === 'object' ? accounts[env] : undefined;
|
|
62
|
+
const expected = typeof expectedRaw === 'string' ? expectedRaw.trim() : '';
|
|
63
|
+
if (!expected) {
|
|
64
|
+
// Production is the one env where an unset guard is refused rather than
|
|
65
|
+
// warned past — a p deploy into the wrong account is the exact accident
|
|
66
|
+
// this check exists for. d/stg stay lenient: an account not yet pinned is
|
|
67
|
+
// a legitimate bootstrap state.
|
|
68
|
+
if (env === 'p') {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Env 'p' requires accounts.p in clawform.config.json — a production deploy without ` +
|
|
71
|
+
`an account guard is refused. Add the 12-digit account id (edit the file or rerun clawform init --force).`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
logger.warn(`No account guard configured for env '${env}' — skipping account-match check.`);
|
|
75
|
+
return 'skipped';
|
|
76
|
+
}
|
|
77
|
+
const client = stsClientFactory ? stsClientFactory() : new STSClient(region ? { region } : {});
|
|
78
|
+
const resp = await client.send(new GetCallerIdentityCommand({}));
|
|
79
|
+
const caller = resp && resp.Account ? String(resp.Account).trim() : '';
|
|
80
|
+
if (caller !== expected) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Account mismatch: caller is ${caller || '(unknown)'} but env '${env}' is configured for ${expected} ` +
|
|
83
|
+
`in clawform.config.json. Refusing to deploy.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return 'verified';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Unattended production deploys are fine for CI — CodePipeline's manual
|
|
90
|
+
// approval already puts a human in the loop — but only when the account guard
|
|
91
|
+
// genuinely ran and matched. autoApprove combined with a bypassed guard on p
|
|
92
|
+
// would mean nothing checked which account is being written to.
|
|
93
|
+
export function assertProdDeployAllowed({ env, autoApprove, accountCheckMode } = {}) {
|
|
94
|
+
if (env !== 'p' || !autoApprove) return;
|
|
95
|
+
if (accountCheckMode !== 'verified') {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`--auto-approve is refused for a production deploy unless the account guard verified the ` +
|
|
98
|
+
`caller (guard was '${accountCheckMode}'). Configure accounts.p and unset CLAWFORM_SKIP_ACCOUNT_CHECK, ` +
|
|
99
|
+
`or run interactively.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// C1 (export-lock pre-check): an UPDATE that removes (or un-exports) a live
|
|
105
|
+
// export still imported elsewhere is guaranteed to fail at execute time with
|
|
106
|
+
// "Export ... in use by" and roll back. Detect it BEFORE creating the changeset
|
|
107
|
+
// (all inputs — live Outputs and the template body — exist by then) and hand
|
|
108
|
+
// the operator the two-step migration instead of a wedged stack (rules/limits.md).
|
|
109
|
+
//
|
|
110
|
+
// Fail-open on our own inability to check (parse failure, ListImports denied
|
|
111
|
+
// with no importer seen) — CloudFormation still refuses the real removal if
|
|
112
|
+
// it's genuinely in use. Fail-closed on any confirmed importer, including
|
|
113
|
+
// partial pagination proof (see checkExportLock).
|
|
114
|
+
export async function assertExportLockClear({ client, liveOutputs, templateBody } = {}) {
|
|
115
|
+
const exportsLive = (liveOutputs ?? []).filter((o) => o && o.ExportName);
|
|
116
|
+
if (exportsLive.length === 0) return; // nothing exported → no lock possible
|
|
117
|
+
const doc = loadCfnTemplate(templateBody);
|
|
118
|
+
if (!doc) return; // unparseable → linters own the error
|
|
119
|
+
const removed = findRemovedExports(
|
|
120
|
+
liveOutputs,
|
|
121
|
+
extractExportingOutputKeys(doc),
|
|
122
|
+
extractLiteralExportNames(doc), // same export name under a new OutputKey = moved, not removed
|
|
123
|
+
);
|
|
124
|
+
if (removed.length === 0) return;
|
|
125
|
+
const { blocked, unchecked } = await checkExportLock(client, removed);
|
|
126
|
+
for (const u of unchecked) {
|
|
127
|
+
logger.warn(
|
|
128
|
+
`Could not verify importers for export "${u.exportName}" (${u.error}) — proceeding; ` +
|
|
129
|
+
`CloudFormation will still refuse the removal if it's in use.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (blocked.length === 0) return;
|
|
133
|
+
const lines = blocked.map((b) => ` • ${b.exportName} — imported by: ${b.importers.join(', ')}`);
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Export-lock: this update removes ${blocked.length} export(s) still imported by other stack(s):\n` +
|
|
136
|
+
`${lines.join('\n')}\n` +
|
|
137
|
+
`CloudFormation will reject the changeset ("Export ... in use by") and roll back. ` +
|
|
138
|
+
`Use the two-step migration (rules/limits.md):\n` +
|
|
139
|
+
` 1. Keep the old export; add the new export alongside it and deploy this producer first.\n` +
|
|
140
|
+
` 2. Migrate each consumer to the new import, deploy them in dependency order, then retire the\n` +
|
|
141
|
+
` old export in a final producer deploy once "aws cloudformation list-imports" is empty.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export default async function deploy({ file, env, stackName, project, params, capabilities, autoApprove }) {
|
|
146
|
+
if (!ENVS.includes(env)) {
|
|
147
|
+
throw new Error(`Invalid env "${env}". Expected one of: ${ENVS.join(' | ')}`);
|
|
148
|
+
}
|
|
149
|
+
if (!stackName) throw new Error('--stack-name is required.');
|
|
150
|
+
if (!project) throw new Error('--project is required (used as Project tag value).');
|
|
151
|
+
|
|
152
|
+
const projectDir = resolveProjectDir();
|
|
153
|
+
const projectCfg = loadProjectConfig({ projectDir });
|
|
154
|
+
const legacyRe = buildLegacyPrefixRegex(projectCfg.legacy_prefixes);
|
|
155
|
+
if (legacyRe && legacyRe.test(stackName)) {
|
|
156
|
+
const list = projectCfg.legacy_prefixes.map((p) => `${p}-*`).join(', ');
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Stack "${stackName}" matches a legacy prefix (${list}) from your clawform.config.json.\n` +
|
|
159
|
+
`clawform deploy refuses to modify legacy stacks. See rules/legacy-freeze.md and docs/config-schema.md.\n` +
|
|
160
|
+
`Use raw aws cloudformation CLI with a "# FORCE LEGACY: <reason>" comment if modification is unavoidable.`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// loadConfig() still gates AWS_PROFILE / AWS_REGION with the friendly error.
|
|
165
|
+
// OWNER / COST_CENTER are no longer required here — resolveStackTags() owns
|
|
166
|
+
// the env-var-then-tags_defaults chain and throws with a usable message
|
|
167
|
+
// when both sources are empty.
|
|
168
|
+
const cfg = loadConfig({});
|
|
169
|
+
const stackTags = resolveStackTags(projectCfg, env);
|
|
170
|
+
|
|
171
|
+
// Runtime account guard. Runs after loadConfig() so the friendly missing-creds
|
|
172
|
+
// error fires first; runs before any CFN call so the engineer doesn't have to
|
|
173
|
+
// wait on a changeset round-trip to discover they're pointed at the wrong AWS
|
|
174
|
+
// account. See docs/config-schema.md "Wired today" for resolution rules.
|
|
175
|
+
const accountCheckMode = await assertAccountMatches({
|
|
176
|
+
env,
|
|
177
|
+
accounts: projectCfg.accounts,
|
|
178
|
+
region: cfg.AWS_REGION,
|
|
179
|
+
});
|
|
180
|
+
assertProdDeployAllowed({ env, autoApprove, accountCheckMode });
|
|
181
|
+
|
|
182
|
+
const templateBody = await readFile(file, 'utf8');
|
|
183
|
+
|
|
184
|
+
// C2 introduction gate: refuse a template whose literal Export.Name strings
|
|
185
|
+
// would CREATE a legacy-prefixed export (covers CREATE deploys too — the
|
|
186
|
+
// live-outputs gate below only sees exports that already exist). Anchored
|
|
187
|
+
// regex: the prefix must lead the export name, so legacy tokens mid-name in
|
|
188
|
+
// convention-compliant exports don't false-positive.
|
|
189
|
+
const anchoredLegacyRe = buildAnchoredLegacyPrefixRegex(projectCfg.legacy_prefixes);
|
|
190
|
+
assertNoFrozenTemplateExports({
|
|
191
|
+
templateExportNames: extractLiteralExportNames(loadCfnTemplate(templateBody)),
|
|
192
|
+
legacyRe: anchoredLegacyRe,
|
|
193
|
+
stackName,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
let parameters = [];
|
|
197
|
+
if (params) {
|
|
198
|
+
const raw = await readFile(params, 'utf8');
|
|
199
|
+
parameters = JSON.parse(raw);
|
|
200
|
+
if (!Array.isArray(parameters)) {
|
|
201
|
+
throw new Error(`${params}: expected JSON array of {ParameterKey, ParameterValue}.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Tag key spelling pinned by rules/tagging.md — Env (not Environment), and
|
|
206
|
+
// the 5 mandatory keys (Project, Env, Owner, CostCenter, ManagedBy). Customer
|
|
207
|
+
// from resolveStackTags is informational and not emitted as a tag here.
|
|
208
|
+
const tags = [
|
|
209
|
+
{ Key: 'Project', Value: project },
|
|
210
|
+
{ Key: 'Env', Value: stackTags.Environment },
|
|
211
|
+
{ Key: 'Owner', Value: stackTags.Owner },
|
|
212
|
+
{ Key: 'CostCenter', Value: stackTags.CostCenter },
|
|
213
|
+
{ Key: 'ManagedBy', Value: stackTags.ManagedBy },
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
const caps = capabilities ? capabilities.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
217
|
+
|
|
218
|
+
const client = cfnClient();
|
|
219
|
+
const status = await stackStatus(client, stackName);
|
|
220
|
+
|
|
221
|
+
if (status.exists) {
|
|
222
|
+
// C2: frozen-export gate — refuse a stack emitting legacy-prefixed exports
|
|
223
|
+
// even when its name doesn't match. Anchored regex (leading segment only).
|
|
224
|
+
assertNoFrozenExports({ liveOutputs: status.stack?.Outputs, legacyRe: anchoredLegacyRe, stackName });
|
|
225
|
+
|
|
226
|
+
// C1: export-lock pre-check. Runs BEFORE the changeset — both inputs (live
|
|
227
|
+
// Outputs, template body) already exist, and a blocked deploy should not
|
|
228
|
+
// pay a CreateChangeSet round-trip or leave an orphaned changeset behind.
|
|
229
|
+
await assertExportLockClear({ client, liveOutputs: status.stack?.Outputs, templateBody });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const changeSetType = decideChangeSetType(status);
|
|
233
|
+
logger.info(`Stack ${stackName}: ${status.exists ? status.status : '(does not exist)'} → ${changeSetType}`);
|
|
234
|
+
|
|
235
|
+
logger.step(`Creating change-set on ${stackName}…`);
|
|
236
|
+
const cs = await createAndWait(client, {
|
|
237
|
+
stackName,
|
|
238
|
+
changeSetType,
|
|
239
|
+
templateBody,
|
|
240
|
+
parameters,
|
|
241
|
+
tags,
|
|
242
|
+
capabilities: caps,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (cs.isEmpty) {
|
|
246
|
+
logger.ok(`No changes (${cs.statusReason}). Empty change-set deleted.`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// In-flight state: the changeset now exists in AWS but hasn't executed. Record
|
|
251
|
+
// it so that if the operator aborts at the confirm prompt (or runs /clear),
|
|
252
|
+
// the next session's context hook warns a changeset is sitting unexecuted.
|
|
253
|
+
// Cleared after execute below. Fail-open — a state-write problem must never
|
|
254
|
+
// block the deploy.
|
|
255
|
+
try {
|
|
256
|
+
addPendingChangeset(projectDir, {
|
|
257
|
+
stack: stackName,
|
|
258
|
+
changeSetName: cs.name,
|
|
259
|
+
env,
|
|
260
|
+
createdAt: new Date().toISOString(),
|
|
261
|
+
});
|
|
262
|
+
} catch (err) {
|
|
263
|
+
logger.warn(`Could not record pending changeset state (continuing): ${err.message}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const buckets = classifyChanges(cs.changes);
|
|
267
|
+
renderChangeTable(buckets);
|
|
268
|
+
|
|
269
|
+
const dataRemove = buckets.remove.filter((r) => isDataResource(r.resourceType));
|
|
270
|
+
const needsRetype = buckets.dataResourceReplace.length > 0 || dataRemove.length > 0;
|
|
271
|
+
|
|
272
|
+
if (autoApprove) {
|
|
273
|
+
if (buckets.replace.length > 0 || needsRetype) {
|
|
274
|
+
throw new Error('--auto-approve refused: change-set includes Replace or data-resource removal.');
|
|
275
|
+
}
|
|
276
|
+
logger.info('Proceeding (--auto-approve, no Replace / data removal).');
|
|
277
|
+
} else {
|
|
278
|
+
// Production always earns the retype gate, whatever the changeset holds —
|
|
279
|
+
// the extra keystroke is the point. Folded into the same prompt as the
|
|
280
|
+
// data-resource retype so an operator never types the name twice.
|
|
281
|
+
const prodRetype = env === 'p';
|
|
282
|
+
if (prodRetype && !needsRetype) {
|
|
283
|
+
logger.warn(`Production deploy to '${stackName}' — confirm by retyping the stack name.`);
|
|
284
|
+
}
|
|
285
|
+
if (needsRetype || prodRetype) {
|
|
286
|
+
if (buckets.dataResourceReplace.length > 0) {
|
|
287
|
+
logger.warn(`${buckets.dataResourceReplace.length} data resource(s) will be REPLACED — physical resource recreated.`);
|
|
288
|
+
// A4 (plain language): spell out the consequence so a non-DevOps operator
|
|
289
|
+
// understands what "Replace" costs before they retype the stack name.
|
|
290
|
+
logger.warn(' In plain terms: a brand-new resource is created and the old one is destroyed. Live data');
|
|
291
|
+
logger.warn(' on the old resource is NOT migrated — it survives only as a snapshot if DeletionPolicy/');
|
|
292
|
+
logger.warn(' UpdateReplacePolicy is Snapshot (RDS/Aurora) or Retain. Verify that before continuing.');
|
|
293
|
+
}
|
|
294
|
+
if (dataRemove.length > 0) {
|
|
295
|
+
logger.warn(`${dataRemove.length} data resource(s) will be REMOVED from the stack — physical fate depends on DeletionPolicy (change-set does not expose it).`);
|
|
296
|
+
for (const r of dataRemove) logger.warn(` ${r.logicalId} (${r.resourceType})`);
|
|
297
|
+
logger.warn(' In plain terms: CloudFormation stops managing these. If DeletionPolicy is Retain/Snapshot the');
|
|
298
|
+
logger.warn(' underlying data survives; otherwise it is deleted. Confirm the policy before continuing.');
|
|
299
|
+
}
|
|
300
|
+
const { typed } = await inquirer.prompt([{
|
|
301
|
+
type: 'input',
|
|
302
|
+
name: 'typed',
|
|
303
|
+
message: `Type the stack name EXACTLY to confirm:`,
|
|
304
|
+
validate: (v) => v.trim() === stackName ? true : `Type "${stackName}" exactly.`,
|
|
305
|
+
}]);
|
|
306
|
+
if (typed.trim() !== stackName) throw new Error('Aborted: stack name did not match.');
|
|
307
|
+
}
|
|
308
|
+
const { ok } = await inquirer.prompt([{
|
|
309
|
+
type: 'confirm',
|
|
310
|
+
name: 'ok',
|
|
311
|
+
message: `Execute change-set ${cs.name} on ${stackName}?`,
|
|
312
|
+
default: false,
|
|
313
|
+
}]);
|
|
314
|
+
if (!ok) {
|
|
315
|
+
logger.warn('Aborted by user. Change-set kept (delete via AWS console or rerun).');
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
logger.step(`Executing change-set ${cs.name}…`);
|
|
321
|
+
const result = await executeAndPoll(client, {
|
|
322
|
+
stackName,
|
|
323
|
+
changeSetName: cs.name,
|
|
324
|
+
onEvent: (ev) => {
|
|
325
|
+
const color = EVENT_COLOR[ev.ResourceStatus] ?? ((s) => s);
|
|
326
|
+
const reason = ev.ResourceStatusReason ? ` ${chalk.gray('—')} ${ev.ResourceStatusReason}` : '';
|
|
327
|
+
logger.info(` ${(ev.LogicalResourceId ?? '').padEnd(30)} ${color(ev.ResourceStatus)}${reason}`);
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// The changeset has been executed (success or failure) — no longer pending.
|
|
332
|
+
try {
|
|
333
|
+
clearPendingChangeset(projectDir, { stack: stackName, changeSetName: cs.name });
|
|
334
|
+
} catch { /* fail-open — never let a state-write problem mask the deploy result */ }
|
|
335
|
+
|
|
336
|
+
if (/COMPLETE$/.test(result.stackStatus) && !/ROLLBACK/.test(result.stackStatus)) {
|
|
337
|
+
logger.ok(`Stack ${stackName} → ${result.stackStatus}`);
|
|
338
|
+
const outs = result.stack?.Outputs ?? [];
|
|
339
|
+
if (outs.length) {
|
|
340
|
+
logger.info('Outputs:');
|
|
341
|
+
for (const o of outs) {
|
|
342
|
+
logger.info(` ${(o.OutputKey ?? '').padEnd(30)} = ${o.OutputValue ?? ''}`);
|
|
343
|
+
}
|
|
344
|
+
highlightEndpoints(outs);
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
logger.error(`Stack ${stackName} → ${result.stackStatus}. Use /rollback to investigate.`);
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// D2 (post-deploy verification): a clean COMPLETE means CloudFormation finished,
|
|
353
|
+
// not that the app works. Surface endpoint-like outputs so the operator has
|
|
354
|
+
// something concrete to hit before calling the deploy done. Heuristic on the
|
|
355
|
+
// OutputKey name — intentionally loose; a false highlight costs nothing.
|
|
356
|
+
// Keep in sync with the canonical export suffixes in rules/cfn-standards.md
|
|
357
|
+
// (-id, -arn, -name, -dns, -uri) — 'uri' in particular is the convention's own
|
|
358
|
+
// endpoint suffix.
|
|
359
|
+
const ENDPOINT_KEY_RE = /(url|uri|endpoint|dns|domain|address|hostname|fqdn)/i;
|
|
360
|
+
|
|
361
|
+
export function highlightEndpoints(outs) {
|
|
362
|
+
const endpoints = (outs ?? []).filter((o) => ENDPOINT_KEY_RE.test(o.OutputKey ?? '') && o.OutputValue);
|
|
363
|
+
if (endpoints.length === 0) return;
|
|
364
|
+
logger.info('');
|
|
365
|
+
logger.info('Endpoints to verify:');
|
|
366
|
+
for (const o of endpoints) logger.info(` ${chalk.cyan(o.OutputValue)} ${chalk.gray(`(${o.OutputKey})`)}`);
|
|
367
|
+
logger.info(chalk.gray('A clean stack status means CloudFormation finished — confirm each endpoint actually responds before considering the deploy done.'));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const EVENT_COLOR = {
|
|
371
|
+
CREATE_COMPLETE: chalk.green,
|
|
372
|
+
UPDATE_COMPLETE: chalk.green,
|
|
373
|
+
CREATE_IN_PROGRESS: chalk.cyan,
|
|
374
|
+
UPDATE_IN_PROGRESS: chalk.cyan,
|
|
375
|
+
DELETE_COMPLETE: chalk.gray,
|
|
376
|
+
DELETE_IN_PROGRESS: chalk.gray,
|
|
377
|
+
CREATE_FAILED: chalk.red,
|
|
378
|
+
UPDATE_FAILED: chalk.red,
|
|
379
|
+
DELETE_FAILED: chalk.red,
|
|
380
|
+
ROLLBACK_IN_PROGRESS: chalk.yellow,
|
|
381
|
+
ROLLBACK_COMPLETE: chalk.yellow,
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
function renderChangeTable(buckets) {
|
|
385
|
+
logger.info('');
|
|
386
|
+
logger.info('Change-set summary:');
|
|
387
|
+
logger.info(` ${chalk.green('+ Add')} ${buckets.add.length}`);
|
|
388
|
+
logger.info(` ${chalk.cyan('~ Modify')} ${buckets.modify.length}`);
|
|
389
|
+
logger.info(` ${chalk.yellow('! Replace')} ${buckets.replace.length}`);
|
|
390
|
+
logger.info(` ${chalk.red('- Remove')} ${buckets.remove.length}`);
|
|
391
|
+
|
|
392
|
+
if (buckets.dataResourceReplace.length > 0) {
|
|
393
|
+
logger.info('');
|
|
394
|
+
logger.warn('Data-resource Replace (DESTRUCTIVE):');
|
|
395
|
+
for (const r of buckets.dataResourceReplace) logger.warn(` ${r.logicalId} (${r.resourceType})`);
|
|
396
|
+
}
|
|
397
|
+
printBucket('Add', buckets.add, chalk.green, '+');
|
|
398
|
+
printBucket('Modify', buckets.modify, chalk.cyan, '~', true);
|
|
399
|
+
printBucket('Replace', buckets.replace, chalk.yellow, '!');
|
|
400
|
+
printBucket('Remove', buckets.remove, chalk.red, '-');
|
|
401
|
+
logger.info('');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function printBucket(label, items, color, marker, showProps = false) {
|
|
405
|
+
if (items.length === 0) return;
|
|
406
|
+
logger.info('');
|
|
407
|
+
logger.info(`${label} (${items.length}):`);
|
|
408
|
+
for (const r of items) {
|
|
409
|
+
const props = showProps && r.detailsTargets.length ? ` props=[${r.detailsTargets.join(', ')}]` : '';
|
|
410
|
+
const repl = r.replacement && r.replacement !== 'False' ? ` replacement=${r.replacement}` : '';
|
|
411
|
+
logger.info(` ${color(marker)} ${r.logicalId} (${r.resourceType})${repl}${props}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { GetTemplateCommand } from '@aws-sdk/client-cloudformation';
|
|
4
|
+
import { createPatch } from 'diff';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { logger } from '../lib/logger.js';
|
|
7
|
+
import { cfnClient } from '../lib/aws-client.js';
|
|
8
|
+
|
|
9
|
+
export default async function diff({ stack, file }) {
|
|
10
|
+
const localPath = file ?? `cfn/${stack}.yaml`;
|
|
11
|
+
logger.step(`clawform diff ${stack} ↔ ${localPath}`);
|
|
12
|
+
|
|
13
|
+
if (!existsSync(localPath)) {
|
|
14
|
+
throw new Error(`Local template not found: ${localPath}. Pass --file <path> to override.`);
|
|
15
|
+
}
|
|
16
|
+
// Normalize line endings before comparison. CFN's GetTemplate returns LF, but
|
|
17
|
+
// fs.readFile preserves whatever bytes are on disk — on Windows checkouts (git's
|
|
18
|
+
// default core.autocrlf=true) every line of an identical template differs by a
|
|
19
|
+
// trailing \r, producing a wall-of-red diff that destroys the command's value.
|
|
20
|
+
const local = (await readFile(localPath, 'utf8')).replace(/\r\n/g, '\n');
|
|
21
|
+
|
|
22
|
+
const client = cfnClient();
|
|
23
|
+
const out = await client.send(new GetTemplateCommand({ StackName: stack, TemplateStage: 'Original' }));
|
|
24
|
+
const live = (out.TemplateBody ?? '').replace(/\r\n/g, '\n');
|
|
25
|
+
|
|
26
|
+
if (live === local) {
|
|
27
|
+
logger.ok('No diff. Local template matches live stack.');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const patch = createPatch(stack, live, local, 'live', 'local', { context: 3 });
|
|
32
|
+
for (const line of patch.split('\n')) {
|
|
33
|
+
if (line.startsWith('+++ ') || line.startsWith('--- ')) console.log(chalk.bold(line));
|
|
34
|
+
else if (line.startsWith('+')) console.log(chalk.green(line));
|
|
35
|
+
else if (line.startsWith('-')) console.log(chalk.red(line));
|
|
36
|
+
else if (line.startsWith('@@')) console.log(chalk.cyan(line));
|
|
37
|
+
else console.log(line);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { logger } from '../lib/logger.js';
|
|
3
|
+
import { cfnClient } from '../lib/aws-client.js';
|
|
4
|
+
import { detectAndWait, listResourceDrifts } from '../aws/drift.js';
|
|
5
|
+
|
|
6
|
+
export default async function drift({ stack }) {
|
|
7
|
+
logger.step(`clawform drift ${stack}`);
|
|
8
|
+
const client = cfnClient();
|
|
9
|
+
|
|
10
|
+
logger.info('Detecting drift (this can take a few minutes)…');
|
|
11
|
+
const summary = await detectAndWait(client, { stackName: stack });
|
|
12
|
+
logger.info(`StackDriftStatus: ${summary.stackDriftStatus} · drifted resources: ${summary.driftedResourceCount}`);
|
|
13
|
+
|
|
14
|
+
if (summary.stackDriftStatus === 'IN_SYNC') {
|
|
15
|
+
logger.ok('No drift.');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const drifts = await listResourceDrifts(client, stack);
|
|
20
|
+
for (const d of drifts) {
|
|
21
|
+
logger.info('');
|
|
22
|
+
logger.info(`${chalk.yellow(d.driftStatus)} ${d.logicalId} (${d.resourceType})`);
|
|
23
|
+
for (const p of d.propertyDifferences) {
|
|
24
|
+
const status = p.DifferenceType ?? '?';
|
|
25
|
+
logger.info(` ${chalk.dim('@')}${p.PropertyPath ?? '/'} ${status}`);
|
|
26
|
+
if (p.ExpectedValue !== undefined) logger.info(` expected: ${truncate(p.ExpectedValue)}`);
|
|
27
|
+
if (p.ActualValue !== undefined) logger.info(` actual: ${truncate(p.ActualValue)}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function truncate(v, max = 120) {
|
|
33
|
+
const s = typeof v === 'string' ? v : JSON.stringify(v);
|
|
34
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
35
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// clawform init plugin [--global|--project] — materialize the plugin into a
|
|
2
|
+
// self-contained dir under ~/.clawform and register it with Claude Code.
|
|
3
|
+
//
|
|
4
|
+
// Opt 2 (chosen): decrypt the content library (skills/commands/agents/rules/
|
|
5
|
+
// templates) and copy the plaintext CLI parts (src/, hooks/, .claude-plugin/,
|
|
6
|
+
// CLAUDE.md) into ~/.clawform/plugin, then run `claude plugin marketplace add`
|
|
7
|
+
// on that dir. Claude Code loads it as a real plugin, so CLAUDE_PLUGIN_ROOT is
|
|
8
|
+
// set and the skills resolve rules/templates with no edits — and because we
|
|
9
|
+
// never hand-write into the user's own .claude, there is nothing to back up.
|
|
10
|
+
//
|
|
11
|
+
// Enforcement: this command IS license-gated. The decrypt key ships in the CLI
|
|
12
|
+
// (model 1a), so encryption alone does not stop a non-buyer — refusing to run
|
|
13
|
+
// this without an active license is what does. While the vendor client is
|
|
14
|
+
// dormant (pre-launch) the gate passes, exactly like the value commands.
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import {
|
|
18
|
+
readFileSync, writeFileSync, mkdirSync, cpSync, existsSync, readdirSync, statSync,
|
|
19
|
+
} from 'node:fs';
|
|
20
|
+
import { homedir as osHomedir } from 'node:os';
|
|
21
|
+
import { join, dirname } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { logger } from '../lib/logger.js';
|
|
24
|
+
import { resolveClient } from '../lib/license-client.js';
|
|
25
|
+
import { readLicense as storeRead } from '../lib/license-store.js';
|
|
26
|
+
import { assertLicensed } from '../lib/license.js';
|
|
27
|
+
import { obtainKit } from '../lib/kit-source.js';
|
|
28
|
+
import { KIT_SERVICE_URL } from '../lib/license-config.js';
|
|
29
|
+
import { contentWatermark, licensedToBody } from '../lib/watermark.js';
|
|
30
|
+
|
|
31
|
+
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const PACKAGE_ROOT = join(MODULE_DIR, '..', '..');
|
|
33
|
+
|
|
34
|
+
// The mechanism, shipped in cleartext because it has to be runnable by path —
|
|
35
|
+
// `bin/` is the npm bin entry, and `src/hooks/*.js` is what hooks.json tells
|
|
36
|
+
// Claude Code to execute (${CLAUDE_PLUGIN_ROOT}/src/hooks/*.js). Nothing else
|
|
37
|
+
// belongs here: every inert file the plugin needs, hooks.json and the manifests
|
|
38
|
+
// included, rides in the encrypted payload and lands in this same dir at
|
|
39
|
+
// decrypt time.
|
|
40
|
+
export const PLAINTEXT_PLUGIN_PARTS = ['bin', 'src'];
|
|
41
|
+
|
|
42
|
+
function sha256File(abs) {
|
|
43
|
+
return createHash('sha256').update(readFileSync(abs)).digest('hex');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function walkRel(dir, root, out) {
|
|
47
|
+
for (const name of readdirSync(dir)) {
|
|
48
|
+
const abs = join(dir, name);
|
|
49
|
+
if (statSync(abs).isDirectory()) walkRel(abs, root, out);
|
|
50
|
+
else out.push(abs.slice(root.length + 1).split('\\').join('/'));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const RECEIPT_FILENAME = '.licensed-to';
|
|
55
|
+
|
|
56
|
+
// Files the manifest deliberately ignores: it exists to tell our content from
|
|
57
|
+
// the user's edits, and both of these are per-install metadata (the receipt
|
|
58
|
+
// carries a timestamp, so it would report churn on every run).
|
|
59
|
+
const MANIFEST_EXCLUDED = new Set(['install-manifest.json', RECEIPT_FILENAME]);
|
|
60
|
+
|
|
61
|
+
// Build the plugin dir. Pure filesystem work — no license check, no network, no
|
|
62
|
+
// child process — so it tests against temp dirs. Returns the manifest.
|
|
63
|
+
//
|
|
64
|
+
// Takes the kit as `entries` rather than reading it: how the kit is OBTAINED is
|
|
65
|
+
// a separate concern that now involves a licence check and a network call, and
|
|
66
|
+
// folding that in here would make the only pure part of this command untestable
|
|
67
|
+
// without a server.
|
|
68
|
+
//
|
|
69
|
+
// `licenseRecord` is optional: without an active one (dev, or pre-launch while
|
|
70
|
+
// the gate is dormant) nothing is watermarked and no receipt is written.
|
|
71
|
+
export function assemblePlugin({ packageRoot, destDir, entries, licenseRecord, now = () => new Date().toISOString() }) {
|
|
72
|
+
mkdirSync(destDir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
// 1. Write the content library into the dir. Every file is rewritten from the
|
|
75
|
+
// kit, so step 3 always stamps a clean copy — re-running cannot
|
|
76
|
+
// double-stamp.
|
|
77
|
+
const written = [];
|
|
78
|
+
for (const { path, content } of entries) {
|
|
79
|
+
const abs = join(destDir, path);
|
|
80
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
81
|
+
writeFileSync(abs, content, 'utf8');
|
|
82
|
+
written.push({ path, abs });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 2. Copy the plaintext CLI parts alongside it.
|
|
86
|
+
for (const part of PLAINTEXT_PLUGIN_PARTS) {
|
|
87
|
+
const src = join(packageRoot, part);
|
|
88
|
+
if (existsSync(src)) cpSync(src, join(destDir, part), { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 3. Watermark the content so a copied directory stays traceable to the seat
|
|
92
|
+
// it came from. SKILL.md only: appending to a YAML archetype risks tripping
|
|
93
|
+
// cfn-lint, and markdown renders an HTML comment invisibly.
|
|
94
|
+
const stamp = contentWatermark(licenseRecord);
|
|
95
|
+
if (stamp) {
|
|
96
|
+
for (const { path, abs } of written) {
|
|
97
|
+
if (!path.endsWith('/SKILL.md')) continue;
|
|
98
|
+
const body = readFileSync(abs, 'utf8');
|
|
99
|
+
if (body.includes(stamp)) continue;
|
|
100
|
+
writeFileSync(abs, `${body.replace(/\s*$/, '')}\n\n${stamp}\n`, 'utf8');
|
|
101
|
+
}
|
|
102
|
+
writeFileSync(join(destDir, RECEIPT_FILENAME), licensedToBody(licenseRecord, now()), 'utf8');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 4. Manifest: sha256 of every file written, so a later upgrade/uninstall can
|
|
106
|
+
// tell our files from anything the user added (the AgentKit pattern).
|
|
107
|
+
const rels = [];
|
|
108
|
+
walkRel(destDir, destDir, rels);
|
|
109
|
+
const files = rels
|
|
110
|
+
.filter((r) => !MANIFEST_EXCLUDED.has(r))
|
|
111
|
+
.sort()
|
|
112
|
+
.map((r) => ({ path: r, sha256: sha256File(join(destDir, r)) }));
|
|
113
|
+
const manifest = { v: 1, files };
|
|
114
|
+
writeFileSync(join(destDir, 'install-manifest.json'), JSON.stringify(manifest, null, 2));
|
|
115
|
+
return manifest;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default async function initPlugin({ scope = 'global' } = {}, {
|
|
119
|
+
packageRoot = PACKAGE_ROOT,
|
|
120
|
+
homedir = osHomedir,
|
|
121
|
+
client = resolveClient(),
|
|
122
|
+
readLicense = storeRead,
|
|
123
|
+
runClaude = defaultRunClaude,
|
|
124
|
+
getKit = obtainKit,
|
|
125
|
+
log = (m) => logger.ok(m),
|
|
126
|
+
} = {}) {
|
|
127
|
+
// Gate first — refuse before touching disk when enforcement is on.
|
|
128
|
+
await assertLicensed({ command: 'init-plugin', client, readLicense });
|
|
129
|
+
|
|
130
|
+
const vendorHome = join(homedir(), '.clawform');
|
|
131
|
+
const pluginDir = join(vendorHome, 'plugin');
|
|
132
|
+
logger.step('clawform init plugin');
|
|
133
|
+
|
|
134
|
+
// Fetch (or reuse the cached) kit before writing anything: a service failure
|
|
135
|
+
// should leave the existing plugin dir exactly as it was, not half-replaced.
|
|
136
|
+
const entries = await getKit({
|
|
137
|
+
licenseKey: readLicense()?.key,
|
|
138
|
+
serviceUrl: KIT_SERVICE_URL,
|
|
139
|
+
cacheDir: vendorHome,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
assemblePlugin({ packageRoot, destDir: pluginDir, entries, licenseRecord: readLicense() });
|
|
143
|
+
log(`Plugin materialized at ${pluginDir}`);
|
|
144
|
+
|
|
145
|
+
// Register with Claude Code. The exact marketplace/scope flags are Claude
|
|
146
|
+
// Code semantics that must be confirmed against a live install (clean-room
|
|
147
|
+
// C3) — the runner is injected so this stays testable, and --project vs
|
|
148
|
+
// --global is passed through for that verification.
|
|
149
|
+
const scopeFlag = scope === 'project' ? ['--project'] : [];
|
|
150
|
+
const add = await runClaude(['plugin', 'marketplace', 'add', pluginDir, ...scopeFlag]);
|
|
151
|
+
if (add.code !== 0) throw new Error(`claude plugin marketplace add failed: ${add.stderr ?? `exit ${add.code}`}`);
|
|
152
|
+
const install = await runClaude(['plugin', 'install', 'clawform@clawform', ...scopeFlag]);
|
|
153
|
+
if (install.code !== 0) throw new Error(`claude plugin install failed: ${install.stderr ?? `exit ${install.code}`}`);
|
|
154
|
+
|
|
155
|
+
log(`Clawform plugin installed (${scope}). Restart Claude Code to pick it up.`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function defaultRunClaude(args) {
|
|
159
|
+
const { execa } = await import('execa');
|
|
160
|
+
try {
|
|
161
|
+
const r = await execa('claude', args, { reject: false });
|
|
162
|
+
return { code: r.exitCode, stderr: r.stderr };
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// `claude` not on PATH is the common case — report it as a failed run, not
|
|
165
|
+
// a thrown stack trace, so the command's own error message can guide.
|
|
166
|
+
return { code: 127, stderr: err?.shortMessage ?? String(err) };
|
|
167
|
+
}
|
|
168
|
+
}
|