@thejoseki/clawform 0.0.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +95 -4
- package/README.md +87 -10
- package/bin/clawform.js +151 -16
- package/package.json +47 -11
- package/src/aws/catalog.js +116 -0
- package/src/aws/changeset.js +231 -0
- package/src/aws/drift.js +59 -0
- package/src/aws/exports.js +213 -0
- package/src/aws/inventory.js +156 -0
- package/src/commands/activate.js +105 -0
- package/src/commands/ci-init.js +212 -0
- package/src/commands/cost-report.js +166 -0
- package/src/commands/deactivate.js +36 -0
- package/src/commands/deploy.js +413 -0
- package/src/commands/diff.js +39 -0
- package/src/commands/drift.js +35 -0
- package/src/commands/init-plugin.js +168 -0
- package/src/commands/init.js +360 -0
- package/src/commands/license-status.js +28 -0
- package/src/commands/rollback.js +126 -0
- package/src/commands/status.js +150 -0
- package/src/commands/sync.js +53 -0
- package/src/commands/validate.js +349 -0
- package/src/hooks/block-dangerous-eval.js +160 -0
- package/src/hooks/block-dangerous.js +62 -0
- package/src/hooks/cfn-lint-after-edit-eval.js +30 -0
- package/src/hooks/cfn-lint-after-edit.js +81 -0
- package/src/hooks/check-catalog-fresh-eval.js +63 -0
- package/src/hooks/check-catalog-fresh.js +33 -0
- package/src/hooks/session-context-eval.js +81 -0
- package/src/hooks/session-context.js +41 -0
- package/src/hooks/subagent-context-eval.js +44 -0
- package/src/hooks/subagent-context.js +48 -0
- package/src/lib/audit-synthesis.js +24 -0
- package/src/lib/aws-client.js +15 -0
- package/src/lib/cfn-yaml.js +92 -0
- package/src/lib/config.js +76 -0
- package/src/lib/constants.js +85 -0
- package/src/lib/defaults.js +16 -0
- package/src/lib/fingerprint.js +65 -0
- package/src/lib/install-workflow.js +28 -0
- package/src/lib/kit-source.js +81 -0
- package/src/lib/license-client.js +84 -0
- package/src/lib/license-config.js +162 -0
- package/src/lib/license-store.js +107 -0
- package/src/lib/license.js +146 -0
- package/src/lib/lint-overrides.js +226 -0
- package/src/lib/logger.js +17 -0
- package/src/lib/payload.js +74 -0
- package/src/lib/project-config.js +145 -0
- package/src/lib/providers/polar.js +215 -0
- package/src/lib/rename-compat.js +50 -0
- package/src/lib/session-state.js +91 -0
- package/src/lib/trial-claim.js +65 -0
- package/src/lib/watermark.js +65 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// clawform activate <key> — claim one activation slot for this machine and
|
|
2
|
+
// cache the verdict locally. All I/O seams are injectable for the tests; the
|
|
3
|
+
// CLI passes nothing and gets the real store, fingerprint and vendor client.
|
|
4
|
+
|
|
5
|
+
import { logger } from '../lib/logger.js';
|
|
6
|
+
import { resolveClient, resolveTrialBenefitIds } from '../lib/license-client.js';
|
|
7
|
+
import { writeLicense } from '../lib/license-store.js';
|
|
8
|
+
import { machineFingerprint } from '../lib/fingerprint.js';
|
|
9
|
+
import { claimTrialDevice } from '../lib/trial-claim.js';
|
|
10
|
+
|
|
11
|
+
const maskKey = (key) => `…${String(key).slice(-4)}`;
|
|
12
|
+
|
|
13
|
+
export default async function activate({ key } = {}, {
|
|
14
|
+
client = resolveClient(),
|
|
15
|
+
write = writeLicense,
|
|
16
|
+
fingerprint = machineFingerprint,
|
|
17
|
+
trialBenefitIds = resolveTrialBenefitIds(),
|
|
18
|
+
claimTrial = claimTrialDevice,
|
|
19
|
+
now = Date.now,
|
|
20
|
+
log = (m) => logger.ok(m),
|
|
21
|
+
} = {}) {
|
|
22
|
+
if (!key || typeof key !== 'string') {
|
|
23
|
+
throw new Error('A license key is required: clawform activate <key> (it is in your purchase email).');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const fp = fingerprint();
|
|
27
|
+
|
|
28
|
+
// Which tier this key belongs to decides whether our own service is consulted
|
|
29
|
+
// at all. A PAID activation must never depend on it: someone who has just
|
|
30
|
+
// paid, blocked by our downtime, is the failure the deploy/rollback invariant
|
|
31
|
+
// exists to prevent — moved to the worst possible moment.
|
|
32
|
+
//
|
|
33
|
+
// Costs one extra validate call. That is a once-per-machine command, and the
|
|
34
|
+
// alternative is threading a callback through the vendor adapter so the
|
|
35
|
+
// branch can happen mid-activation.
|
|
36
|
+
let pre = null;
|
|
37
|
+
try { pre = await client.validate(key); } catch { /* fall through to activate, which reports it */ }
|
|
38
|
+
|
|
39
|
+
if (pre?.status === 'valid' && trialBenefitIds.includes(pre.benefitId)) {
|
|
40
|
+
// Claim BEFORE the slot is taken. A device refused after activation would
|
|
41
|
+
// keep the activation it had already spent.
|
|
42
|
+
//
|
|
43
|
+
// Fail-open on any error: our downtime must cost a lead, never a purchase.
|
|
44
|
+
// The window allows 14 more days of the cheap CLI commands — a far smaller
|
|
45
|
+
// loss than a would-be buyer who hit an error once and never came back.
|
|
46
|
+
let claim = { allowed: true };
|
|
47
|
+
try { claim = await claimTrial({ licenseKey: key, fingerprint: fp }); } catch { /* fail open */ }
|
|
48
|
+
|
|
49
|
+
if (claim && claim.allowed === false) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
'This machine has already used a Clawform trial, so a second one cannot start here. '
|
|
52
|
+
+ 'Buy a seat at https://clawform.thejoseki.com — or, if you believe this is a mistake, '
|
|
53
|
+
+ 'write to support@thejoseki.com and it will be sorted out by a person.',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Hand the walk we already paid for to the adapter, so it does not repeat it.
|
|
59
|
+
// Without this one command made about ten Polar calls and got rate-limited.
|
|
60
|
+
const res = await client.activate(key, fp, pre?.status === 'valid' ? { validated: pre } : {});
|
|
61
|
+
|
|
62
|
+
if (res.status === 'valid') {
|
|
63
|
+
write({
|
|
64
|
+
status: 'active',
|
|
65
|
+
key,
|
|
66
|
+
instanceId: res.instanceId,
|
|
67
|
+
holder: res.holder,
|
|
68
|
+
fingerprint: fp,
|
|
69
|
+
lastValidatedAt: new Date(now()).toISOString(),
|
|
70
|
+
// Only when the vendor gave one. A perpetual seat carrying
|
|
71
|
+
// `expiresAt: undefined` would read as "expired at the epoch" to the
|
|
72
|
+
// gate's date arithmetic — every paying customer refused on day one.
|
|
73
|
+
...(res.expiresAt ? { expiresAt: res.expiresAt } : {}),
|
|
74
|
+
});
|
|
75
|
+
log(`License ${maskKey(key)} activated on this machine${res.holder ? ` for ${res.holder}` : ''}.`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (res.status === 'invalid') {
|
|
80
|
+
if (res.reason === 'limit_reached') {
|
|
81
|
+
throw new Error(
|
|
82
|
+
'This key has used all of its activation slots. Free one with `clawform deactivate` on a ' +
|
|
83
|
+
'machine you no longer use — CI should use the CLAWFORM_LICENSE_KEY env var, which never ' +
|
|
84
|
+
'takes a slot.',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`The license service rejected this key (${res.reason ?? 'invalid'}). Check for typos in the key from your purchase email.`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Throttling reads as unreachable to the generic branch below, and its
|
|
91
|
+
// advice — "try again once you are online" — is false for someone who is
|
|
92
|
+
// online and whose own retries caused this. Say what happened instead.
|
|
93
|
+
if (res.reason === 'rate_limited') {
|
|
94
|
+
throw new Error(
|
|
95
|
+
'The license service is rate-limiting this key. Nothing was activated. ' +
|
|
96
|
+
'Wait about a minute and run the same command again — repeating it immediately ' +
|
|
97
|
+
'extends the limit rather than clearing it.',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Could not reach the license service (${res.reason ?? 'network error'}). ` +
|
|
103
|
+
'Nothing was activated — try again once you are online.',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// clawform cost-report — month-to-date AWS spend, grouped by the Project / Env /
|
|
2
|
+
// CostCenter tag (or by service). The POST-deploy half of cost governance:
|
|
3
|
+
// cost-guard estimates before you commit a resource; this reads Cost Explorer
|
|
4
|
+
// for what actually got billed, so a team without a FinOps function can see the
|
|
5
|
+
// real number broken down the same way their stacks are tagged.
|
|
6
|
+
//
|
|
7
|
+
// Cost Explorer is a GLOBAL service with only a us-east-1 endpoint — the client
|
|
8
|
+
// is pinned there regardless of the project's deploy region, and (unlike the
|
|
9
|
+
// CFN commands) no AWS_REGION/AWS_PROFILE is *required*: the SDK's default
|
|
10
|
+
// credential chain (env keys, SSO, instance role) is enough, which matters for
|
|
11
|
+
// CI/cron use. CE bills ~$0.01 per GetCostAndUsage request; one logical query
|
|
12
|
+
// per invocation (followed across NextPageToken pages — truncating silently
|
|
13
|
+
// would understate the TOTAL, the one number this command exists to get right).
|
|
14
|
+
//
|
|
15
|
+
// Pure helpers (monthRange / groupByDef / parseCostRows / renderRows) are
|
|
16
|
+
// exported and unit-tested without AWS; costReport() is the thin SDK wrapper.
|
|
17
|
+
|
|
18
|
+
import { CostExplorerClient, GetCostAndUsageCommand } from '@aws-sdk/client-cost-explorer';
|
|
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 GROUP_BY = {
|
|
24
|
+
project: { Type: 'TAG', Key: 'Project' },
|
|
25
|
+
env: { Type: 'TAG', Key: 'Env' },
|
|
26
|
+
costcenter: { Type: 'TAG', Key: 'CostCenter' },
|
|
27
|
+
service: { Type: 'DIMENSION', Key: 'SERVICE' },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Resolve the CE GroupBy definition. Defaults to the Project tag — the grouping
|
|
31
|
+
// that matches how every clawform deploy tags its stacks (rules/tagging.md).
|
|
32
|
+
export function groupByDef(groupBy = 'project') {
|
|
33
|
+
const key = String(groupBy).toLowerCase();
|
|
34
|
+
const def = GROUP_BY[key];
|
|
35
|
+
if (!def) {
|
|
36
|
+
throw new Error(`Unknown --group-by "${groupBy}". Expected one of: ${Object.keys(GROUP_BY).join(' | ')}.`);
|
|
37
|
+
}
|
|
38
|
+
return { key, def };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function pad2(n) { return String(n).padStart(2, '0'); }
|
|
42
|
+
function ymd(d) { return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`; }
|
|
43
|
+
|
|
44
|
+
// Compute the CE TimePeriod for a month. `month` is 'YYYY-MM' (defaults to the
|
|
45
|
+
// month containing `today`). CE's End is EXCLUSIVE and may not be in the future,
|
|
46
|
+
// so for the current month we clamp End to tomorrow (month-to-date); for a past
|
|
47
|
+
// month End is the first of the next month (the full month).
|
|
48
|
+
//
|
|
49
|
+
// `today` is injected so the computation is deterministic in tests.
|
|
50
|
+
export function monthRange(month, today) {
|
|
51
|
+
const now = today instanceof Date ? today : new Date();
|
|
52
|
+
let year, mon; // mon is 1-12
|
|
53
|
+
if (month) {
|
|
54
|
+
const m = /^(\d{4})-(\d{2})$/.exec(String(month).trim());
|
|
55
|
+
if (!m) throw new Error(`Invalid --month "${month}". Expected YYYY-MM.`);
|
|
56
|
+
year = Number(m[1]);
|
|
57
|
+
mon = Number(m[2]);
|
|
58
|
+
if (mon < 1 || mon > 12) throw new Error(`Invalid month number in "${month}".`);
|
|
59
|
+
} else {
|
|
60
|
+
year = now.getUTCFullYear();
|
|
61
|
+
mon = now.getUTCMonth() + 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const start = new Date(Date.UTC(year, mon - 1, 1));
|
|
65
|
+
const nextMonthFirst = new Date(Date.UTC(year, mon, 1));
|
|
66
|
+
const tomorrow = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
|
|
67
|
+
|
|
68
|
+
if (start >= tomorrow) {
|
|
69
|
+
throw new Error(`Month ${year}-${pad2(mon)} is in the future — no cost data yet.`);
|
|
70
|
+
}
|
|
71
|
+
const end = nextMonthFirst < tomorrow ? nextMonthFirst : tomorrow;
|
|
72
|
+
// "partial" = the reported month is still accruing charges, i.e. it's the
|
|
73
|
+
// current UTC month — NOT "end was clamped": on the month's last day the
|
|
74
|
+
// clamp is a no-op (tomorrow == nextMonthFirst) but today's spend is still
|
|
75
|
+
// incomplete, and the label must say so. All date math is UTC because Cost
|
|
76
|
+
// Explorer's day boundaries are UTC.
|
|
77
|
+
const partial = year === now.getUTCFullYear() && mon === now.getUTCMonth() + 1;
|
|
78
|
+
return { Start: ymd(start), End: ymd(end), label: `${year}-${pad2(mon)}`, partial };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Flatten a CE GetCostAndUsage response into sorted rows. Sums each group key
|
|
82
|
+
// across every period returned (we use MONTHLY granularity, so usually one).
|
|
83
|
+
// TAG group keys arrive as "Project$value" — strip to the value; an empty value
|
|
84
|
+
// becomes "(untagged)" so unallocated spend is visible, not hidden.
|
|
85
|
+
export function parseCostRows(response) {
|
|
86
|
+
const totals = new Map();
|
|
87
|
+
let unit = 'USD';
|
|
88
|
+
for (const period of response?.ResultsByTime ?? []) {
|
|
89
|
+
for (const g of period.Groups ?? []) {
|
|
90
|
+
const rawKey = g.Keys?.[0] ?? '';
|
|
91
|
+
const value = rawKey.includes('$') ? rawKey.slice(rawKey.indexOf('$') + 1) : rawKey;
|
|
92
|
+
const key = value === '' ? '(untagged)' : value;
|
|
93
|
+
const metric = g.Metrics?.UnblendedCost ?? {};
|
|
94
|
+
const amount = Number(metric.Amount ?? 0);
|
|
95
|
+
if (metric.Unit) unit = metric.Unit;
|
|
96
|
+
totals.set(key, (totals.get(key) ?? 0) + (Number.isFinite(amount) ? amount : 0));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const rows = [...totals.entries()]
|
|
100
|
+
.map(([key, amount]) => ({ key, amount, unit }))
|
|
101
|
+
.sort((a, b) => b.amount - a.amount);
|
|
102
|
+
const total = rows.reduce((s, r) => s + r.amount, 0);
|
|
103
|
+
return { rows, total, unit };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function renderRows({ rows, total, unit }, meta) {
|
|
107
|
+
const lines = [];
|
|
108
|
+
const span = meta.partial ? `${meta.label} (month-to-date)` : meta.label;
|
|
109
|
+
lines.push(`Cost by ${meta.groupBy} — ${span} — ${meta.Start} → ${meta.End} (exclusive)`);
|
|
110
|
+
lines.push('');
|
|
111
|
+
if (rows.length === 0) {
|
|
112
|
+
lines.push(' (no cost data for this period)');
|
|
113
|
+
return lines.join('\n');
|
|
114
|
+
}
|
|
115
|
+
const width = Math.max(8, ...rows.map((r) => r.key.length));
|
|
116
|
+
for (const r of rows) {
|
|
117
|
+
lines.push(` ${r.key.padEnd(width)} ${r.amount.toFixed(2).padStart(10)} ${r.unit}`);
|
|
118
|
+
}
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push(` ${'TOTAL'.padEnd(width)} ${total.toFixed(2).padStart(10)} ${unit}`);
|
|
121
|
+
|
|
122
|
+
const untagged = rows.find((r) => r.key === '(untagged)');
|
|
123
|
+
if (untagged && total > 0 && untagged.amount / total > 0.5 && meta.def.Type === 'TAG') {
|
|
124
|
+
lines.push('');
|
|
125
|
+
lines.push(
|
|
126
|
+
` Note: >50% of spend is (untagged) for tag "${meta.def.Key}". Activate it under ` +
|
|
127
|
+
`Billing → Cost allocation tags so per-${meta.groupBy} breakdown becomes meaningful ` +
|
|
128
|
+
`(activation is not retroactive).`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return lines.join('\n');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export default async function costReport({ month, groupBy, profile, today } = {}) {
|
|
135
|
+
await assertLicensed({ command: 'cost-report', client: resolveLicenseClient() });
|
|
136
|
+
const { key, def } = groupByDef(groupBy);
|
|
137
|
+
const range = monthRange(month, today);
|
|
138
|
+
|
|
139
|
+
// Unlike the CFN commands, no profile/region is REQUIRED here: CE is global
|
|
140
|
+
// (us-east-1) and the SDK's default credential chain covers env keys / SSO /
|
|
141
|
+
// instance roles — the common CI shapes. An explicit --profile still wins.
|
|
142
|
+
if (profile) process.env.AWS_PROFILE = profile;
|
|
143
|
+
|
|
144
|
+
const client = new CostExplorerClient({ region: 'us-east-1' });
|
|
145
|
+
|
|
146
|
+
logger.step(`clawform cost-report — ${range.label} by ${key}`);
|
|
147
|
+
|
|
148
|
+
// GetCostAndUsage paginates via NextPageToken; follow it — a truncated group
|
|
149
|
+
// list silently understates the TOTAL.
|
|
150
|
+
const results = [];
|
|
151
|
+
let nextPageToken;
|
|
152
|
+
do {
|
|
153
|
+
const resp = await client.send(new GetCostAndUsageCommand({
|
|
154
|
+
TimePeriod: { Start: range.Start, End: range.End },
|
|
155
|
+
Granularity: 'MONTHLY',
|
|
156
|
+
Metrics: ['UnblendedCost'],
|
|
157
|
+
GroupBy: [def],
|
|
158
|
+
...(nextPageToken ? { NextPageToken: nextPageToken } : {}),
|
|
159
|
+
}));
|
|
160
|
+
results.push(...(resp.ResultsByTime ?? []));
|
|
161
|
+
nextPageToken = resp.NextPageToken;
|
|
162
|
+
} while (nextPageToken);
|
|
163
|
+
|
|
164
|
+
const parsed = parseCostRows({ ResultsByTime: results });
|
|
165
|
+
console.log(renderRows(parsed, { ...range, groupBy: key, def }));
|
|
166
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// clawform deactivate — release this machine's activation slot and clear the
|
|
2
|
+
// local cache. Local-first: the cache clears even when the vendor can't be
|
|
3
|
+
// reached, because the buyer's next step (activate elsewhere) needs this
|
|
4
|
+
// machine forgotten locally either way — we just warn that the vendor-side
|
|
5
|
+
// slot may still be counted until support or a later retry releases it.
|
|
6
|
+
|
|
7
|
+
import { logger } from '../lib/logger.js';
|
|
8
|
+
import { resolveClient } from '../lib/license-client.js';
|
|
9
|
+
import { readLicense, clearLicense } from '../lib/license-store.js';
|
|
10
|
+
|
|
11
|
+
export default async function deactivate(_opts = {}, {
|
|
12
|
+
client = resolveClient(),
|
|
13
|
+
read = readLicense,
|
|
14
|
+
clear = clearLicense,
|
|
15
|
+
log = (m) => logger.ok(m),
|
|
16
|
+
warn = (m) => logger.warn(m),
|
|
17
|
+
} = {}) {
|
|
18
|
+
const record = read();
|
|
19
|
+
if (record.status !== 'active' || !record.key) {
|
|
20
|
+
log('Nothing to deactivate — no license is active on this machine.');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const res = await client.deactivate(record.key, record.instanceId);
|
|
25
|
+
clear();
|
|
26
|
+
|
|
27
|
+
if (res.status === 'valid') {
|
|
28
|
+
log('License deactivated — the activation slot is free for another machine.');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
warn(
|
|
32
|
+
'The local license was cleared, but the license service could not confirm the release ' +
|
|
33
|
+
`(${res.reason ?? res.status}) — the activation slot may still be counted. ` +
|
|
34
|
+
'Rerun `clawform activate` here later to reclaim it, or contact support.',
|
|
35
|
+
);
|
|
36
|
+
}
|