@thejoseki/clawform 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +141 -120
  2. package/bin/clawform.js +161 -151
  3. package/package.json +65 -65
  4. package/src/aws/catalog.js +116 -116
  5. package/src/aws/changeset.js +231 -231
  6. package/src/aws/drift.js +59 -59
  7. package/src/aws/exports.js +213 -213
  8. package/src/aws/inventory.js +156 -156
  9. package/src/commands/activate.js +137 -105
  10. package/src/commands/ci-init.js +393 -212
  11. package/src/commands/cost-report.js +163 -163
  12. package/src/commands/deactivate.js +9 -2
  13. package/src/commands/deploy.js +413 -413
  14. package/src/commands/diff.js +39 -39
  15. package/src/commands/drift.js +35 -35
  16. package/src/commands/init-plugin.js +6 -6
  17. package/src/commands/init.js +360 -360
  18. package/src/commands/rollback.js +126 -126
  19. package/src/commands/status.js +147 -147
  20. package/src/commands/sync.js +50 -50
  21. package/src/commands/update.js +24 -0
  22. package/src/commands/validate.js +349 -349
  23. package/src/hooks/block-dangerous.js +62 -62
  24. package/src/hooks/cfn-lint-after-edit-eval.js +30 -30
  25. package/src/hooks/cfn-lint-after-edit.js +81 -81
  26. package/src/hooks/check-catalog-fresh-eval.js +63 -63
  27. package/src/hooks/check-catalog-fresh.js +33 -33
  28. package/src/hooks/session-context-eval.js +81 -81
  29. package/src/hooks/session-context.js +41 -41
  30. package/src/hooks/subagent-context-eval.js +44 -44
  31. package/src/hooks/subagent-context.js +48 -48
  32. package/src/lib/audit-synthesis.js +24 -24
  33. package/src/lib/aws-client.js +15 -15
  34. package/src/lib/cfn-yaml.js +92 -92
  35. package/src/lib/config.js +76 -76
  36. package/src/lib/constants.js +85 -85
  37. package/src/lib/defaults.js +16 -16
  38. package/src/lib/install-workflow.js +28 -28
  39. package/src/lib/kit-source.js +43 -5
  40. package/src/lib/license-client.js +84 -84
  41. package/src/lib/license-config.js +162 -162
  42. package/src/lib/license-store.js +43 -8
  43. package/src/lib/license.js +163 -150
  44. package/src/lib/lint-overrides.js +226 -226
  45. package/src/lib/logger.js +16 -16
  46. package/src/lib/project-config.js +145 -145
  47. package/src/lib/providers/polar.js +215 -215
  48. package/src/lib/session-state.js +91 -91
