@thejoseki/clawform 2.3.1 → 2.4.0

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