@thejoseki/clawform 2.3.1 → 2.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -120
- package/bin/clawform.js +161 -151
- package/package.json +65 -65
- package/src/aws/catalog.js +116 -116
- package/src/aws/changeset.js +231 -231
- package/src/aws/drift.js +59 -59
- package/src/aws/exports.js +213 -213
- package/src/aws/inventory.js +156 -156
- package/src/commands/activate.js +137 -105
- package/src/commands/ci-init.js +393 -212
- package/src/commands/cost-report.js +163 -163
- package/src/commands/deactivate.js +9 -2
- package/src/commands/deploy.js +413 -413
- package/src/commands/diff.js +39 -39
- package/src/commands/drift.js +35 -35
- package/src/commands/init-plugin.js +6 -6
- package/src/commands/init.js +360 -360
- package/src/commands/rollback.js +126 -126
- package/src/commands/status.js +147 -147
- package/src/commands/sync.js +50 -50
- package/src/commands/update.js +24 -0
- package/src/commands/validate.js +349 -349
- package/src/hooks/block-dangerous.js +62 -62
- package/src/hooks/cfn-lint-after-edit-eval.js +30 -30
- package/src/hooks/cfn-lint-after-edit.js +81 -81
- package/src/hooks/check-catalog-fresh-eval.js +63 -63
- package/src/hooks/check-catalog-fresh.js +33 -33
- package/src/hooks/session-context-eval.js +81 -81
- package/src/hooks/session-context.js +41 -41
- package/src/hooks/subagent-context-eval.js +44 -44
- package/src/hooks/subagent-context.js +48 -48
- package/src/lib/audit-synthesis.js +24 -24
- package/src/lib/aws-client.js +15 -15
- package/src/lib/cfn-yaml.js +92 -92
- package/src/lib/config.js +76 -76
- package/src/lib/constants.js +85 -85
- package/src/lib/defaults.js +16 -16
- package/src/lib/install-workflow.js +28 -28
- package/src/lib/kit-source.js +43 -5
- package/src/lib/license-client.js +84 -84
- package/src/lib/license-config.js +162 -162
- package/src/lib/license-store.js +43 -8
- package/src/lib/license.js +163 -150
- package/src/lib/lint-overrides.js +226 -226
- package/src/lib/logger.js +16 -16
- package/src/lib/project-config.js +145 -145
- package/src/lib/providers/polar.js +215 -215
- package/src/lib/session-state.js +91 -91
package/src/commands/init.js
CHANGED
|
@@ -1,360 +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
|
-
}
|
|
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
|
+
}
|