@thejoseki/clawform 0.0.1 → 2.2.1
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 +93 -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 +150 -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,360 @@
|
|
|
1
|
+
// clawform init — interactive wizard that produces a consumer-specific
|
|
2
|
+
// clawform.config.json at the project root, plus an optional starter
|
|
3
|
+
// .claude/settings.local.json copied from the example.
|
|
4
|
+
//
|
|
5
|
+
// Three modes:
|
|
6
|
+
// 1. Interactive — TTY attached; uses `inquirer` to walk the operator
|
|
7
|
+
// through every field. Default mode.
|
|
8
|
+
// 2. Non-interactive — non-TTY (CI) **or** any CLAWFORM_INIT_* env var is
|
|
9
|
+
// set. Reads every value from env vars; refuses with a clear message
|
|
10
|
+
// if a TTY is unavailable AND no env vars are present.
|
|
11
|
+
// 3. Refuse-overwrite — if clawform.config.json already exists and --force
|
|
12
|
+
// was not passed, print the existing file's location + values and exit.
|
|
13
|
+
//
|
|
14
|
+
// Validators are deliberately shared between modes so the env-var path can
|
|
15
|
+
// never write garbage that the interactive prompts would have rejected.
|
|
16
|
+
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import { mkdir, readFile, writeFile, copyFile } from 'node:fs/promises';
|
|
19
|
+
import { join, dirname } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import inquirer from 'inquirer';
|
|
22
|
+
import chalk from 'chalk';
|
|
23
|
+
import { logger } from '../lib/logger.js';
|
|
24
|
+
import { readEnv, ENV_PREFIX, DEPRECATED_ENV_PREFIX } from '../lib/rename-compat.js';
|
|
25
|
+
import { CUSTOMER_RE, CUSTOMER_MAX_LEN } from '../lib/constants.js';
|
|
26
|
+
|
|
27
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
|
|
29
|
+
// Single source of truth for both interactive validators and the env-var path.
|
|
30
|
+
// Throw on invalid input — caller decides whether to translate the throw into
|
|
31
|
+
// a prompt re-ask (inquirer expects validators that return true/string) or
|
|
32
|
+
// surface it as a fatal CLI error.
|
|
33
|
+
const VALID_REGIONS = [
|
|
34
|
+
'us-east-1', 'us-east-2', 'us-west-1', 'us-west-2',
|
|
35
|
+
'ap-northeast-1', 'ap-northeast-2', 'ap-northeast-3',
|
|
36
|
+
'ap-southeast-1', 'ap-southeast-2', 'ap-southeast-3', 'ap-southeast-4',
|
|
37
|
+
'ap-south-1', 'ap-east-1',
|
|
38
|
+
'eu-west-1', 'eu-west-2', 'eu-west-3',
|
|
39
|
+
'eu-central-1', 'eu-north-1', 'eu-south-1',
|
|
40
|
+
'ca-central-1', 'sa-east-1',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const ACCOUNT_RE = /^\d{12}$/;
|
|
44
|
+
const ENV_TOKENS = ['d', 'stg', 'p'];
|
|
45
|
+
|
|
46
|
+
export const validators = {
|
|
47
|
+
customer(value) {
|
|
48
|
+
if (typeof value !== 'string' || value.length === 0) return 'customer is required';
|
|
49
|
+
if (!CUSTOMER_RE.test(value)) {
|
|
50
|
+
return `customer must match ${CUSTOMER_RE.source} — lowercase alphanumeric, max ${CUSTOMER_MAX_LEN} chars (the AWS load-balancer name budget; see templates/alb.yaml)`;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
},
|
|
54
|
+
region(value) {
|
|
55
|
+
if (typeof value !== 'string' || value.length === 0) return 'region is required';
|
|
56
|
+
if (!VALID_REGIONS.includes(value)) {
|
|
57
|
+
return `region "${value}" is not in the common-regions list. Add it to VALID_REGIONS if you really need it.`;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
},
|
|
61
|
+
accountId(value) {
|
|
62
|
+
if (value === '' || value === undefined || value === null) return true; // blank is allowed
|
|
63
|
+
if (!ACCOUNT_RE.test(String(value).trim())) return 'account ID must be 12 digits (or blank to skip the guard)';
|
|
64
|
+
return true;
|
|
65
|
+
},
|
|
66
|
+
envs(value) {
|
|
67
|
+
if (!Array.isArray(value) || value.length === 0) return 'pick at least one env';
|
|
68
|
+
for (const v of value) {
|
|
69
|
+
if (!ENV_TOKENS.includes(v)) return `unknown env token "${v}" (allowed: ${ENV_TOKENS.join(', ')})`;
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function assertValid(field, value) {
|
|
76
|
+
const result = validators[field](value);
|
|
77
|
+
if (result !== true) throw new Error(`${field}: ${result}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Trim + drop empty entries; used for the comma-separated optional fields.
|
|
81
|
+
function splitCsv(raw) {
|
|
82
|
+
if (!raw || typeof raw !== 'string') return [];
|
|
83
|
+
return raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Build a config object in the exact key order used by clawform.config.example.json
|
|
87
|
+
// so diffs against the example stay clean.
|
|
88
|
+
function buildConfig({ customer, envs, region, accounts, legacy_prefixes, service_shortnames_extra, tags_defaults }) {
|
|
89
|
+
return {
|
|
90
|
+
$schema: './docs/config-schema.md',
|
|
91
|
+
customer,
|
|
92
|
+
envs,
|
|
93
|
+
region,
|
|
94
|
+
accounts,
|
|
95
|
+
legacy_prefixes,
|
|
96
|
+
service_shortnames_extra,
|
|
97
|
+
tags_defaults,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isInteractive() {
|
|
102
|
+
return Boolean(process.stdin?.isTTY && process.stdout?.isTTY);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Non-interactive path: read every value from CLAWFORM_INIT_* env vars,
|
|
106
|
+
// validate, and return the assembled config. Env-var presence (any one of
|
|
107
|
+
// them set) is the SOLE trigger for non-interactive mode — there is no
|
|
108
|
+
// separate commander flag.
|
|
109
|
+
//
|
|
110
|
+
// Env vars:
|
|
111
|
+
// CLAWFORM_INIT_CUSTOMER required
|
|
112
|
+
// CLAWFORM_INIT_REGION required
|
|
113
|
+
// CLAWFORM_INIT_ENVS optional, csv, default "d,stg,p"
|
|
114
|
+
// CLAWFORM_INIT_ACCOUNT_D optional, 12-digit
|
|
115
|
+
// CLAWFORM_INIT_ACCOUNT_STG optional, 12-digit
|
|
116
|
+
// CLAWFORM_INIT_ACCOUNT_P optional, 12-digit
|
|
117
|
+
// CLAWFORM_INIT_LEGACY_PREFIXES optional, csv, default ""
|
|
118
|
+
// CLAWFORM_INIT_OWNER optional
|
|
119
|
+
// CLAWFORM_INIT_COST_CENTER optional
|
|
120
|
+
// CLAWFORM_INIT_SERVICE_SHORTNAMES_EXTRA optional, csv
|
|
121
|
+
export function hasNonInteractiveEnv(env = process.env) {
|
|
122
|
+
// Both spellings count as "the caller wants non-interactive mode", so a
|
|
123
|
+
// pre-rename CI job still skips the prompts. Referenced through the compat
|
|
124
|
+
// constants rather than written out, so a rename sweep cannot quietly
|
|
125
|
+
// collapse the two branches into the same string.
|
|
126
|
+
return Object.keys(env).some(
|
|
127
|
+
(k) => k.startsWith(`${ENV_PREFIX}INIT_`) || k.startsWith(`${DEPRECATED_ENV_PREFIX}INIT_`),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function buildConfigFromEnv(env = process.env) {
|
|
132
|
+
const customer = (readEnv('INIT_CUSTOMER', env) ?? '').trim();
|
|
133
|
+
const region = (readEnv('INIT_REGION', env) ?? '').trim();
|
|
134
|
+
const envs = splitCsv(readEnv('INIT_ENVS', env) ?? 'd,stg,p');
|
|
135
|
+
|
|
136
|
+
assertValid('customer', customer);
|
|
137
|
+
assertValid('region', region);
|
|
138
|
+
assertValid('envs', envs);
|
|
139
|
+
|
|
140
|
+
const accounts = {};
|
|
141
|
+
for (const e of envs) {
|
|
142
|
+
const v = (readEnv(`INIT_ACCOUNT_${e.toUpperCase()}`, env) ?? '').trim();
|
|
143
|
+
assertValid('accountId', v);
|
|
144
|
+
accounts[e] = v;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const legacy_prefixes = splitCsv(readEnv('INIT_LEGACY_PREFIXES', env) ?? '');
|
|
148
|
+
const service_shortnames_extra = splitCsv(readEnv('INIT_SERVICE_SHORTNAMES_EXTRA', env) ?? '');
|
|
149
|
+
|
|
150
|
+
const tags_defaults = {};
|
|
151
|
+
const owner = (readEnv('INIT_OWNER', env) ?? '').trim();
|
|
152
|
+
const cost = (readEnv('INIT_COST_CENTER', env) ?? '').trim();
|
|
153
|
+
if (owner) tags_defaults.Owner = owner;
|
|
154
|
+
if (cost) tags_defaults.CostCenter = cost;
|
|
155
|
+
|
|
156
|
+
return buildConfig({ customer, envs, region, accounts, legacy_prefixes, service_shortnames_extra, tags_defaults });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function promptInteractive() {
|
|
160
|
+
logger.step('Clawform — greenfield project wizard');
|
|
161
|
+
logger.info('Press Ctrl+C at any time to abort. Nothing is written until the final step.');
|
|
162
|
+
|
|
163
|
+
const base = await inquirer.prompt([
|
|
164
|
+
{
|
|
165
|
+
type: 'input',
|
|
166
|
+
name: 'customer',
|
|
167
|
+
message: 'Customer shortname (the <customer> slot in stack names):',
|
|
168
|
+
validate: validators.customer,
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
type: 'list',
|
|
172
|
+
name: 'region',
|
|
173
|
+
message: 'Default AWS region:',
|
|
174
|
+
choices: VALID_REGIONS,
|
|
175
|
+
default: 'ap-northeast-1',
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
type: 'checkbox',
|
|
179
|
+
name: 'envs',
|
|
180
|
+
message: 'Environments this project will deploy to:',
|
|
181
|
+
choices: ENV_TOKENS.map((e) => ({ name: e, value: e, checked: true })),
|
|
182
|
+
validate: (v) => (v.length === 0 ? 'pick at least one env' : true),
|
|
183
|
+
},
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
// Per-env account prompts depend on the envs answer, so they must be a
|
|
187
|
+
// separate prompt call. Each is optional — blank disables the account guard.
|
|
188
|
+
const accounts = {};
|
|
189
|
+
for (const env of base.envs) {
|
|
190
|
+
const { accountId } = await inquirer.prompt([
|
|
191
|
+
{
|
|
192
|
+
type: 'input',
|
|
193
|
+
name: 'accountId',
|
|
194
|
+
message: `AWS account ID for env "${env}" (12 digits, blank = no account-match guard):`,
|
|
195
|
+
validate: validators.accountId,
|
|
196
|
+
},
|
|
197
|
+
]);
|
|
198
|
+
accounts[env] = (accountId ?? '').trim();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const tail = await inquirer.prompt([
|
|
202
|
+
{
|
|
203
|
+
type: 'input',
|
|
204
|
+
name: 'legacy_prefixes_raw',
|
|
205
|
+
// The "empty is correct for a greenfield consumer" hint is critical —
|
|
206
|
+
// new operators see a blank default and assume they should fill it.
|
|
207
|
+
// Keep it in the `message` (not `suffix`) so it renders on every
|
|
208
|
+
// inquirer version we care about, and so it shows up in screen
|
|
209
|
+
// captures of the prompt rendering.
|
|
210
|
+
message: `Legacy stack-name prefixes the hook should refuse to modify (comma-separated). ${chalk.gray('Empty is correct for a greenfield consumer.')}`,
|
|
211
|
+
default: '',
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
type: 'input',
|
|
215
|
+
name: 'owner',
|
|
216
|
+
message: 'Default Owner tag (optional, blank to skip):',
|
|
217
|
+
default: '',
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
type: 'input',
|
|
221
|
+
name: 'cost_center',
|
|
222
|
+
message: 'Default CostCenter tag (optional, blank to skip):',
|
|
223
|
+
default: '',
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
type: 'input',
|
|
227
|
+
name: 'service_shortnames_extra_raw',
|
|
228
|
+
message: 'Extra service shortnames beyond the built-ins (comma-separated, optional):',
|
|
229
|
+
default: '',
|
|
230
|
+
},
|
|
231
|
+
]);
|
|
232
|
+
|
|
233
|
+
const tags_defaults = {};
|
|
234
|
+
if (tail.owner.trim()) tags_defaults.Owner = tail.owner.trim();
|
|
235
|
+
if (tail.cost_center.trim()) tags_defaults.CostCenter = tail.cost_center.trim();
|
|
236
|
+
|
|
237
|
+
return buildConfig({
|
|
238
|
+
customer: base.customer.trim(),
|
|
239
|
+
envs: base.envs,
|
|
240
|
+
region: base.region,
|
|
241
|
+
accounts,
|
|
242
|
+
legacy_prefixes: splitCsv(tail.legacy_prefixes_raw),
|
|
243
|
+
service_shortnames_extra: splitCsv(tail.service_shortnames_extra_raw),
|
|
244
|
+
tags_defaults,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function maybeBootstrapSettings(cwd) {
|
|
249
|
+
// The example lives in this repo's own .claude/. When clawform is installed
|
|
250
|
+
// as a plugin elsewhere, the example file is shipped via package.json
|
|
251
|
+
// `files` and resolved relative to this module. Same path on disk; resolve
|
|
252
|
+
// from __dirname so the plugin-install case works.
|
|
253
|
+
const target = join(cwd, '.claude', 'settings.local.json');
|
|
254
|
+
if (existsSync(target)) {
|
|
255
|
+
logger.info(`.claude/settings.local.json already exists — leaving it alone.`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const pluginRoot = join(__dirname, '..', '..');
|
|
259
|
+
const example = join(pluginRoot, '.claude', 'settings.local.json.example');
|
|
260
|
+
if (!existsSync(example)) {
|
|
261
|
+
logger.info('No settings.local.json.example found in plugin — skipping bootstrap.');
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const { create } = await inquirer.prompt([
|
|
266
|
+
{
|
|
267
|
+
type: 'confirm',
|
|
268
|
+
name: 'create',
|
|
269
|
+
message: 'Create .claude/settings.local.json from the example (per-user env: AWS_PROFILE, etc.)?',
|
|
270
|
+
default: true,
|
|
271
|
+
},
|
|
272
|
+
]);
|
|
273
|
+
if (!create) return;
|
|
274
|
+
|
|
275
|
+
const { profile } = await inquirer.prompt([
|
|
276
|
+
{
|
|
277
|
+
type: 'input',
|
|
278
|
+
name: 'profile',
|
|
279
|
+
message: 'AWS_PROFILE to use locally (blank to keep the example default):',
|
|
280
|
+
default: '',
|
|
281
|
+
},
|
|
282
|
+
]);
|
|
283
|
+
|
|
284
|
+
await mkdir(join(cwd, '.claude'), { recursive: true });
|
|
285
|
+
await copyFile(example, target);
|
|
286
|
+
|
|
287
|
+
if (profile.trim()) {
|
|
288
|
+
// Re-read what we just wrote and patch only AWS_PROFILE; never touch
|
|
289
|
+
// anything else, so the rest of the example (region defaults, etc.) is
|
|
290
|
+
// preserved exactly.
|
|
291
|
+
const raw = await readFile(target, 'utf8');
|
|
292
|
+
const parsed = JSON.parse(raw);
|
|
293
|
+
parsed.env = parsed.env || {};
|
|
294
|
+
parsed.env.AWS_PROFILE = profile.trim();
|
|
295
|
+
await writeFile(target, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
logger.ok(`Wrote ${target}`);
|
|
299
|
+
logger.info('That file is gitignored — keep per-user secrets there, not in clawform.config.json.');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export default async function init(opts = {}) {
|
|
303
|
+
const cwd = process.cwd();
|
|
304
|
+
const configPath = join(cwd, 'clawform.config.json');
|
|
305
|
+
|
|
306
|
+
if (existsSync(configPath) && !opts.force) {
|
|
307
|
+
logger.warn(`Refusing to overwrite existing ${configPath}.`);
|
|
308
|
+
try {
|
|
309
|
+
const raw = await readFile(configPath, 'utf8');
|
|
310
|
+
logger.info('Current contents:');
|
|
311
|
+
console.log(raw);
|
|
312
|
+
} catch {
|
|
313
|
+
// existence check already succeeded; ignore read failures
|
|
314
|
+
}
|
|
315
|
+
logger.info('Re-run with --force to overwrite, or delete the file first.');
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const envHasInit = hasNonInteractiveEnv();
|
|
320
|
+
let config;
|
|
321
|
+
|
|
322
|
+
if (envHasInit) {
|
|
323
|
+
logger.step('Non-interactive mode (CLAWFORM_INIT_* env vars detected).');
|
|
324
|
+
config = buildConfigFromEnv();
|
|
325
|
+
} else if (!isInteractive()) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
'No TTY detected and no CLAWFORM_INIT_* env vars set.\n' +
|
|
328
|
+
' Run interactively, OR provide:\n' +
|
|
329
|
+
' CLAWFORM_INIT_CUSTOMER=<shortname>\n' +
|
|
330
|
+
' CLAWFORM_INIT_REGION=ap-northeast-1\n' +
|
|
331
|
+
' CLAWFORM_INIT_ENVS=d,stg,p (optional, default d,stg,p)\n' +
|
|
332
|
+
' CLAWFORM_INIT_ACCOUNT_D=<12-digit> (optional)\n' +
|
|
333
|
+
' CLAWFORM_INIT_ACCOUNT_STG=<12-digit> (optional)\n' +
|
|
334
|
+
' CLAWFORM_INIT_ACCOUNT_P=<12-digit> (optional)\n' +
|
|
335
|
+
' CLAWFORM_INIT_LEGACY_PREFIXES=OLDNET,OLDAPP (optional, default empty)\n' +
|
|
336
|
+
' CLAWFORM_INIT_OWNER=<name> (optional)\n' +
|
|
337
|
+
' CLAWFORM_INIT_COST_CENTER=<code> (optional)\n' +
|
|
338
|
+
' CLAWFORM_INIT_SERVICE_SHORTNAMES_EXTRA=<csv> (optional)'
|
|
339
|
+
);
|
|
340
|
+
} else {
|
|
341
|
+
config = await promptInteractive();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
345
|
+
logger.ok(`Wrote ${configPath}`);
|
|
346
|
+
|
|
347
|
+
if (isInteractive()) {
|
|
348
|
+
await maybeBootstrapSettings(cwd);
|
|
349
|
+
} else {
|
|
350
|
+
logger.info('Skipping .claude/settings.local.json bootstrap (non-interactive).');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
logger.step('Next steps:');
|
|
354
|
+
logger.info(' 1. Open this project in Claude Code.');
|
|
355
|
+
logger.info(' 2. Greenfield architecture: /clawform:scaffold-project');
|
|
356
|
+
logger.info(' 3. Add a single stack: /clawform:new-stack');
|
|
357
|
+
logger.info(' 4. Validate a template: /clawform:validate cfn/<name>.yaml');
|
|
358
|
+
logger.info(' 5. Deploy: /clawform:deploy cfn/<name>.yaml <env>');
|
|
359
|
+
logger.info('Docs: docs/runbook.md (scenario-first) and CLAUDE.md (hard rules).');
|
|
360
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// clawform license [--check] — show what this machine holds. --check is the
|
|
2
|
+
// scriptable form: silent success when active, a thrown (exit-1) error when
|
|
3
|
+
// not, so skills can gate a workflow step on it without parsing output.
|
|
4
|
+
|
|
5
|
+
import { logger } from '../lib/logger.js';
|
|
6
|
+
import { readLicense } from '../lib/license-store.js';
|
|
7
|
+
|
|
8
|
+
export default async function licenseStatus({ check = false } = {}, {
|
|
9
|
+
read = readLicense,
|
|
10
|
+
log = (m) => logger.info(m),
|
|
11
|
+
} = {}) {
|
|
12
|
+
const record = read();
|
|
13
|
+
const active = record.status === 'active' && Boolean(record.key);
|
|
14
|
+
|
|
15
|
+
if (check) {
|
|
16
|
+
if (active) return;
|
|
17
|
+
throw new Error('No active license on this machine. Run: clawform activate <key>.');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!active) {
|
|
21
|
+
log('License: none — run `clawform activate <key>` (the key is in your purchase email).');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
log(`License: active${record.holder ? ` — ${record.holder}` : ''}`);
|
|
26
|
+
log(`Key: …${String(record.key).slice(-4)}`);
|
|
27
|
+
if (record.lastValidatedAt) log(`Last verified: ${record.lastValidatedAt}`);
|
|
28
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DescribeStacksCommand,
|
|
3
|
+
DescribeStackResourcesCommand,
|
|
4
|
+
} from '@aws-sdk/client-cloudformation';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { logger } from '../lib/logger.js';
|
|
7
|
+
import { cfnClient } from '../lib/aws-client.js';
|
|
8
|
+
import { isDataResource } from '../lib/constants.js';
|
|
9
|
+
import { loadProjectConfig, buildLegacyPrefixRegex, buildAnchoredLegacyPrefixRegex, resolveProjectDir } from '../lib/project-config.js';
|
|
10
|
+
import { assertNoFrozenExports } from '../aws/exports.js';
|
|
11
|
+
|
|
12
|
+
export default async function rollback({ stack }) {
|
|
13
|
+
logger.step(`clawform rollback ${stack}`);
|
|
14
|
+
|
|
15
|
+
// Legacy-freeze gate (mirrors deploy.js): the freeze applies to recovery,
|
|
16
|
+
// not just edits. The CLI refuses outright — no override flag. The
|
|
17
|
+
// documented escape hatch is running the raw aws CLI with a
|
|
18
|
+
// `# FORCE LEGACY: <reason>` comment, which goes through the PreToolUse
|
|
19
|
+
// hook, not this CLI. See rules/legacy-freeze.md.
|
|
20
|
+
const projectCfg = loadProjectConfig({ projectDir: resolveProjectDir() });
|
|
21
|
+
const legacyRe = buildLegacyPrefixRegex(projectCfg.legacy_prefixes);
|
|
22
|
+
if (legacyRe && legacyRe.test(stack)) {
|
|
23
|
+
const list = projectCfg.legacy_prefixes.map((p) => `${p}-*`).join(', ');
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Stack "${stack}" matches a legacy prefix (${list}) from your clawform.config.json.\n` +
|
|
26
|
+
`clawform rollback refuses to suggest recovery commands for legacy stacks. See rules/legacy-freeze.md.\n` +
|
|
27
|
+
`If recovery is unavoidable, use the raw aws cloudformation CLI with a "# FORCE LEGACY: <reason>" comment on its own line.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const client = cfnClient();
|
|
32
|
+
|
|
33
|
+
const out = await client.send(new DescribeStacksCommand({ StackName: stack }));
|
|
34
|
+
const s = out.Stacks?.[0];
|
|
35
|
+
if (!s) throw new Error(`Stack ${stack} not found.`);
|
|
36
|
+
|
|
37
|
+
// C2: frozen-export gate — shared with deploy.js (src/aws/exports.js). A
|
|
38
|
+
// stack whose NAME isn't frozen may still emit legacy-prefixed EXPORT names;
|
|
39
|
+
// the freeze applies to recovery too (rules/legacy-freeze.md). Anchored
|
|
40
|
+
// regex: prefix must lead the export name.
|
|
41
|
+
assertNoFrozenExports({
|
|
42
|
+
liveOutputs: s.Outputs,
|
|
43
|
+
legacyRe: buildAnchoredLegacyPrefixRegex(projectCfg.legacy_prefixes),
|
|
44
|
+
stackName: stack,
|
|
45
|
+
action: 'recovery',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
logger.info(`Status: ${s.StackStatus}`);
|
|
49
|
+
|
|
50
|
+
switch (s.StackStatus) {
|
|
51
|
+
case 'UPDATE_ROLLBACK_FAILED':
|
|
52
|
+
case 'ROLLBACK_FAILED':
|
|
53
|
+
await suggestContinueRollback(client, stack);
|
|
54
|
+
break;
|
|
55
|
+
case 'CREATE_FAILED':
|
|
56
|
+
case 'ROLLBACK_COMPLETE':
|
|
57
|
+
await suggestDelete(client, stack);
|
|
58
|
+
break;
|
|
59
|
+
case 'UPDATE_FAILED':
|
|
60
|
+
logger.info('CFN will normally auto-roll-back to UPDATE_ROLLBACK_COMPLETE. If stuck, wait then re-run /rollback.');
|
|
61
|
+
break;
|
|
62
|
+
case 'CREATE_COMPLETE':
|
|
63
|
+
case 'UPDATE_COMPLETE':
|
|
64
|
+
case 'UPDATE_ROLLBACK_COMPLETE':
|
|
65
|
+
case 'IMPORT_COMPLETE':
|
|
66
|
+
logger.ok('Stack is healthy — nothing to roll back.');
|
|
67
|
+
break;
|
|
68
|
+
default:
|
|
69
|
+
if (/_IN_PROGRESS$/.test(s.StackStatus)) {
|
|
70
|
+
logger.info('Stack is mid-operation. Wait for terminal status, then re-run /rollback.');
|
|
71
|
+
} else {
|
|
72
|
+
logger.warn(`Status ${s.StackStatus} has no automatic recovery suggestion.`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function suggestContinueRollback(client, stack) {
|
|
78
|
+
const failed = await fetchFailedResources(client, stack);
|
|
79
|
+
if (failed.length === 0) {
|
|
80
|
+
logger.info(chalk.bold('Suggested:'));
|
|
81
|
+
logger.info(` aws cloudformation continue-update-rollback --stack-name ${stack}`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
logger.info(`Failed resources (${failed.length}): ${failed.join(', ')}`);
|
|
85
|
+
logger.info(chalk.bold('Suggested:'));
|
|
86
|
+
logger.info(` aws cloudformation continue-update-rollback --stack-name ${stack} \\`);
|
|
87
|
+
logger.info(` --resources-to-skip ${failed.join(' ')}`);
|
|
88
|
+
logger.warn('Confirm each skipped resource is acceptable to leave in mixed state before running.');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function suggestDelete(client, stack) {
|
|
92
|
+
const out = await client.send(new DescribeStackResourcesCommand({ StackName: stack }));
|
|
93
|
+
const dataResources = (out.StackResources ?? []).filter((r) => isDataResource(r.ResourceType));
|
|
94
|
+
if (dataResources.length > 0) {
|
|
95
|
+
logger.warn(`Stack contains ${dataResources.length} data resource(s):`);
|
|
96
|
+
for (const r of dataResources) logger.warn(` ${r.LogicalResourceId} (${r.ResourceType}) → ${r.PhysicalResourceId ?? '(no physical)'}`);
|
|
97
|
+
logger.info('Delete is unsafe. Options:');
|
|
98
|
+
logger.info(' 1. Verify each data resource has DeletionPolicy: Retain → delete-stack is safe (physical resource survives)');
|
|
99
|
+
logger.info(' 2. Import data resources into a new stack first (see rules/legacy-freeze.md "replace pattern")');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
logger.ok('No data resources detected.');
|
|
103
|
+
logger.info(chalk.bold('Suggested:'));
|
|
104
|
+
logger.info(` aws cloudformation delete-stack --stack-name ${stack}`);
|
|
105
|
+
logger.info('Then run: aws cloudformation wait stack-delete-complete --stack-name ' + stack);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function fetchFailedResources(client, stack) {
|
|
109
|
+
// Use DescribeStackResources, not DescribeStackEvents. Two reasons:
|
|
110
|
+
// 1. (bug_028) DescribeStackResources returns CURRENT per-resource status. The
|
|
111
|
+
// previous DescribeStackEvents+filter walked event HISTORY and added any
|
|
112
|
+
// resource that had ever-been-FAILED — so a resource that hit UPDATE_FAILED
|
|
113
|
+
// early and was then auto-rolled-back to UPDATE_COMPLETE stayed in the
|
|
114
|
+
// suggested --resources-to-skip list. AWS rejects continue-update-rollback
|
|
115
|
+
// with "resource X is not in a state to be skipped" for those, defeating
|
|
116
|
+
// the whole point of the suggestion.
|
|
117
|
+
// 2. (merged_bug_008) DescribeStackEvents is paginated and capped ~100/page;
|
|
118
|
+
// after a long forward update + rollback the original FAILED events scroll
|
|
119
|
+
// off page 1. DescribeStackResources avoids the pagination question entirely.
|
|
120
|
+
// The suggestDelete path already uses DescribeStackResources — this keeps the
|
|
121
|
+
// file consistent.
|
|
122
|
+
const out = await client.send(new DescribeStackResourcesCommand({ StackName: stack }));
|
|
123
|
+
return (out.StackResources ?? [])
|
|
124
|
+
.filter((r) => /_FAILED$/.test(r.ResourceStatus ?? '') && r.LogicalResourceId)
|
|
125
|
+
.map((r) => r.LogicalResourceId);
|
|
126
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// clawform status — a one-command day-2 health board for a project's stacks.
|
|
2
|
+
//
|
|
3
|
+
// For each live stack belonging to the project: its CloudFormation status, when
|
|
4
|
+
// it was last deployed, and its LAST-KNOWN drift status (from the ListStacks
|
|
5
|
+
// summary — this does NOT trigger a fresh drift detection, which costs time and
|
|
6
|
+
// API calls; run `clawform drift <stack>` for that). It also flags whether the
|
|
7
|
+
// project has a monitoring stack covering it, since "no alarms" is the silent
|
|
8
|
+
// gap [[observability]] exists to catch.
|
|
9
|
+
//
|
|
10
|
+
// Read-only. Pure helpers (filterProjectStacks / summarize / monitoringPresent /
|
|
11
|
+
// renderStatus) are unit-tested without AWS; status() is the thin SDK wrapper.
|
|
12
|
+
|
|
13
|
+
import { ListStacksCommand } from '@aws-sdk/client-cloudformation';
|
|
14
|
+
import chalk from 'chalk';
|
|
15
|
+
import { logger } from '../lib/logger.js';
|
|
16
|
+
import { cfnClient } from '../lib/aws-client.js';
|
|
17
|
+
import { ENVS } from '../lib/constants.js';
|
|
18
|
+
import { assertLicensed } from '../lib/license.js';
|
|
19
|
+
import { resolveClient as resolveLicenseClient } from '../lib/license-client.js';
|
|
20
|
+
|
|
21
|
+
// Every status a non-deleted stack can be in. ListStacks with StackStatusFilter
|
|
22
|
+
// returns ONLY the listed statuses, so an omission makes that stack VANISH from
|
|
23
|
+
// the board — and a board that hides DELETE_FAILED (the classic wedged-teardown
|
|
24
|
+
// state) is worse than no board. Named EXISTING (not LIVE) deliberately:
|
|
25
|
+
// src/aws/inventory.js has a different LIVE_STATUSES meaning "stable/deployable",
|
|
26
|
+
// and the two must not be unified.
|
|
27
|
+
const EXISTING_STATUSES = [
|
|
28
|
+
'CREATE_COMPLETE', 'UPDATE_COMPLETE', 'UPDATE_ROLLBACK_COMPLETE',
|
|
29
|
+
'IMPORT_COMPLETE', 'ROLLBACK_COMPLETE',
|
|
30
|
+
'CREATE_FAILED', 'UPDATE_FAILED', 'UPDATE_ROLLBACK_FAILED',
|
|
31
|
+
'ROLLBACK_FAILED', 'DELETE_FAILED', 'IMPORT_ROLLBACK_FAILED',
|
|
32
|
+
'CREATE_IN_PROGRESS', 'UPDATE_IN_PROGRESS', 'ROLLBACK_IN_PROGRESS',
|
|
33
|
+
'DELETE_IN_PROGRESS', 'UPDATE_ROLLBACK_IN_PROGRESS',
|
|
34
|
+
'UPDATE_COMPLETE_CLEANUP_IN_PROGRESS', 'UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS',
|
|
35
|
+
'IMPORT_IN_PROGRESS', 'IMPORT_ROLLBACK_IN_PROGRESS',
|
|
36
|
+
'REVIEW_IN_PROGRESS',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// Hand-rolled pagination (ListStacksCommand + NextToken) instead of
|
|
40
|
+
// paginateListStacks so a plain { send } stub mocks the seam in tests — the
|
|
41
|
+
// generated paginator hard-checks `client instanceof CloudFormationClient`.
|
|
42
|
+
export async function fetchStackSummaries(client) {
|
|
43
|
+
const out = [];
|
|
44
|
+
let nextToken;
|
|
45
|
+
do {
|
|
46
|
+
const resp = await client.send(new ListStacksCommand({
|
|
47
|
+
StackStatusFilter: EXISTING_STATUSES,
|
|
48
|
+
NextToken: nextToken,
|
|
49
|
+
}));
|
|
50
|
+
for (const s of resp.StackSummaries ?? []) {
|
|
51
|
+
out.push({
|
|
52
|
+
name: s.StackName,
|
|
53
|
+
status: s.StackStatus,
|
|
54
|
+
lastUpdatedTime: s.LastUpdatedTime ?? s.CreationTime ?? null,
|
|
55
|
+
driftStatus: s.DriftInformation?.StackDriftStatus ?? 'NOT_CHECKED',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
nextToken = resp.NextToken;
|
|
59
|
+
} while (nextToken);
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Stacks belonging to a project: the `<project>` token appears as a hyphen-
|
|
64
|
+
// delimited segment of the name (e.g. d-acme-pri-snet-myproj-zonea → 'myproj').
|
|
65
|
+
// Optional env narrows to names starting `<env>-`.
|
|
66
|
+
export function filterProjectStacks(summaries, { project, env } = {}) {
|
|
67
|
+
return (summaries ?? []).filter((s) => {
|
|
68
|
+
const segments = (s.name ?? '').split('-');
|
|
69
|
+
if (project && !segments.includes(project)) return false;
|
|
70
|
+
if (env && !(s.name ?? '').startsWith(`${env}-`)) return false;
|
|
71
|
+
return true;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A monitoring stack is present if any stack in the set names the monitoring
|
|
76
|
+
// archetype or carries the cwa (CloudWatch alarm) service token.
|
|
77
|
+
export function monitoringPresent(stacks) {
|
|
78
|
+
return (stacks ?? []).some((s) => {
|
|
79
|
+
const segs = (s.name ?? '').split('-');
|
|
80
|
+
return segs.includes('monitoring') || segs.includes('cwa');
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function healthOf(status) {
|
|
85
|
+
if (/_FAILED$/.test(status)) return 'failed'; // incl. DELETE_FAILED — a wedged teardown is broken, not gone
|
|
86
|
+
if (/_IN_PROGRESS$/.test(status)) return 'in-progress';
|
|
87
|
+
if (status === 'ROLLBACK_COMPLETE') return 'failed';
|
|
88
|
+
return 'ok';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function summarize(stacks) {
|
|
92
|
+
return [...(stacks ?? [])]
|
|
93
|
+
.map((s) => ({
|
|
94
|
+
name: s.name,
|
|
95
|
+
status: s.status,
|
|
96
|
+
health: healthOf(s.status),
|
|
97
|
+
updated: s.lastUpdatedTime ? new Date(s.lastUpdatedTime).toISOString().slice(0, 10) : '—',
|
|
98
|
+
drift: s.driftStatus,
|
|
99
|
+
}))
|
|
100
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const HEALTH_COLOR = { ok: chalk.green, failed: chalk.red, 'in-progress': chalk.yellow };
|
|
104
|
+
const DRIFT_COLOR = { DRIFTED: chalk.red, IN_SYNC: chalk.green };
|
|
105
|
+
|
|
106
|
+
export function renderStatus(rows, meta) {
|
|
107
|
+
const lines = [];
|
|
108
|
+
const scope = [meta.env && `env ${meta.env}`, meta.project ? `project ${meta.project}` : 'all projects']
|
|
109
|
+
.filter(Boolean).join(', ');
|
|
110
|
+
lines.push(`Stack status — ${scope}`);
|
|
111
|
+
lines.push('');
|
|
112
|
+
if (rows.length === 0) {
|
|
113
|
+
lines.push(' (no matching stacks)');
|
|
114
|
+
return lines.join('\n');
|
|
115
|
+
}
|
|
116
|
+
const w = Math.max(20, ...rows.map((r) => r.name.length));
|
|
117
|
+
for (const r of rows) {
|
|
118
|
+
const statusColor = HEALTH_COLOR[r.health] ?? ((s) => s);
|
|
119
|
+
const driftColor = DRIFT_COLOR[r.drift] ?? chalk.gray;
|
|
120
|
+
lines.push(
|
|
121
|
+
` ${r.name.padEnd(w)} ${statusColor(r.status.padEnd(24))} ${r.updated} ${driftColor(r.drift)}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
lines.push('');
|
|
125
|
+
const failed = rows.filter((r) => r.health === 'failed').length;
|
|
126
|
+
const drifted = rows.filter((r) => r.drift === 'DRIFTED').length;
|
|
127
|
+
lines.push(` ${rows.length} stack(s) · ${failed} failed · ${drifted} drifted`);
|
|
128
|
+
if (!meta.hasMonitoring) {
|
|
129
|
+
lines.push(chalk.yellow(' ⚠ No monitoring stack detected for this scope — see observability (templates/monitoring.yaml).'));
|
|
130
|
+
}
|
|
131
|
+
lines.push(chalk.gray(' Drift = last known (not re-checked). Run `clawform drift <stack>` for a fresh detection.'));
|
|
132
|
+
return lines.join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export default async function status({ project, env, profile, region } = {}) {
|
|
136
|
+
await assertLicensed({ command: 'status', client: resolveLicenseClient() });
|
|
137
|
+
if (env && !ENVS.includes(env)) {
|
|
138
|
+
throw new Error(`Invalid --env "${env}". Expected one of: ${ENVS.join(' | ')}`);
|
|
139
|
+
}
|
|
140
|
+
const client = cfnClient({
|
|
141
|
+
...(profile ? { AWS_PROFILE: profile } : {}),
|
|
142
|
+
...(region ? { AWS_REGION: region } : {}),
|
|
143
|
+
});
|
|
144
|
+
logger.step(`clawform status${project ? ` — ${project}` : ''}`);
|
|
145
|
+
|
|
146
|
+
const all = await fetchStackSummaries(client);
|
|
147
|
+
const mine = filterProjectStacks(all, { project, env });
|
|
148
|
+
const rows = summarize(mine);
|
|
149
|
+
console.log(renderStatus(rows, { project, env, hasMonitoring: monitoringPresent(mine) }));
|
|
150
|
+
}
|