@thejoseki/clawform 2.3.1 → 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.
- package/README.md +141 -120
- package/bin/clawform.js +161 -151
- package/package.json +65 -65
- package/src/aws/catalog.js +116 -116
- package/src/aws/changeset.js +231 -231
- package/src/aws/drift.js +59 -59
- package/src/aws/exports.js +213 -213
- package/src/aws/inventory.js +156 -156
- package/src/commands/activate.js +137 -105
- package/src/commands/ci-init.js +393 -212
- package/src/commands/cost-report.js +163 -163
- package/src/commands/deactivate.js +9 -2
- package/src/commands/deploy.js +413 -413
- package/src/commands/diff.js +39 -39
- package/src/commands/drift.js +35 -35
- package/src/commands/init-plugin.js +6 -6
- package/src/commands/init.js +360 -360
- package/src/commands/rollback.js +126 -126
- package/src/commands/status.js +147 -147
- package/src/commands/sync.js +50 -50
- package/src/commands/update.js +24 -0
- package/src/commands/validate.js +349 -349
- package/src/hooks/block-dangerous.js +62 -62
- package/src/hooks/cfn-lint-after-edit-eval.js +30 -30
- package/src/hooks/cfn-lint-after-edit.js +81 -81
- package/src/hooks/check-catalog-fresh-eval.js +63 -63
- package/src/hooks/check-catalog-fresh.js +33 -33
- package/src/hooks/session-context-eval.js +81 -81
- package/src/hooks/session-context.js +41 -41
- package/src/hooks/subagent-context-eval.js +44 -44
- package/src/hooks/subagent-context.js +48 -48
- package/src/lib/audit-synthesis.js +24 -24
- package/src/lib/aws-client.js +15 -15
- package/src/lib/cfn-yaml.js +92 -92
- package/src/lib/config.js +76 -76
- package/src/lib/constants.js +85 -85
- package/src/lib/defaults.js +16 -16
- package/src/lib/install-workflow.js +28 -28
- package/src/lib/kit-source.js +43 -5
- package/src/lib/license-client.js +84 -84
- package/src/lib/license-config.js +162 -162
- package/src/lib/license-store.js +43 -8
- package/src/lib/license.js +163 -150
- package/src/lib/lint-overrides.js +226 -226
- package/src/lib/logger.js +16 -16
- package/src/lib/project-config.js +145 -145
- package/src/lib/providers/polar.js +215 -215
- package/src/lib/session-state.js +91 -91
package/src/aws/inventory.js
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/activate.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
'
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
'machine
|
|
84
|
-
'
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
+
}
|