@thejoseki/clawform 2.3.0 → 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.
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 +34 -2
  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 +15 -2
  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,212 +1,393 @@
1
- // clawform ci init — generate a .gitlab-ci.yml for the Clawform CI model:
2
- // GitLab On-Prem is the source of truth; AWS just runs the pipeline.
3
- //
4
- // validate (every MR, offline) → deploy:upload (protected branch)
5
- // - validate: cfn-lint the cfn/ templates on a GitLab Runner. No AWS creds,
6
- // so it's safe on any runner and fast on every merge request. cfn-lint is
7
- // the CI floor local `clawform validate` adds cfn-guard + cfn-nag +
8
- // lint-overrides on top; the generated file says so.
9
- // - deploy:upload: zip the repo and put it to the pipeline's SourceBucket as
10
- // source.zip, which triggers the CodePipeline stood up by
11
- // templates/cicd-pipeline.yaml (validate manual approval execute).
12
- //
13
- // The CLI writes the file and prints the next steps; it does NOT touch AWS.
14
- // renderGitlabCi() is a pure string builder so it's unit-tested without I/O.
15
-
16
- import { writeFile, mkdir } from 'node:fs/promises';
17
- import { existsSync } from 'node:fs';
18
- import { join, dirname } from 'node:path';
19
- import { logger } from '../lib/logger.js';
20
- import { assertLicensed } from '../lib/license.js';
21
- import { resolveClient as resolveLicenseClient } from '../lib/license-client.js';
22
-
23
- const BUCKET_PLACEHOLDER = '<PIPELINE_SOURCE_BUCKET>';
24
-
25
- export function renderGitlabCi({ cfnDir = 'cfn', branch = 'main', sourceBucket } = {}) {
26
- const bucket = sourceBucket || BUCKET_PLACEHOLDER;
27
- return `# Generated by \`clawform ci init\`. Clawform CI for GitLab On-Prem.
28
- # Model: GitLab is the source of truth; AWS runs the pipeline. The protected
29
- # branch uploads a source bundle to S3, which triggers the CodePipeline from
30
- # templates/cicd-pipeline.yaml (validate -> manual approval -> execute).
31
- #
32
- # Required CI/CD variables (Settings -> CI/CD -> Variables), protected + masked:
33
- # AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY -> an IAM user scoped to
34
- # s3:PutObject on the pipeline SourceBucket (or use a runner instance role)
35
- # AWS_DEFAULT_REGION -> the pipeline's region
36
-
37
- stages:
38
- - validate
39
- - deploy
40
-
41
- # Every merge request: lint the templates. Offline — no AWS credentials needed.
42
- # NOTE: cfn-lint is the CI *floor*. Local \`clawform validate\` additionally runs
43
- # cfn-guard (the shipped rules.guard, incl. the Rule #4 retention checks) and
44
- # cfn-nag — run it before merging; green CI is not the same as a green /validate.
45
- validate:cfn:
46
- stage: validate
47
- image: python:3.12-slim
48
- rules:
49
- - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
50
- variables:
51
- PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
52
- cache:
53
- key: clawform-pip
54
- paths:
55
- - .cache/pip
56
- script:
57
- - pip install --quiet cfn-lint
58
- - cfn-lint ${cfnDir}/*.yaml
59
-
60
- # Protected branch only: bundle the repo and hand it to the pipeline. CodePipeline
61
- # re-validates, builds a change-set, and waits for a human to approve in the
62
- # CodePipeline console before executing (Rule #5 approval gate, off-TTY).
63
- # The entrypoint override is REQUIRED: amazon/aws-cli's Docker ENTRYPOINT is the
64
- # aws binary itself, so without it the runner cannot start a shell in the container.
65
- deploy:upload:
66
- stage: deploy
67
- image:
68
- name: amazon/aws-cli:latest
69
- entrypoint: [""]
70
- rules:
71
- - if: '$CI_COMMIT_BRANCH == "${branch}"'
72
- before_script:
73
- - yum install -y -q zip
74
- script:
75
- # Bundle the template dir plus the optional config/params paths that exist.
76
- # Built explicitly (no stderr suppression, no || fallback) so a real zip
77
- # failure fails the job instead of uploading a partial bundle.
78
- - BUNDLE_PATHS="${cfnDir}"
79
- - for p in clawform.config.json params; do [ -e "$p" ] && BUNDLE_PATHS="$BUNDLE_PATHS $p"; done
80
- - echo "Bundling: $BUNDLE_PATHS"
81
- - zip -r source.zip $BUNDLE_PATHS
82
- - aws s3 cp source.zip "s3://${bucket}/source.zip"
83
- # The pipeline takes over from here — approve the change-set in CodePipeline.
84
- `;
85
- }
86
-
87
- // Same Git → S3 → CodePipeline model as the GitLab variant, expressed as a
88
- // GitHub Actions workflow. Pure string builder, unit-tested without I/O.
89
- export function renderGithubActions({ cfnDir = 'cfn', branch = 'main', sourceBucket } = {}) {
90
- const bucket = sourceBucket || BUCKET_PLACEHOLDER;
91
- return `# Generated by \`clawform ci init --provider github\`. Clawform CI for GitHub.
92
- # Model: GitHub is the source of truth; AWS runs the pipeline. The protected
93
- # branch uploads a source bundle to S3, which triggers the CodePipeline from
94
- # templates/cicd-pipeline.yaml (validate -> manual approval -> execute).
95
- #
96
- # Required repository secrets (Settings -> Secrets and variables -> Actions):
97
- # AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY -> an IAM user scoped to
98
- # s3:PutObject on the pipeline SourceBucket
99
- # AWS_DEFAULT_REGION -> the pipeline's region
100
-
101
- name: clawform-cfn
102
-
103
- on:
104
- pull_request:
105
- push:
106
- branches: [ ${branch} ]
107
-
108
- jobs:
109
- # Every pull request: lint the templates. Offline — no AWS credentials.
110
- # NOTE: cfn-lint is the CI floor. Local \`clawform validate\` additionally runs
111
- # cfn-guard (rules.guard, incl. Rule #4 retention) and cfn-nag — green CI is
112
- # not the same as a green /validate.
113
- validate:
114
- runs-on: ubuntu-latest
115
- steps:
116
- - uses: actions/checkout@v4
117
- - uses: actions/setup-python@v5
118
- with:
119
- python-version: '3.12'
120
- cache: pip
121
- - run: pip install --quiet cfn-lint
122
- - run: cfn-lint ${cfnDir}/*.yaml
123
-
124
- # Protected branch only: bundle the repo and hand it to the pipeline, which
125
- # re-validates, builds a change-set, and waits for a human to approve in the
126
- # CodePipeline console before executing (Rule #5 approval gate, off-TTY).
127
- upload:
128
- if: github.ref == 'refs/heads/${branch}'
129
- needs: validate
130
- runs-on: ubuntu-latest
131
- env:
132
- AWS_ACCESS_KEY_ID: \${{ secrets.AWS_ACCESS_KEY_ID }}
133
- AWS_SECRET_ACCESS_KEY: \${{ secrets.AWS_SECRET_ACCESS_KEY }}
134
- AWS_DEFAULT_REGION: \${{ secrets.AWS_DEFAULT_REGION }}
135
- steps:
136
- - uses: actions/checkout@v4
137
- - name: Bundle and upload source
138
- run: |
139
- # Bundle the template dir plus the optional config/params paths that
140
- # exist. Built explicitly (no stderr suppression, no || fallback) so a
141
- # real zip failure fails the job instead of uploading a partial bundle.
142
- BUNDLE_PATHS="${cfnDir}"
143
- for p in clawform.config.json params; do [ -e "$p" ] && BUNDLE_PATHS="$BUNDLE_PATHS $p"; done
144
- echo "Bundling: $BUNDLE_PATHS"
145
- zip -r source.zip $BUNDLE_PATHS
146
- aws s3 cp source.zip "s3://${bucket}/source.zip"
147
- # The pipeline takes over from here approve the change-set in CodePipeline.
148
- `;
149
- }
150
-
151
- export default async function ciInit({ sourceBucket, cfnDir = 'cfn', branch = 'main', force, projectDir, provider = 'gitlab' } = {}) {
152
- await assertLicensed({ command: 'ci-init', client: resolveLicenseClient() });
153
- const dir = projectDir || process.cwd();
154
-
155
- // Provider decides both the rendered content and where it lands. Default is
156
- // gitlab so the pre-existing call shape keeps working.
157
- const isGithub = provider === 'github';
158
- const content = isGithub
159
- ? renderGithubActions({ cfnDir, branch, sourceBucket })
160
- : renderGitlabCi({ cfnDir, branch, sourceBucket });
161
-
162
- const primary = isGithub
163
- ? join(dir, '.github', 'workflows', 'clawform-cfn.yml')
164
- : join(dir, '.gitlab-ci.yml');
165
- const sample = isGithub
166
- ? join(dir, '.github', 'workflows', 'clawform-cfn.clawform.yml')
167
- : join(dir, '.gitlab-ci.clawform.yml');
168
-
169
- await mkdir(dirname(primary), { recursive: true });
170
- const exists = existsSync(primary);
171
- // Never clobber an existing pipeline config silently write a sample alongside
172
- // it and let the user merge, unless they explicitly asked to overwrite.
173
- const target = exists && !force ? sample : primary;
174
- await writeFile(target, content, 'utf8');
175
-
176
- logger.step(`clawform ci init`);
177
- if (target === primary) {
178
- logger.ok(`Wrote ${target}`);
179
- } else {
180
- logger.warn(`${primary} already exists wrote a sample to ${target} instead. Merge it in (or re-run with --force to overwrite).`);
181
- }
182
-
183
- logger.info('');
184
- logger.info('Next steps:');
185
- logger.info(' 1. Stand up the pipeline stack FIRST (it creates the SourceBucket this CI uploads to):');
186
- logger.info(' clawform deploy templates/cicd-pipeline.yaml <env> \\');
187
- logger.info(' --stack-name <env>-<customer>-<project>-pipeline --project <project> \\');
188
- logger.info(' --params params/<env>-pipeline.json --capabilities CAPABILITY_NAMED_IAM');
189
- logger.info(' The params file must set ALL template parameters: EnvName, CustomerName, ProjectName,');
190
- logger.info(' TargetStackName, TemplatePathInBundle, ApprovalEmail, CfnDeployRoleArn');
191
- if (cfnDir !== 'cfn') {
192
- logger.info(` — and CfnDirInBundle="${cfnDir}" (you used --cfn-dir, so the pipeline's lint stage must match).`);
193
- } else {
194
- logger.info(' (CfnDirInBundle defaults to "cfn" and matches this CI file).');
195
- }
196
- if (!sourceBucket) {
197
- logger.warn(` 2. Replace ${BUCKET_PLACEHOLDER} in the generated file with the stack's SourceBucketName output`);
198
- logger.info(' (or re-run: clawform ci init --source-bucket <name> --force).');
199
- } else {
200
- logger.info(` 2. SourceBucket is set to "${sourceBucket}".`);
201
- }
202
- logger.info(' 3. In GitLab → Settings → CI/CD → Variables, add (protected + masked):');
203
- logger.info(' AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (IAM user scoped to s3:PutObject on the bucket),');
204
- logger.info(' AWS_DEFAULT_REGION.');
205
- logger.info(' 4. Know the pipeline\'s limits: it enforces the Rule #5 SHAPE (changeset → approval → execute)');
206
- logger.info(' but NOT the CLI\'s other gates (account guard, legacy-freeze, export-lock, data-resource');
207
- logger.info(' retype). The approver MUST open the change-set link and check for Replace on data');
208
- logger.info(' resources before approving. Details: templates/cicd-pipeline.yaml header.');
209
- logger.info(' 5. Older account that still has CodeCommit and prefers a Git mirror? Point GitLab repository');
210
- logger.info(' mirroring at a CodeCommit repo and swap the pipeline Source action to CodeCommit — see');
211
- logger.info(' the prose header in templates/cicd-pipeline.yaml.');
212
- }
1
+ // clawform ci init — generate CI config for the Clawform model: your Git host
2
+ // (GitHub or GitLab, SaaS or self-managed) is the source of truth; AWS just runs
3
+ // the pipeline.
4
+ //
5
+ // validate (every MR/PR, offline) → upload (protected branch)
6
+ // - validate: cfn-lint the cfn/ templates on a runner. No AWS creds, so it's
7
+ // safe on any runner and fast on every change. cfn-lint is the CI floor —
8
+ // local `clawform validate` adds cfn-guard + cfn-nag + lint-overrides on top;
9
+ // the generated file says so.
10
+ // - upload: zip the repo and put it to the pipeline's SourceBucket as
11
+ // source.zip, which triggers the CodePipeline stood up by
12
+ // templates/cicd-pipeline.yaml (validate → manual approval → execute).
13
+ //
14
+ // Credentials come one of two ways (--auth): `keys` uses long-lived IAM access
15
+ // keys stored as CI secrets (default); `oidc` is keyless — the protected branch
16
+ // assumes an IAM role via the host's OIDC issuer (github.com / gitlab.com only).
17
+ //
18
+ // The CLI writes the file and prints the next steps; it does NOT touch AWS.
19
+ // The render* functions are pure string builders, unit-tested without I/O.
20
+
21
+ import { writeFile, mkdir } from 'node:fs/promises';
22
+ import { existsSync } from 'node:fs';
23
+ import { join, dirname } from 'node:path';
24
+ import { logger } from '../lib/logger.js';
25
+ import { loadProjectConfig } from '../lib/project-config.js';
26
+ import { assertLicensed } from '../lib/license.js';
27
+ import { resolveClient as resolveLicenseClient } from '../lib/license-client.js';
28
+
29
+ const PROVIDERS = new Set(['gitlab', 'github']);
30
+ const AUTH_MODES = new Set(['keys', 'oidc']);
31
+
32
+ const BUCKET_PLACEHOLDER = '<PIPELINE_SOURCE_BUCKET>';
33
+
34
+ // The bundle-and-upload tail shared by both GitLab auth branches. Built explicitly
35
+ // (no stderr suppression, no || fallback) so a real zip failure fails the job
36
+ // instead of uploading a partial bundle. Emitted identically for keys and oidc so
37
+ // the two modes stay functionally equivalent past the credential step (D1, D4).
38
+ function gitlabBundleSteps({ cfnDir, bucket }) {
39
+ return ` - BUNDLE_PATHS="${cfnDir}"
40
+ - for p in clawform.config.json params; do [ -e "$p" ] && BUNDLE_PATHS="$BUNDLE_PATHS $p"; done
41
+ - echo "Bundling: $BUNDLE_PATHS"
42
+ - zip -r source.zip $BUNDLE_PATHS
43
+ - aws s3 cp source.zip "s3://${bucket}/source.zip"`;
44
+ }
45
+
46
+ export function renderGitlabCi({ cfnDir = 'cfn', branch = 'main', sourceBucket, auth = 'keys', roleArn, region } = {}) {
47
+ const bucket = sourceBucket || BUCKET_PLACEHOLDER;
48
+ const isOidc = auth === 'oidc';
49
+
50
+ // Host-neutral header framing (D1): "GitLab is the source of truth" holds for
51
+ // SaaS and self-managed alike — the old "On-Prem" label hid gitlab.com. The
52
+ // oidc header must NOT carry the stored-key "Required CI/CD variables" block.
53
+ const header = isOidc
54
+ ? `# Generated by \`clawform ci init --auth oidc\`. Clawform CI (keyless / OIDC).
55
+ # Model: GitLab (SaaS or self-managed) is the source of truth; AWS runs the
56
+ # pipeline. The protected branch assumes an IAM role via GitLab OIDC — NO stored
57
+ # AWS keys then uploads a source bundle to S3, which triggers the CodePipeline
58
+ # from templates/cicd-pipeline.yaml (validate -> manual approval -> execute).
59
+ #
60
+ # No AWS access keys are stored. The AWS-side IAM OIDC provider + role are a
61
+ # one-time prerequisite see the \`clawform ci init\` "Next steps" output for the
62
+ # exact trust policy (issuer gitlab.com, sub-scoped to this project + branch,
63
+ # audience sts.amazonaws.com). OIDC targets gitlab.com; on a self-managed
64
+ # instance re-run with --auth keys.
65
+ # NOTE: do NOT enable CI_DEBUG_TRACE on this job — a trace would print the OIDC
66
+ # JWT ($AWS_ID_TOKEN) as it is written to the token file.`
67
+ : `# Generated by \`clawform ci init\`. Clawform CI: your Git host is the source of
68
+ # truth; AWS just runs the pipeline. The protected branch uploads a source bundle
69
+ # to S3, which triggers the CodePipeline from templates/cicd-pipeline.yaml
70
+ # (validate -> manual approval -> execute).
71
+ #
72
+ # Required CI/CD variables (Settings -> CI/CD -> Variables), protected + masked:
73
+ # AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY -> an IAM user scoped to
74
+ # s3:PutObject on the pipeline SourceBucket (or use a runner instance role)
75
+ # AWS_DEFAULT_REGION -> the pipeline's region`;
76
+
77
+ // The upload job: keys reads creds from protected CI variables; oidc mints
78
+ // short-lived creds from ONE assume-role-with-web-identity call (three fields
79
+ // from one session two calls would not pair) with the token passed via a file
80
+ // so it never lands on the process argv (Finding 10). Both keep the full bundle
81
+ // body and the entrypoint override (amazon/aws-cli's ENTRYPOINT is the aws
82
+ // binary, so without it the runner cannot start a shell).
83
+ const uploadJob = isOidc
84
+ ? `deploy:upload:
85
+ stage: deploy
86
+ image:
87
+ name: amazon/aws-cli:latest
88
+ entrypoint: [""]
89
+ id_tokens:
90
+ AWS_ID_TOKEN:
91
+ aud: sts.amazonaws.com
92
+ rules:
93
+ - if: '$CI_COMMIT_BRANCH == "${branch}"'
94
+ before_script:
95
+ - yum install -y -q zip
96
+ script:
97
+ # Keyless auth via the AWS web-identity-token-file credential provider: write
98
+ # the OIDC JWT to a file and point the SDK at it with AWS_ROLE_ARN +
99
+ # AWS_WEB_IDENTITY_TOKEN_FILE. The CLI assumes the role itself on each call —
100
+ # no manual STS call to parse, no credentials in the job shell (so nothing to
101
+ # leak into logs), and the token never appears on a process argv.
102
+ - export AWS_DEFAULT_REGION=${region}
103
+ - export AWS_ROLE_ARN="${roleArn}"
104
+ - export AWS_ROLE_SESSION_NAME="gitlab-clawform-$CI_PIPELINE_ID"
105
+ - printf '%s' "$AWS_ID_TOKEN" > /tmp/awsclaw-oidc-token
106
+ - export AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/awsclaw-oidc-token
107
+ ${gitlabBundleSteps({ cfnDir, bucket })}`
108
+ : `deploy:upload:
109
+ stage: deploy
110
+ image:
111
+ name: amazon/aws-cli:latest
112
+ entrypoint: [""]
113
+ rules:
114
+ - if: '$CI_COMMIT_BRANCH == "${branch}"'
115
+ before_script:
116
+ - yum install -y -q zip
117
+ script:
118
+ # Creds come from the protected CI/CD variables above, injected as env vars.
119
+ ${gitlabBundleSteps({ cfnDir, bucket })}`;
120
+
121
+ return `${header}
122
+
123
+ stages:
124
+ - validate
125
+ - deploy
126
+
127
+ # Every merge request: lint the templates. Offline — no AWS credentials needed.
128
+ # NOTE: cfn-lint is the CI *floor*. Local \`clawform validate\` additionally runs
129
+ # cfn-guard (the shipped rules.guard, incl. the Rule #4 retention checks) and
130
+ # cfn-nag — run it before merging; green CI is not the same as a green /validate.
131
+ validate:cfn:
132
+ stage: validate
133
+ image: python:3.12-slim
134
+ rules:
135
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
136
+ variables:
137
+ PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
138
+ cache:
139
+ key: clawform-pip
140
+ paths:
141
+ - .cache/pip
142
+ script:
143
+ - pip install --quiet cfn-lint
144
+ - cfn-lint ${cfnDir}/*.yaml
145
+
146
+ # Protected branch only: bundle the repo and hand it to the pipeline. CodePipeline
147
+ # re-validates, builds a change-set, and waits for a human to approve in the
148
+ # CodePipeline console before executing (Rule #5 approval gate, off-TTY).
149
+ ${uploadJob}
150
+ # The pipeline takes over from here — approve the change-set in CodePipeline.
151
+ `;
152
+ }
153
+
154
+ // The bundle-and-upload step body shared by both GitHub auth branches (indented
155
+ // for a `run: |` block). Identical across keys and oidc so the modes stay
156
+ // functionally equivalent past the credential step (D1).
157
+ function githubBundleStep({ cfnDir, bucket }) {
158
+ return ` - name: Bundle and upload source
159
+ run: |
160
+ # Bundle the template dir plus the optional config/params paths that
161
+ # exist. Built explicitly (no stderr suppression, no || fallback) so a
162
+ # real zip failure fails the job instead of uploading a partial bundle.
163
+ BUNDLE_PATHS="${cfnDir}"
164
+ for p in clawform.config.json params; do [ -e "$p" ] && BUNDLE_PATHS="$BUNDLE_PATHS $p"; done
165
+ echo "Bundling: $BUNDLE_PATHS"
166
+ zip -r source.zip $BUNDLE_PATHS
167
+ aws s3 cp source.zip "s3://${bucket}/source.zip"`;
168
+ }
169
+
170
+ // Same Git → S3 → CodePipeline model as the GitLab variant, expressed as a
171
+ // GitHub Actions workflow. Pure string builder, unit-tested without I/O.
172
+ export function renderGithubActions({ cfnDir = 'cfn', branch = 'main', sourceBucket, auth = 'keys', roleArn, region } = {}) {
173
+ const bucket = sourceBucket || BUCKET_PLACEHOLDER;
174
+ const isOidc = auth === 'oidc';
175
+
176
+ // Host-neutral header framing (D1). The oidc header must NOT carry the stored-key
177
+ // "Required repository secrets" block.
178
+ const header = isOidc
179
+ ? `# Generated by \`clawform ci init --provider github --auth oidc\`. Clawform CI
180
+ # (keyless / OIDC). Model: GitHub is the source of truth; AWS runs the pipeline.
181
+ # The protected branch assumes an IAM role via GitHub OIDC — NO stored AWS keys —
182
+ # then uploads a source bundle to S3, which triggers the CodePipeline from
183
+ # templates/cicd-pipeline.yaml (validate -> manual approval -> execute).
184
+ #
185
+ # No AWS access keys are stored. The AWS-side IAM OIDC provider + role are a
186
+ # one-time prerequisite — see the \`clawform ci init\` "Next steps" output for the
187
+ # exact trust policy (issuer token.actions.githubusercontent.com, sub-scoped to
188
+ # this repo + branch, audience sts.amazonaws.com).`
189
+ : `# Generated by \`clawform ci init --provider github\`. Clawform CI: your Git host is
190
+ # the source of truth; AWS just runs the pipeline. The protected branch uploads a
191
+ # source bundle to S3, which triggers the CodePipeline from
192
+ # templates/cicd-pipeline.yaml (validate -> manual approval -> execute).
193
+ #
194
+ # Required repository secrets (Settings -> Secrets and variables -> Actions):
195
+ # AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY -> an IAM user scoped to
196
+ # s3:PutObject on the pipeline SourceBucket
197
+ # AWS_DEFAULT_REGION -> the pipeline's region`;
198
+
199
+ // OIDC needs a top-level grant so both jobs' checkout can read the repo; the
200
+ // token-minting permission is scoped to the upload job only (Finding 6).
201
+ const topPermissions = isOidc ? `\npermissions:\n contents: read\n` : '';
202
+
203
+ // The upload job: keys pulls creds from repository secrets; oidc grants
204
+ // id-token:write on THIS job only and lets configure-aws-credentials assume the
205
+ // role (audience defaults to sts.amazonaws.com no static keys anywhere).
206
+ const uploadJob = isOidc
207
+ ? ` upload:
208
+ if: github.ref == 'refs/heads/${branch}'
209
+ needs: validate
210
+ runs-on: ubuntu-latest
211
+ permissions:
212
+ id-token: write
213
+ contents: read
214
+ steps:
215
+ - uses: actions/checkout@v4
216
+ - uses: aws-actions/configure-aws-credentials@v4
217
+ with:
218
+ role-to-assume: ${roleArn}
219
+ aws-region: ${region}
220
+ ${githubBundleStep({ cfnDir, bucket })}`
221
+ : ` upload:
222
+ if: github.ref == 'refs/heads/${branch}'
223
+ needs: validate
224
+ runs-on: ubuntu-latest
225
+ env:
226
+ AWS_ACCESS_KEY_ID: \${{ secrets.AWS_ACCESS_KEY_ID }}
227
+ AWS_SECRET_ACCESS_KEY: \${{ secrets.AWS_SECRET_ACCESS_KEY }}
228
+ AWS_DEFAULT_REGION: \${{ secrets.AWS_DEFAULT_REGION }}
229
+ steps:
230
+ - uses: actions/checkout@v4
231
+ ${githubBundleStep({ cfnDir, bucket })}`;
232
+
233
+ return `${header}
234
+
235
+ name: clawform-cfn
236
+
237
+ on:
238
+ pull_request:
239
+ push:
240
+ branches: [ ${branch} ]
241
+ ${topPermissions}
242
+ jobs:
243
+ # Every pull request: lint the templates. Offline — no AWS credentials.
244
+ # NOTE: cfn-lint is the CI floor. Local \`clawform validate\` additionally runs
245
+ # cfn-guard (rules.guard, incl. Rule #4 retention) and cfn-nag — green CI is
246
+ # not the same as a green /validate.
247
+ validate:
248
+ runs-on: ubuntu-latest
249
+ steps:
250
+ - uses: actions/checkout@v4
251
+ - uses: actions/setup-python@v5
252
+ with:
253
+ python-version: '3.12'
254
+ cache: pip
255
+ - run: pip install --quiet cfn-lint
256
+ - run: cfn-lint ${cfnDir}/*.yaml
257
+
258
+ # Protected branch only: bundle the repo and hand it to the pipeline, which
259
+ # re-validates, builds a change-set, and waits for a human to approve in the
260
+ # CodePipeline console before executing (Rule #5 approval gate, off-TTY).
261
+ ${uploadJob}
262
+ # The pipeline takes over from here — approve the change-set in CodePipeline.
263
+ `;
264
+ }
265
+
266
+ export default async function ciInit({ sourceBucket, cfnDir = 'cfn', branch = 'main', force, projectDir, provider = 'gitlab', auth = 'keys', roleArn, region } = {}) {
267
+ await assertLicensed({ command: 'ci-init', client: resolveLicenseClient() });
268
+ const dir = projectDir || process.cwd();
269
+
270
+ // Validate the enums in ciInit() itself, not only in commander — a direct
271
+ // caller must not be able to silently degrade to gitlab/keys on a typo (the old
272
+ // `provider === 'github'` test did exactly that). Throw BEFORE any mkdir/write
273
+ // so a bad invocation never lands a file; assertLicensed stays the first call.
274
+ if (!PROVIDERS.has(provider)) {
275
+ throw new Error(`Unknown --provider "${provider}". Use one of: gitlab, github.`);
276
+ }
277
+ if (!AUTH_MODES.has(auth)) {
278
+ throw new Error(`Unknown --auth "${auth}". Use one of: keys, oidc.`);
279
+ }
280
+
281
+ // OIDC needs an AWS role to assume and a region for the upload. role-arn is
282
+ // hard-required (the CLI never creates the role); region falls back to the
283
+ // project config so a configured repo need not repeat --region. Neither
284
+ // resolvable → refuse here, before writing, so we never ship a file whose
285
+ // protected-branch job is green on PRs but fails the moment it merges.
286
+ if (auth === 'oidc') {
287
+ if (!roleArn) {
288
+ throw new Error(
289
+ '--auth oidc requires --role-arn <arn> (the IAM role the pipeline assumes via OIDC). ' +
290
+ 'The CLI does not create it — see the "Next steps" trust-policy block.',
291
+ );
292
+ }
293
+ region = region || loadProjectConfig({ projectDir: dir }).region;
294
+ if (!region) {
295
+ throw new Error(
296
+ '--auth oidc needs a region: pass --region <name> or set "region" in clawform.config.json.',
297
+ );
298
+ }
299
+ }
300
+
301
+ // Provider decides both the rendered content and where it lands. Default is
302
+ // gitlab so the pre-existing call shape keeps working.
303
+ const isGithub = provider === 'github';
304
+ const isOidc = auth === 'oidc';
305
+ const content = isGithub
306
+ ? renderGithubActions({ cfnDir, branch, sourceBucket, auth, roleArn, region })
307
+ : renderGitlabCi({ cfnDir, branch, sourceBucket, auth, roleArn, region });
308
+
309
+ const primary = isGithub
310
+ ? join(dir, '.github', 'workflows', 'clawform-cfn.yml')
311
+ : join(dir, '.gitlab-ci.yml');
312
+ const sample = isGithub
313
+ ? join(dir, '.github', 'workflows', 'clawform-cfn.clawform.yml')
314
+ : join(dir, '.gitlab-ci.clawform.yml');
315
+
316
+ await mkdir(dirname(primary), { recursive: true });
317
+ const exists = existsSync(primary);
318
+ // Never clobber an existing pipeline config silently — write a sample alongside
319
+ // it and let the user merge, unless they explicitly asked to overwrite.
320
+ const target = exists && !force ? sample : primary;
321
+ await writeFile(target, content, 'utf8');
322
+
323
+ logger.step(`clawform ci init`);
324
+ if (target === primary) {
325
+ logger.ok(`Wrote ${target}`);
326
+ } else {
327
+ logger.warn(`${primary} already exists — wrote a sample to ${target} instead. Merge it in (or re-run with --force to overwrite).`);
328
+ }
329
+
330
+ logger.info('');
331
+ logger.info('Next steps:');
332
+ logger.info(' 1. Stand up the pipeline stack FIRST (it creates the SourceBucket this CI uploads to):');
333
+ logger.info(' clawform deploy templates/cicd-pipeline.yaml <env> \\');
334
+ logger.info(' --stack-name <env>-<customer>-<project>-pipeline --project <project> \\');
335
+ logger.info(' --params params/<env>-pipeline.json --capabilities CAPABILITY_NAMED_IAM');
336
+ logger.info(' The params file must set ALL template parameters: EnvName, CustomerName, ProjectName,');
337
+ logger.info(' TargetStackName, TemplatePathInBundle, ApprovalEmail, CfnDeployRoleArn');
338
+ if (cfnDir !== 'cfn') {
339
+ logger.info(` — and CfnDirInBundle="${cfnDir}" (you used --cfn-dir, so the pipeline's lint stage must match).`);
340
+ } else {
341
+ logger.info(' (CfnDirInBundle defaults to "cfn" and matches this CI file).');
342
+ }
343
+ if (!sourceBucket) {
344
+ logger.warn(` 2. Replace ${BUCKET_PLACEHOLDER} in the generated file with the stack's SourceBucketName output`);
345
+ logger.info(' (or re-run: clawform ci init --source-bucket <name> --force).');
346
+ } else {
347
+ logger.info(` 2. SourceBucket is set to "${sourceBucket}".`);
348
+ }
349
+ if (isOidc) {
350
+ // Keyless: the AWS-side IAM OIDC provider + sub-scoped role are a one-time
351
+ // prerequisite the CLI cannot create. Emit an exact, copy-paste trust policy.
352
+ const issuer = isGithub ? 'token.actions.githubusercontent.com' : 'gitlab.com';
353
+ const sub = isGithub
354
+ ? `repo:<OWNER>/<REPO>:ref:refs/heads/${branch}`
355
+ : `project_path:<GROUP>/<PROJECT>:ref_type:branch:ref:${branch}`;
356
+ logger.info(' 3. Create the AWS-side OIDC trust ONCE — the CLI does NOT create it:');
357
+ logger.info(` a. IAM → Identity providers → Add provider → OpenID Connect.`);
358
+ logger.info(` Provider URL: https://${issuer} Audience: sts.amazonaws.com`);
359
+ logger.info(` b. Give the role ${roleArn} this trust policy (the sub line is what scopes it):`);
360
+ logger.info(' {');
361
+ logger.info(' "Effect": "Allow",');
362
+ logger.info(` "Principal": { "Federated": "arn:aws:iam::<ACCOUNT_ID>:oidc-provider/${issuer}" },`);
363
+ logger.info(' "Action": "sts:AssumeRoleWithWebIdentity",');
364
+ logger.info(' "Condition": { "StringEquals": {');
365
+ logger.info(` "${issuer}:aud": "sts.amazonaws.com",`);
366
+ logger.info(` "${issuer}:sub": "${sub}"`);
367
+ logger.info(' } }');
368
+ logger.info(' }');
369
+ logger.warn(' WARNING: an aud-only policy (no sub) trusts EVERY repo/project on the issuer — keep the sub line.');
370
+ logger.info(` c. Attach a permissions policy to ${roleArn} allowing s3:PutObject on the SourceBucket.`);
371
+ if (!isGithub) {
372
+ logger.info(' Note: OIDC targets gitlab.com only. On a self-managed GitLab, re-run with --auth keys.');
373
+ }
374
+ logger.info(' → CUTOVER once the keyless run works: delete the old IAM user access keys and remove the');
375
+ logger.info(' AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_DEFAULT_REGION CI variables/secrets.');
376
+ logger.info(' Re-running ci init writes a *.clawform sample unless you pass --force to regenerate in place.');
377
+ } else if (isGithub) {
378
+ logger.info(' 3. In GitHub → Settings → Secrets and variables → Actions, add:');
379
+ logger.info(' AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (IAM user scoped to s3:PutObject on the bucket),');
380
+ logger.info(' AWS_DEFAULT_REGION.');
381
+ } else {
382
+ logger.info(' 3. In GitLab → Settings → CI/CD → Variables, add (protected + masked):');
383
+ logger.info(' AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (IAM user scoped to s3:PutObject on the bucket),');
384
+ logger.info(' AWS_DEFAULT_REGION.');
385
+ }
386
+ logger.info(' 4. Know the pipeline\'s limits: it enforces the Rule #5 SHAPE (changeset → approval → execute)');
387
+ logger.info(' but NOT the CLI\'s other gates (account guard, legacy-freeze, export-lock, data-resource');
388
+ logger.info(' retype). The approver MUST open the change-set link and check for Replace on data');
389
+ logger.info(' resources before approving. Details: templates/cicd-pipeline.yaml header.');
390
+ logger.info(' 5. Older account that still has CodeCommit and prefers a Git mirror? Point GitLab repository');
391
+ logger.info(' mirroring at a CodeCommit repo and swap the pipeline Source action to CodeCommit — see');
392
+ logger.info(' the prose header in templates/cicd-pipeline.yaml.');
393
+ }