@@ -1,156 +1,156 @@
1
- import { paginateListStacks } from '@aws-sdk/client-cloudformation';
2
-
3
- export const STALE_DAYS = 180;
4
-
5
- const LIVE_STATUSES = [
6
- 'CREATE_COMPLETE',
7
- 'UPDATE_COMPLETE',
8
- 'UPDATE_ROLLBACK_COMPLETE',
9
- 'IMPORT_COMPLETE',
10
- ];
11
-
12
- const hasLegacyPrefix = (name, prefixes) => prefixes.some((p) => name.startsWith(p + '-'));
13
-
14
- const SERVERLESS_DESC_RE = /Serverless application/i;
15
-
16
- export async function fetchStacks(client) {
17
- const stacks = [];
18
- for await (const page of paginateListStacks(
19
- { client },
20
- { StackStatusFilter: LIVE_STATUSES },
21
- )) {
22
- for (const s of page.StackSummaries ?? []) {
23
- stacks.push({
24
- name: s.StackName,
25
- status: s.StackStatus,
26
- templateDescription: s.TemplateDescription,
27
- creationTime: s.CreationTime,
28
- lastUpdatedTime: s.LastUpdatedTime,
29
- });
30
- }
31
- }
32
- return stacks;
33
- }
34
-
35
- export function classifyStacks(stacks, now = new Date(), legacyPrefixes = []) {
36
- // legacyPrefixes comes from the consumer's clawform.config.json. Empty = a fresh
37
- // consumer with no legacy stacks; nothing gets routed to the `legacy` bucket.
38
- const result = { legacy: [], serverless: [], active: [], stale: [] };
39
- const staleMs = STALE_DAYS * 24 * 60 * 60 * 1000;
40
- for (const s of stacks) {
41
- if (hasLegacyPrefix(s.name, legacyPrefixes)) {
42
- result.legacy.push(s);
43
- continue;
44
- }
45
- if (SERVERLESS_DESC_RE.test(s.templateDescription ?? '')) {
46
- result.serverless.push(s);
47
- continue;
48
- }
49
- const lastTouch = s.lastUpdatedTime ?? s.creationTime;
50
- const ageMs = lastTouch ? now.getTime() - new Date(lastTouch).getTime() : 0;
51
- if (ageMs > staleMs) result.stale.push(s);
52
- else result.active.push(s);
53
- }
54
- return result;
55
- }
56
-
57
- function fmtDate(d) {
58
- if (!d) return '';
59
- return new Date(d).toISOString().slice(0, 10);
60
- }
61
-
62
- function escapePipe(s) {
63
- return String(s ?? '').replace(/\|/g, '\\|');
64
- }
65
-
66
- function renderSection(lines, title, items, headers, fmtRow) {
67
- lines.push(`## ${title} (${items.length})`, '');
68
- if (items.length === 0) {
69
- lines.push('_None_', '');
70
- return;
71
- }
72
- lines.push('| ' + headers.join(' | ') + ' |');
73
- lines.push('|' + headers.map(() => '---').join('|') + '|');
74
- const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
75
- for (const it of sorted) {
76
- lines.push('| ' + fmtRow(it).map((v) => `\`${escapePipe(v)}\``).join(' | ') + ' |');
77
- }
78
- lines.push('');
79
- }
80
-
81
- export function renderInventory(classified, meta) {
82
- const lines = [
83
- '# CloudFormation Stacks Inventory',
84
- '',
85
- `> Generated by \`clawform sync\` at ${meta.generatedAt}`,
86
- `> Account: ${meta.account} · Region: ${meta.region}`,
87
- '',
88
- ];
89
-
90
- renderSection(
91
- lines,
92
- '🔒 Legacy (refuse modify — see rules/legacy-freeze.md)',
93
- classified.legacy,
94
- ['Stack', 'Status', 'Last update'],
95
- (s) => [s.name, s.status, fmtDate(s.lastUpdatedTime ?? s.creationTime)],
96
- );
97
-
98
- renderSection(
99
- lines,
100
- '🛠️ Serverless Framework (managed by `sls` tool — modify via serverless.yml)',
101
- classified.serverless,
102
- ['Stack', 'Status'],
103
- (s) => [s.name, s.status],
104
- );
105
-
106
- renderSection(
107
- lines,
108
- '✅ Active (Clawform-modifiable)',
109
- classified.active,
110
- ['Stack', 'Status', 'Last update'],
111
- (s) => [s.name, s.status, fmtDate(s.lastUpdatedTime ?? s.creationTime)],
112
- );
113
-
114
- renderSection(
115
- lines,
116
- `💤 Stale (≥${STALE_DAYS} days no update — review/decommission)`,
117
- classified.stale,
118
- ['Stack', 'Last update'],
119
- (s) => [s.name, fmtDate(s.lastUpdatedTime ?? s.creationTime)],
120
- );
121
-
122
- return lines.join('\n');
123
- }
124
-
125
- const SECTION_KEY_BY_TITLE_FRAGMENT = [
126
- ['Legacy', 'legacy'],
127
- ['Serverless', 'serverless'],
128
- ['Active', 'active'],
129
- ['Stale', 'stale'],
130
- ];
131
-
132
- function sectionKeyFromTitle(title) {
133
- const hit = SECTION_KEY_BY_TITLE_FRAGMENT.find(([fragment]) => title.includes(fragment));
134
- return hit ? hit[1] : null;
135
- }
136
-
137
- // Read-back counterpart to renderInventory — recovers the stack list from a
138
- // committed .clawform/catalog/stacks-inventory.md without a live AWS call. Used by the
139
- // project-audit workflow (Phase 3), which runs with no network access.
140
- export function parseInventory(markdown) {
141
- const result = { legacy: [], serverless: [], active: [], stale: [] };
142
- let currentKey = null;
143
- for (const line of markdown.split(/\r?\n/)) {
144
- const heading = line.match(/^## (.+?) \(\d+\)$/);
145
- if (heading) {
146
- currentKey = sectionKeyFromTitle(heading[1]);
147
- continue;
148
- }
149
- if (!currentKey || !line.startsWith('| ')) continue;
150
- const cells = line.split('|').map((c) => c.trim()).filter(Boolean);
151
- if (cells.length === 0 || cells[0] === 'Stack') continue;
152
- const name = cells[0].replace(/^`|`$/g, '');
153
- result[currentKey].push(name);
154
- }
155
- return result;
156
- }
1
+ import { paginateListStacks } from '@aws-sdk/client-cloudformation';
2
+
3
+ export const STALE_DAYS = 180;
4
+
5
+ const LIVE_STATUSES = [
6
+ 'CREATE_COMPLETE',
7
+ 'UPDATE_COMPLETE',
8
+ 'UPDATE_ROLLBACK_COMPLETE',
9
+ 'IMPORT_COMPLETE',
10
+ ];
11
+
12
+ const hasLegacyPrefix = (name, prefixes) => prefixes.some((p) => name.startsWith(p + '-'));
13
+
14
+ const SERVERLESS_DESC_RE = /Serverless application/i;
15
+
16
+ export async function fetchStacks(client) {
17
+ const stacks = [];
18
+ for await (const page of paginateListStacks(
19
+ { client },
20
+ { StackStatusFilter: LIVE_STATUSES },
21
+ )) {
22
+ for (const s of page.StackSummaries ?? []) {
23
+ stacks.push({
24
+ name: s.StackName,
25
+ status: s.StackStatus,
26
+ templateDescription: s.TemplateDescription,
27
+ creationTime: s.CreationTime,
28
+ lastUpdatedTime: s.LastUpdatedTime,
29
+ });
30
+ }
31
+ }
32
+ return stacks;
33
+ }
34
+
35
+ export function classifyStacks(stacks, now = new Date(), legacyPrefixes = []) {
36
+ // legacyPrefixes comes from the consumer's clawform.config.json. Empty = a fresh
37
+ // consumer with no legacy stacks; nothing gets routed to the `legacy` bucket.
38
+ const result = { legacy: [], serverless: [], active: [], stale: [] };
39
+ const staleMs = STALE_DAYS * 24 * 60 * 60 * 1000;
40
+ for (const s of stacks) {
41
+ if (hasLegacyPrefix(s.name, legacyPrefixes)) {
42
+ result.legacy.push(s);
43
+ continue;
44
+ }
45
+ if (SERVERLESS_DESC_RE.test(s.templateDescription ?? '')) {
46
+ result.serverless.push(s);
47
+ continue;
48
+ }
49
+ const lastTouch = s.lastUpdatedTime ?? s.creationTime;
50
+ const ageMs = lastTouch ? now.getTime() - new Date(lastTouch).getTime() : 0;
51
+ if (ageMs > staleMs) result.stale.push(s);
52
+ else result.active.push(s);
53
+ }
54
+ return result;
55
+ }
56
+
57
+ function fmtDate(d) {
58
+ if (!d) return '';
59
+ return new Date(d).toISOString().slice(0, 10);
60
+ }
61
+
62
+ function escapePipe(s) {
63
+ return String(s ?? '').replace(/\|/g, '\\|');
64
+ }
65
+
66
+ function renderSection(lines, title, items, headers, fmtRow) {
67
+ lines.push(`## ${title} (${items.length})`, '');
68
+ if (items.length === 0) {
69
+ lines.push('_None_', '');
70
+ return;
71
+ }
72
+ lines.push('| ' + headers.join(' | ') + ' |');
73
+ lines.push('|' + headers.map(() => '---').join('|') + '|');
74
+ const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
75
+ for (const it of sorted) {
76
+ lines.push('| ' + fmtRow(it).map((v) => `\`${escapePipe(v)}\``).join(' | ') + ' |');
77
+ }
78
+ lines.push('');
79
+ }
80
+
81
+ export function renderInventory(classified, meta) {
82
+ const lines = [
83
+ '# CloudFormation Stacks Inventory',
84
+ '',
85
+ `> Generated by \`clawform sync\` at ${meta.generatedAt}`,
86
+ `> Account: ${meta.account} · Region: ${meta.region}`,
87
+ '',
88
+ ];
89
+
90
+ renderSection(
91
+ lines,
92
+ '🔒 Legacy (refuse modify — see rules/legacy-freeze.md)',
93
+ classified.legacy,
94
+ ['Stack', 'Status', 'Last update'],
95
+ (s) => [s.name, s.status, fmtDate(s.lastUpdatedTime ?? s.creationTime)],
96
+ );
97
+
98
+ renderSection(
99
+ lines,
100
+ '🛠️ Serverless Framework (managed by `sls` tool — modify via serverless.yml)',
101
+ classified.serverless,
102
+ ['Stack', 'Status'],
103
+ (s) => [s.name, s.status],
104
+ );
105
+
106
+ renderSection(
107
+ lines,
108
+ '✅ Active (Clawform-modifiable)',
109
+ classified.active,
110
+ ['Stack', 'Status', 'Last update'],
111
+ (s) => [s.name, s.status, fmtDate(s.lastUpdatedTime ?? s.creationTime)],
112
+ );
113
+
114
+ renderSection(
115
+ lines,
116
+ `💤 Stale (≥${STALE_DAYS} days no update — review/decommission)`,
117
+ classified.stale,
118
+ ['Stack', 'Last update'],
119
+ (s) => [s.name, fmtDate(s.lastUpdatedTime ?? s.creationTime)],
120
+ );
121
+
122
+ return lines.join('\n');
123
+ }
124
+
125
+ const SECTION_KEY_BY_TITLE_FRAGMENT = [
126
+ ['Legacy', 'legacy'],
127
+ ['Serverless', 'serverless'],
128
+ ['Active', 'active'],
129
+ ['Stale', 'stale'],
130
+ ];
131
+
132
+ function sectionKeyFromTitle(title) {
133
+ const hit = SECTION_KEY_BY_TITLE_FRAGMENT.find(([fragment]) => title.includes(fragment));
134
+ return hit ? hit[1] : null;
135
+ }
136
+
137
+ // Read-back counterpart to renderInventory — recovers the stack list from a
138
+ // committed .clawform/catalog/stacks-inventory.md without a live AWS call. Used by the
139
+ // project-audit workflow (Phase 3), which runs with no network access.
140
+ export function parseInventory(markdown) {
141
+ const result = { legacy: [], serverless: [], active: [], stale: [] };
142
+ let currentKey = null;
143
+ for (const line of markdown.split(/\r?\n/)) {
144
+ const heading = line.match(/^## (.+?) \(\d+\)$/);
145
+ if (heading) {
146
+ currentKey = sectionKeyFromTitle(heading[1]);
147
+ continue;
148
+ }
149
+ if (!currentKey || !line.startsWith('| ')) continue;
150
+ const cells = line.split('|').map((c) => c.trim()).filter(Boolean);
151
+ if (cells.length === 0 || cells[0] === 'Stack') continue;
152
+ const name = cells[0].replace(/^`|`$/g, '');
153
+ result[currentKey].push(name);
154
+ }
155
+ return result;
156
+ }
@@ -1,105 +1,137 @@
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/?utm_source=cli — 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
- }
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, readLicenseRaw } 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
+ read = readLicenseRaw,
16
+ write = writeLicense,
17
+ fingerprint = machineFingerprint,
18
+ trialBenefitIds = resolveTrialBenefitIds(),
19
+ claimTrial = claimTrialDevice,
20
+ now = Date.now,
21
+ log = (m) => logger.ok(m),
22
+ } = {}) {
23
+ if (!key || typeof key !== 'string') {
24
+ throw new Error('A license key is required: clawform activate <key> (it is in your purchase email).');
25
+ }
26
+
27
+ const fp = fingerprint();
28
+
29
+ // Idempotent re-activation. Polar offers no key-only "list activations", so a
30
+ // re-run would otherwise POST a brand-new activation every time and burn a
31
+ // slot the leak that locks a user out after `limit_activations` upgrades.
32
+ // Instead, if this machine's local record already points at an activation for
33
+ // THIS key, probe it with `validate(key, instanceId)`; if the vendor still
34
+ // honours it, re-adopt it (and re-sign the record, healing a stale one and
35
+ // stamping the CURRENT fingerprint) without minting a second slot. Only when
36
+ // the stored slot is gone/invalid do we fall through to a fresh activation.
37
+ //
38
+ // The slot's validity is judged SERVER-SIDE, not by matching the stored
39
+ // fingerprint: on Windows/macOS the fingerprint has no stable machine-id and
40
+ // shifts when the hostname changes (rename, MDM, DHCP), so a fingerprint guard
41
+ // would skip the reuse and re-create exactly the leak this fixes. The record
42
+ // is per-machine local state (~/.clawform), so a key match is the identity
43
+ // that matters. Deliberately BEFORE the trial-claim block: an already-activated
44
+ // machine must not re-claim a trial.
45
+ const prior = read();
46
+ if (prior.status === 'active' && prior.key === key && prior.instanceId) {
47
+ let check = null;
48
+ try { check = await client.validate(key, prior.instanceId); } catch { /* fall through to a fresh activate */ }
49
+ if (check?.status === 'valid') {
50
+ write({
51
+ status: 'active', key, instanceId: prior.instanceId, holder: prior.holder,
52
+ fingerprint: fp, lastValidatedAt: new Date(now()).toISOString(),
53
+ ...(check.expiresAt ? { expiresAt: check.expiresAt } : (prior.expiresAt ? { expiresAt: prior.expiresAt } : {})),
54
+ });
55
+ log(`License ${maskKey(key)} — re-adopted this machine's existing activation. No extra seat used.`);
56
+ return;
57
+ }
58
+ }
59
+
60
+ // Which tier this key belongs to decides whether our own service is consulted
61
+ // at all. A PAID activation must never depend on it: someone who has just
62
+ // paid, blocked by our downtime, is the failure the deploy/rollback invariant
63
+ // exists to prevent — moved to the worst possible moment.
64
+ //
65
+ // Costs one extra validate call. That is a once-per-machine command, and the
66
+ // alternative is threading a callback through the vendor adapter so the
67
+ // branch can happen mid-activation.
68
+ let pre = null;
69
+ try { pre = await client.validate(key); } catch { /* fall through to activate, which reports it */ }
70
+
71
+ if (pre?.status === 'valid' && trialBenefitIds.includes(pre.benefitId)) {
72
+ // Claim BEFORE the slot is taken. A device refused after activation would
73
+ // keep the activation it had already spent.
74
+ //
75
+ // Fail-open on any error: our downtime must cost a lead, never a purchase.
76
+ // The window allows 14 more days of the cheap CLI commands — a far smaller
77
+ // loss than a would-be buyer who hit an error once and never came back.
78
+ let claim = { allowed: true };
79
+ try { claim = await claimTrial({ licenseKey: key, fingerprint: fp }); } catch { /* fail open */ }
80
+
81
+ if (claim && claim.allowed === false) {
82
+ throw new Error(
83
+ 'This machine has already used a Clawform trial, so a second one cannot start here. '
84
+ + 'Buy a seat at https://clawform.thejoseki.com/?utm_source=cli — or, if you believe this is a mistake, '
85
+ + 'write to support@thejoseki.com and it will be sorted out by a person.',
86
+ );
87
+ }
88
+ }
89
+
90
+ // Hand the walk we already paid for to the adapter, so it does not repeat it.
91
+ // Without this one command made about ten Polar calls and got rate-limited.
92
+ const res = await client.activate(key, fp, pre?.status === 'valid' ? { validated: pre } : {});
93
+
94
+ if (res.status === 'valid') {
95
+ write({
96
+ status: 'active',
97
+ key,
98
+ instanceId: res.instanceId,
99
+ holder: res.holder,
100
+ fingerprint: fp,
101
+ lastValidatedAt: new Date(now()).toISOString(),
102
+ // Only when the vendor gave one. A perpetual seat carrying
103
+ // `expiresAt: undefined` would read as "expired at the epoch" to the
104
+ // gate's date arithmetic — every paying customer refused on day one.
105
+ ...(res.expiresAt ? { expiresAt: res.expiresAt } : {}),
106
+ });
107
+ log(`License ${maskKey(key)} activated on this machine${res.holder ? ` for ${res.holder}` : ''}.`);
108
+ return;
109
+ }
110
+
111
+ if (res.status === 'invalid') {
112
+ if (res.reason === 'limit_reached') {
113
+ throw new Error(
114
+ 'This key has used all of its activation slots. Free one with `clawform deactivate` on a ' +
115
+ 'machine you no longer use — CI should use the CLAWFORM_LICENSE_KEY env var, which never ' +
116
+ 'takes a slot.',
117
+ );
118
+ }
119
+ throw new Error(`The license service rejected this key (${res.reason ?? 'invalid'}). Check for typos in the key from your purchase email.`);
120
+ }
121
+
122
+ // Throttling reads as unreachable to the generic branch below, and its
123
+ // advice — "try again once you are online" — is false for someone who is
124
+ // online and whose own retries caused this. Say what happened instead.
125
+ if (res.reason === 'rate_limited') {
126
+ throw new Error(
127
+ 'The license service is rate-limiting this key. Nothing was activated. ' +
128
+ 'Wait about a minute and run the same command again — repeating it immediately ' +
129
+ 'extends the limit rather than clearing it.',
130
+ );
131
+ }
132
+
133
+ throw new Error(
134
+ `Could not reach the license service (${res.reason ?? 'network error'}). ` +
135
+ 'Nothing was activated — try again once you are online.',
136
+ );
137
+ }