@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.
Files changed (55) hide show
  1. package/LICENSE +95 -4
  2. package/README.md +87 -10
  3. package/bin/clawform.js +151 -16
  4. package/package.json +47 -11
  5. package/src/aws/catalog.js +116 -0
  6. package/src/aws/changeset.js +231 -0
  7. package/src/aws/drift.js +59 -0
  8. package/src/aws/exports.js +213 -0
  9. package/src/aws/inventory.js +156 -0
  10. package/src/commands/activate.js +105 -0
  11. package/src/commands/ci-init.js +212 -0
  12. package/src/commands/cost-report.js +166 -0
  13. package/src/commands/deactivate.js +36 -0
  14. package/src/commands/deploy.js +413 -0
  15. package/src/commands/diff.js +39 -0
  16. package/src/commands/drift.js +35 -0
  17. package/src/commands/init-plugin.js +168 -0
  18. package/src/commands/init.js +360 -0
  19. package/src/commands/license-status.js +28 -0
  20. package/src/commands/rollback.js +126 -0
  21. package/src/commands/status.js +150 -0
  22. package/src/commands/sync.js +53 -0
  23. package/src/commands/validate.js +349 -0
  24. package/src/hooks/block-dangerous-eval.js +160 -0
  25. package/src/hooks/block-dangerous.js +62 -0
  26. package/src/hooks/cfn-lint-after-edit-eval.js +30 -0
  27. package/src/hooks/cfn-lint-after-edit.js +81 -0
  28. package/src/hooks/check-catalog-fresh-eval.js +63 -0
  29. package/src/hooks/check-catalog-fresh.js +33 -0
  30. package/src/hooks/session-context-eval.js +81 -0
  31. package/src/hooks/session-context.js +41 -0
  32. package/src/hooks/subagent-context-eval.js +44 -0
  33. package/src/hooks/subagent-context.js +48 -0
  34. package/src/lib/audit-synthesis.js +24 -0
  35. package/src/lib/aws-client.js +15 -0
  36. package/src/lib/cfn-yaml.js +92 -0
  37. package/src/lib/config.js +76 -0
  38. package/src/lib/constants.js +85 -0
  39. package/src/lib/defaults.js +16 -0
  40. package/src/lib/fingerprint.js +65 -0
  41. package/src/lib/install-workflow.js +28 -0
  42. package/src/lib/kit-source.js +81 -0
  43. package/src/lib/license-client.js +84 -0
  44. package/src/lib/license-config.js +162 -0
  45. package/src/lib/license-store.js +107 -0
  46. package/src/lib/license.js +146 -0
  47. package/src/lib/lint-overrides.js +226 -0
  48. package/src/lib/logger.js +17 -0
  49. package/src/lib/payload.js +74 -0
  50. package/src/lib/project-config.js +145 -0
  51. package/src/lib/providers/polar.js +215 -0
  52. package/src/lib/rename-compat.js +50 -0
  53. package/src/lib/session-state.js +91 -0
  54. package/src/lib/trial-claim.js +65 -0
  55. 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
+ }