@thejoseki/clawform 2.3.0 → 2.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +34 -2
- 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 +15 -2
- 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
|
@@ -1,215 +1,215 @@
|
|
|
1
|
-
// Polar adapter for the vendor-agnostic license client. Maps the Polar
|
|
2
|
-
// customer-portal license-key API onto {status: valid|invalid|error}.
|
|
3
|
-
//
|
|
4
|
-
// The customer-portal endpoints authenticate with the license key + the
|
|
5
|
-
// organization id alone — there is NO server secret to embed, which is why the
|
|
6
|
-
// adapter can ship in a public tarball. Endpoints and error mapping verified
|
|
7
|
-
// against polarsource/polar (2026-07):
|
|
8
|
-
// activate/validate/deactivate under /v1/customer-portal/license-keys/
|
|
9
|
-
// NotPermitted → 403 (limit reached / expired), ResourceNotFound → 404.
|
|
10
|
-
|
|
11
|
-
const PATH = '/v1/customer-portal/license-keys';
|
|
12
|
-
|
|
13
|
-
// A 403 body distinguishes "no slots left" from "expired" by its message; both
|
|
14
|
-
// are terminal (status:'invalid') but the reason drives a different hint.
|
|
15
|
-
function classify403(body) {
|
|
16
|
-
const detail = String(body?.detail ?? '').toLowerCase();
|
|
17
|
-
if (detail.includes('expired')) return 'expired';
|
|
18
|
-
if (detail.includes('limit')) return 'limit_reached';
|
|
19
|
-
return 'not_permitted';
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Polar answers 404 both for a key that does not exist and for a key that
|
|
23
|
-
// belongs to a different product ("License key does not match given benefit.",
|
|
24
|
-
// observed live). Collapsing them tells a buyer with a perfectly valid key to
|
|
25
|
-
// go hunting for typos.
|
|
26
|
-
function classify404(body) {
|
|
27
|
-
const detail = String(body?.detail ?? '').toLowerCase();
|
|
28
|
-
if (detail.includes('benefit')) return 'wrong_product';
|
|
29
|
-
return 'not_found';
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function createPolarClient({
|
|
33
|
-
fetch: fetchImpl = fetch,
|
|
34
|
-
baseUrl,
|
|
35
|
-
organizationId,
|
|
36
|
-
benefitIds,
|
|
37
|
-
...rest
|
|
38
|
-
} = {}) {
|
|
39
|
-
// `benefitId` (singular) was this option's name until tiers arrived. Left
|
|
40
|
-
// unhandled it destructures away, leaves the list empty, and yields a client
|
|
41
|
-
// that enforces nothing — a key from any product in the organization would
|
|
42
|
-
// then activate. Refuse loudly instead: a crash in the licence path is
|
|
43
|
-
// fail-closed, and silence here is the failure mode with no symptom.
|
|
44
|
-
if ('benefitId' in rest) {
|
|
45
|
-
throw new Error('createPolarClient: `benefitId` was superseded by `benefitIds` (a list, one entry per paid tier).');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// One benefit per paid tier: Polar carries limit_activations on the benefit,
|
|
49
|
-
// so a 3-device seat and a 10-device team seat cannot share one. A key is
|
|
50
|
-
// accepted when it matches ANY of them.
|
|
51
|
-
//
|
|
52
|
-
// A bare string is wrapped rather than ignored. Ignoring it would turn a
|
|
53
|
-
// config mistake into a client that enforces nothing — silently permissive is
|
|
54
|
-
// the one failure this whole check exists to prevent.
|
|
55
|
-
const ids = benefitIds == null ? [] : (Array.isArray(benefitIds) ? benefitIds : [benefitIds]);
|
|
56
|
-
|
|
57
|
-
// Which product this key was bought for. Polar nests it differently per
|
|
58
|
-
// endpoint: activate returns an activation with the licence inside, validate
|
|
59
|
-
// returns the licence itself. Reading both here keeps the asymmetry in one
|
|
60
|
-
// place instead of in each caller.
|
|
61
|
-
const wrongProduct = (body) => {
|
|
62
|
-
if (!ids.length) return false; // unset: constructor used in isolation
|
|
63
|
-
const found = body?.benefit_id ?? body?.license_key?.benefit_id;
|
|
64
|
-
return Boolean(found) && !ids.includes(found);
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
async function post(endpoint, body) {
|
|
68
|
-
// Any thrown fetch (DNS, connection, timeout), a 5xx, or a 429 is an
|
|
69
|
-
// OUTAGE or a throttle, not a rejection — it maps to 'error' so the
|
|
70
|
-
// caller's offline grace applies. Only a deliberate 403/404 from Polar is
|
|
71
|
-
// 'invalid'.
|
|
72
|
-
let resp;
|
|
73
|
-
try {
|
|
74
|
-
resp = await fetchImpl(`${baseUrl}${PATH}/${endpoint}`, {
|
|
75
|
-
method: 'POST',
|
|
76
|
-
headers: { 'content-type': 'application/json' },
|
|
77
|
-
body: JSON.stringify({ organization_id: organizationId, ...body }),
|
|
78
|
-
});
|
|
79
|
-
} catch (err) {
|
|
80
|
-
return { kind: 'error', reason: err?.message ?? 'network error' };
|
|
81
|
-
}
|
|
82
|
-
if (resp.status >= 500) return { kind: 'error', reason: `HTTP ${resp.status}` };
|
|
83
|
-
// Throttling, observed against the live API when calls come in a burst.
|
|
84
|
-
// Named rather than passed through as a status code: the caller's generic
|
|
85
|
-
// error text tells the user to retry "once you are online", which is
|
|
86
|
-
// false here and unhelpful to someone whose retries caused it.
|
|
87
|
-
if (resp.status === 429) return { kind: 'error', reason: 'rate_limited' };
|
|
88
|
-
let json = {};
|
|
89
|
-
try { json = await resp.json(); } catch { /* empty body (e.g. 204) is fine */ }
|
|
90
|
-
return { kind: 'http', status: resp.status, ok: resp.ok, body: json };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Asking the vendor to enforce the product beats comparing its answer.
|
|
94
|
-
// `benefit_id` is a documented request field and Polar rejects a mismatch
|
|
95
|
-
// with 404 "License key does not match given benefit." — verified live.
|
|
96
|
-
// Sent only when configured, so the constructor stays usable in isolation.
|
|
97
|
-
async function validateOnce(key, activationId, benefitId) {
|
|
98
|
-
const body = activationId ? { key, activation_id: activationId } : { key };
|
|
99
|
-
return post('validate', benefitId ? { ...body, benefit_id: benefitId } : body);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// A request carries one benefit, so several tiers means several attempts.
|
|
103
|
-
//
|
|
104
|
-
// Two stopping rules, both about not lying to the caller:
|
|
105
|
-
//
|
|
106
|
-
// - Anything that is not a flat rejection ends the walk immediately. An
|
|
107
|
-
// unreachable vendor must stay 'error', because that is what the 14-day
|
|
108
|
-
// offline grace keys off; continuing and reporting whatever the last tier
|
|
109
|
-
// said would lock a paying customer out during someone else's outage.
|
|
110
|
-
// - A rejection that describes the KEY — it does not exist, it expired, it
|
|
111
|
-
// is out of slots — is authoritative whatever tier answered, so it wins
|
|
112
|
-
// over a mere "not this benefit". Only 'wrong_product' is worth retrying,
|
|
113
|
-
// because only it is a statement about the benefit rather than the key.
|
|
114
|
-
// A successful walk also reports WHICH benefit answered. That is not
|
|
115
|
-
// bookkeeping: it is how a caller tells a trial key from a paid one, and
|
|
116
|
-
// therefore whether the vendor's own delivery service may be consulted at
|
|
117
|
-
// all. Deriving it a second time at the call site would mean a second copy of
|
|
118
|
-
// the tier list, which is the drift this whole module exists to prevent.
|
|
119
|
-
async function validateAcrossBenefits(key, activationId) {
|
|
120
|
-
if (!ids.length) return fromValidate(await validateOnce(key, activationId, null));
|
|
121
|
-
|
|
122
|
-
let last;
|
|
123
|
-
for (const benefitId of ids) {
|
|
124
|
-
const res = fromValidate(await validateOnce(key, activationId, benefitId));
|
|
125
|
-
if (res.status === 'valid') return { ...res, benefitId };
|
|
126
|
-
if (res.status !== 'invalid') return res;
|
|
127
|
-
if (res.reason !== 'wrong_product') return res;
|
|
128
|
-
last = res;
|
|
129
|
-
}
|
|
130
|
-
return last;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Observed live: Polar puts `expires_at` on the 200 for a key that is still
|
|
134
|
-
// valid, so a trial's end date is known at activation rather than discovered
|
|
135
|
-
// when it lapses. Omitted entirely when the vendor gives none — a perpetual
|
|
136
|
-
// seat with `expiresAt: undefined` would read as "expired at the epoch" to
|
|
137
|
-
// anything doing date arithmetic.
|
|
138
|
-
const expiryOf = (body) => {
|
|
139
|
-
const at = body?.expires_at ?? body?.license_key?.expires_at;
|
|
140
|
-
return at ? { expiresAt: at } : {};
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
function fromValidate(r) {
|
|
144
|
-
if (r.kind === 'error') return { status: 'error', reason: r.reason };
|
|
145
|
-
if (r.ok) {
|
|
146
|
-
// Secondary, and fail-open by design: the server is authoritative, and
|
|
147
|
-
// a response that simply omits the field must not lock a buyer out.
|
|
148
|
-
if (wrongProduct(r.body)) return { status: 'invalid', reason: 'wrong_product' };
|
|
149
|
-
return { status: 'valid', ...expiryOf(r.body) };
|
|
150
|
-
}
|
|
151
|
-
if (r.status === 403) return { status: 'invalid', reason: classify403(r.body) };
|
|
152
|
-
if (r.status === 404) return { status: 'invalid', reason: classify404(r.body) };
|
|
153
|
-
return { status: 'error', reason: `HTTP ${r.status}` };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return {
|
|
157
|
-
configured: true,
|
|
158
|
-
|
|
159
|
-
async activate(key, fingerprint, { validated } = {}) {
|
|
160
|
-
// Prove the product BEFORE asking Polar to create anything. Checking the
|
|
161
|
-
// activate response is too late: the activation already exists, and it
|
|
162
|
-
// spent a slot on the other product's key. The person penalised is that
|
|
163
|
-
// product's paying customer, for running one wrong command.
|
|
164
|
-
//
|
|
165
|
-
// `validated` lets a caller that has ALREADY walked the list hand the
|
|
166
|
-
// answer over instead of paying for it twice. The CLI's activate has to
|
|
167
|
-
// validate first anyway — it branches on the matched benefit to decide
|
|
168
|
-
// whether the trial-claim service is consulted — and the duplication cost
|
|
169
|
-
// a real activation a 429. Opt-in, never the default: supply nothing and
|
|
170
|
-
// the pre-check still runs.
|
|
171
|
-
//
|
|
172
|
-
// The matched tier is carried through rather than re-derived from the
|
|
173
|
-
// activate response — the two endpoints nest the field differently, and
|
|
174
|
-
// one of the two paths is always the one that gets forgotten.
|
|
175
|
-
let benefitId;
|
|
176
|
-
let expiry = {};
|
|
177
|
-
if (ids.length) {
|
|
178
|
-
const pre = validated ?? await validateAcrossBenefits(key);
|
|
179
|
-
if (pre.status !== 'valid') return pre;
|
|
180
|
-
benefitId = pre.benefitId;
|
|
181
|
-
// The validate response is where the expiry lives; the activate
|
|
182
|
-
// response describes an activation, not the licence behind it.
|
|
183
|
-
if (pre.expiresAt) expiry = { expiresAt: pre.expiresAt };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const r = await post('activate', { key, label: `clawform ${fingerprint}` });
|
|
187
|
-
if (r.kind === 'error') return { status: 'error', reason: r.reason };
|
|
188
|
-
if (r.ok) {
|
|
189
|
-
if (wrongProduct(r.body)) return { status: 'invalid', reason: 'wrong_product' };
|
|
190
|
-
return {
|
|
191
|
-
status: 'valid',
|
|
192
|
-
instanceId: r.body?.id,
|
|
193
|
-
holder: r.body?.license_key?.customer?.email,
|
|
194
|
-
...(benefitId ? { benefitId } : {}),
|
|
195
|
-
...expiry,
|
|
196
|
-
...expiryOf(r.body),
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
if (r.status === 403) return { status: 'invalid', reason: classify403(r.body) };
|
|
200
|
-
if (r.status === 404) return { status: 'invalid', reason: classify404(r.body) };
|
|
201
|
-
return { status: 'error', reason: `HTTP ${r.status}` };
|
|
202
|
-
},
|
|
203
|
-
|
|
204
|
-
async validate(key, activationId) {
|
|
205
|
-
return validateAcrossBenefits(key, activationId);
|
|
206
|
-
},
|
|
207
|
-
|
|
208
|
-
async deactivate(key, activationId) {
|
|
209
|
-
const r = await post('deactivate', { key, activation_id: activationId });
|
|
210
|
-
if (r.kind === 'error') return { status: 'error', reason: r.reason };
|
|
211
|
-
if (r.ok) return { status: 'valid' };
|
|
212
|
-
return { status: 'error', reason: `HTTP ${r.status}` };
|
|
213
|
-
},
|
|
214
|
-
};
|
|
215
|
-
}
|
|
1
|
+
// Polar adapter for the vendor-agnostic license client. Maps the Polar
|
|
2
|
+
// customer-portal license-key API onto {status: valid|invalid|error}.
|
|
3
|
+
//
|
|
4
|
+
// The customer-portal endpoints authenticate with the license key + the
|
|
5
|
+
// organization id alone — there is NO server secret to embed, which is why the
|
|
6
|
+
// adapter can ship in a public tarball. Endpoints and error mapping verified
|
|
7
|
+
// against polarsource/polar (2026-07):
|
|
8
|
+
// activate/validate/deactivate under /v1/customer-portal/license-keys/
|
|
9
|
+
// NotPermitted → 403 (limit reached / expired), ResourceNotFound → 404.
|
|
10
|
+
|
|
11
|
+
const PATH = '/v1/customer-portal/license-keys';
|
|
12
|
+
|
|
13
|
+
// A 403 body distinguishes "no slots left" from "expired" by its message; both
|
|
14
|
+
// are terminal (status:'invalid') but the reason drives a different hint.
|
|
15
|
+
function classify403(body) {
|
|
16
|
+
const detail = String(body?.detail ?? '').toLowerCase();
|
|
17
|
+
if (detail.includes('expired')) return 'expired';
|
|
18
|
+
if (detail.includes('limit')) return 'limit_reached';
|
|
19
|
+
return 'not_permitted';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Polar answers 404 both for a key that does not exist and for a key that
|
|
23
|
+
// belongs to a different product ("License key does not match given benefit.",
|
|
24
|
+
// observed live). Collapsing them tells a buyer with a perfectly valid key to
|
|
25
|
+
// go hunting for typos.
|
|
26
|
+
function classify404(body) {
|
|
27
|
+
const detail = String(body?.detail ?? '').toLowerCase();
|
|
28
|
+
if (detail.includes('benefit')) return 'wrong_product';
|
|
29
|
+
return 'not_found';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createPolarClient({
|
|
33
|
+
fetch: fetchImpl = fetch,
|
|
34
|
+
baseUrl,
|
|
35
|
+
organizationId,
|
|
36
|
+
benefitIds,
|
|
37
|
+
...rest
|
|
38
|
+
} = {}) {
|
|
39
|
+
// `benefitId` (singular) was this option's name until tiers arrived. Left
|
|
40
|
+
// unhandled it destructures away, leaves the list empty, and yields a client
|
|
41
|
+
// that enforces nothing — a key from any product in the organization would
|
|
42
|
+
// then activate. Refuse loudly instead: a crash in the licence path is
|
|
43
|
+
// fail-closed, and silence here is the failure mode with no symptom.
|
|
44
|
+
if ('benefitId' in rest) {
|
|
45
|
+
throw new Error('createPolarClient: `benefitId` was superseded by `benefitIds` (a list, one entry per paid tier).');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// One benefit per paid tier: Polar carries limit_activations on the benefit,
|
|
49
|
+
// so a 3-device seat and a 10-device team seat cannot share one. A key is
|
|
50
|
+
// accepted when it matches ANY of them.
|
|
51
|
+
//
|
|
52
|
+
// A bare string is wrapped rather than ignored. Ignoring it would turn a
|
|
53
|
+
// config mistake into a client that enforces nothing — silently permissive is
|
|
54
|
+
// the one failure this whole check exists to prevent.
|
|
55
|
+
const ids = benefitIds == null ? [] : (Array.isArray(benefitIds) ? benefitIds : [benefitIds]);
|
|
56
|
+
|
|
57
|
+
// Which product this key was bought for. Polar nests it differently per
|
|
58
|
+
// endpoint: activate returns an activation with the licence inside, validate
|
|
59
|
+
// returns the licence itself. Reading both here keeps the asymmetry in one
|
|
60
|
+
// place instead of in each caller.
|
|
61
|
+
const wrongProduct = (body) => {
|
|
62
|
+
if (!ids.length) return false; // unset: constructor used in isolation
|
|
63
|
+
const found = body?.benefit_id ?? body?.license_key?.benefit_id;
|
|
64
|
+
return Boolean(found) && !ids.includes(found);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
async function post(endpoint, body) {
|
|
68
|
+
// Any thrown fetch (DNS, connection, timeout), a 5xx, or a 429 is an
|
|
69
|
+
// OUTAGE or a throttle, not a rejection — it maps to 'error' so the
|
|
70
|
+
// caller's offline grace applies. Only a deliberate 403/404 from Polar is
|
|
71
|
+
// 'invalid'.
|
|
72
|
+
let resp;
|
|
73
|
+
try {
|
|
74
|
+
resp = await fetchImpl(`${baseUrl}${PATH}/${endpoint}`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'content-type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({ organization_id: organizationId, ...body }),
|
|
78
|
+
});
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return { kind: 'error', reason: err?.message ?? 'network error' };
|
|
81
|
+
}
|
|
82
|
+
if (resp.status >= 500) return { kind: 'error', reason: `HTTP ${resp.status}` };
|
|
83
|
+
// Throttling, observed against the live API when calls come in a burst.
|
|
84
|
+
// Named rather than passed through as a status code: the caller's generic
|
|
85
|
+
// error text tells the user to retry "once you are online", which is
|
|
86
|
+
// false here and unhelpful to someone whose retries caused it.
|
|
87
|
+
if (resp.status === 429) return { kind: 'error', reason: 'rate_limited' };
|
|
88
|
+
let json = {};
|
|
89
|
+
try { json = await resp.json(); } catch { /* empty body (e.g. 204) is fine */ }
|
|
90
|
+
return { kind: 'http', status: resp.status, ok: resp.ok, body: json };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Asking the vendor to enforce the product beats comparing its answer.
|
|
94
|
+
// `benefit_id` is a documented request field and Polar rejects a mismatch
|
|
95
|
+
// with 404 "License key does not match given benefit." — verified live.
|
|
96
|
+
// Sent only when configured, so the constructor stays usable in isolation.
|
|
97
|
+
async function validateOnce(key, activationId, benefitId) {
|
|
98
|
+
const body = activationId ? { key, activation_id: activationId } : { key };
|
|
99
|
+
return post('validate', benefitId ? { ...body, benefit_id: benefitId } : body);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A request carries one benefit, so several tiers means several attempts.
|
|
103
|
+
//
|
|
104
|
+
// Two stopping rules, both about not lying to the caller:
|
|
105
|
+
//
|
|
106
|
+
// - Anything that is not a flat rejection ends the walk immediately. An
|
|
107
|
+
// unreachable vendor must stay 'error', because that is what the 14-day
|
|
108
|
+
// offline grace keys off; continuing and reporting whatever the last tier
|
|
109
|
+
// said would lock a paying customer out during someone else's outage.
|
|
110
|
+
// - A rejection that describes the KEY — it does not exist, it expired, it
|
|
111
|
+
// is out of slots — is authoritative whatever tier answered, so it wins
|
|
112
|
+
// over a mere "not this benefit". Only 'wrong_product' is worth retrying,
|
|
113
|
+
// because only it is a statement about the benefit rather than the key.
|
|
114
|
+
// A successful walk also reports WHICH benefit answered. That is not
|
|
115
|
+
// bookkeeping: it is how a caller tells a trial key from a paid one, and
|
|
116
|
+
// therefore whether the vendor's own delivery service may be consulted at
|
|
117
|
+
// all. Deriving it a second time at the call site would mean a second copy of
|
|
118
|
+
// the tier list, which is the drift this whole module exists to prevent.
|
|
119
|
+
async function validateAcrossBenefits(key, activationId) {
|
|
120
|
+
if (!ids.length) return fromValidate(await validateOnce(key, activationId, null));
|
|
121
|
+
|
|
122
|
+
let last;
|
|
123
|
+
for (const benefitId of ids) {
|
|
124
|
+
const res = fromValidate(await validateOnce(key, activationId, benefitId));
|
|
125
|
+
if (res.status === 'valid') return { ...res, benefitId };
|
|
126
|
+
if (res.status !== 'invalid') return res;
|
|
127
|
+
if (res.reason !== 'wrong_product') return res;
|
|
128
|
+
last = res;
|
|
129
|
+
}
|
|
130
|
+
return last;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Observed live: Polar puts `expires_at` on the 200 for a key that is still
|
|
134
|
+
// valid, so a trial's end date is known at activation rather than discovered
|
|
135
|
+
// when it lapses. Omitted entirely when the vendor gives none — a perpetual
|
|
136
|
+
// seat with `expiresAt: undefined` would read as "expired at the epoch" to
|
|
137
|
+
// anything doing date arithmetic.
|
|
138
|
+
const expiryOf = (body) => {
|
|
139
|
+
const at = body?.expires_at ?? body?.license_key?.expires_at;
|
|
140
|
+
return at ? { expiresAt: at } : {};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
function fromValidate(r) {
|
|
144
|
+
if (r.kind === 'error') return { status: 'error', reason: r.reason };
|
|
145
|
+
if (r.ok) {
|
|
146
|
+
// Secondary, and fail-open by design: the server is authoritative, and
|
|
147
|
+
// a response that simply omits the field must not lock a buyer out.
|
|
148
|
+
if (wrongProduct(r.body)) return { status: 'invalid', reason: 'wrong_product' };
|
|
149
|
+
return { status: 'valid', ...expiryOf(r.body) };
|
|
150
|
+
}
|
|
151
|
+
if (r.status === 403) return { status: 'invalid', reason: classify403(r.body) };
|
|
152
|
+
if (r.status === 404) return { status: 'invalid', reason: classify404(r.body) };
|
|
153
|
+
return { status: 'error', reason: `HTTP ${r.status}` };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
configured: true,
|
|
158
|
+
|
|
159
|
+
async activate(key, fingerprint, { validated } = {}) {
|
|
160
|
+
// Prove the product BEFORE asking Polar to create anything. Checking the
|
|
161
|
+
// activate response is too late: the activation already exists, and it
|
|
162
|
+
// spent a slot on the other product's key. The person penalised is that
|
|
163
|
+
// product's paying customer, for running one wrong command.
|
|
164
|
+
//
|
|
165
|
+
// `validated` lets a caller that has ALREADY walked the list hand the
|
|
166
|
+
// answer over instead of paying for it twice. The CLI's activate has to
|
|
167
|
+
// validate first anyway — it branches on the matched benefit to decide
|
|
168
|
+
// whether the trial-claim service is consulted — and the duplication cost
|
|
169
|
+
// a real activation a 429. Opt-in, never the default: supply nothing and
|
|
170
|
+
// the pre-check still runs.
|
|
171
|
+
//
|
|
172
|
+
// The matched tier is carried through rather than re-derived from the
|
|
173
|
+
// activate response — the two endpoints nest the field differently, and
|
|
174
|
+
// one of the two paths is always the one that gets forgotten.
|
|
175
|
+
let benefitId;
|
|
176
|
+
let expiry = {};
|
|
177
|
+
if (ids.length) {
|
|
178
|
+
const pre = validated ?? await validateAcrossBenefits(key);
|
|
179
|
+
if (pre.status !== 'valid') return pre;
|
|
180
|
+
benefitId = pre.benefitId;
|
|
181
|
+
// The validate response is where the expiry lives; the activate
|
|
182
|
+
// response describes an activation, not the licence behind it.
|
|
183
|
+
if (pre.expiresAt) expiry = { expiresAt: pre.expiresAt };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const r = await post('activate', { key, label: `clawform ${fingerprint}` });
|
|
187
|
+
if (r.kind === 'error') return { status: 'error', reason: r.reason };
|
|
188
|
+
if (r.ok) {
|
|
189
|
+
if (wrongProduct(r.body)) return { status: 'invalid', reason: 'wrong_product' };
|
|
190
|
+
return {
|
|
191
|
+
status: 'valid',
|
|
192
|
+
instanceId: r.body?.id,
|
|
193
|
+
holder: r.body?.license_key?.customer?.email,
|
|
194
|
+
...(benefitId ? { benefitId } : {}),
|
|
195
|
+
...expiry,
|
|
196
|
+
...expiryOf(r.body),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (r.status === 403) return { status: 'invalid', reason: classify403(r.body) };
|
|
200
|
+
if (r.status === 404) return { status: 'invalid', reason: classify404(r.body) };
|
|
201
|
+
return { status: 'error', reason: `HTTP ${r.status}` };
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
async validate(key, activationId) {
|
|
205
|
+
return validateAcrossBenefits(key, activationId);
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
async deactivate(key, activationId) {
|
|
209
|
+
const r = await post('deactivate', { key, activation_id: activationId });
|
|
210
|
+
if (r.kind === 'error') return { status: 'error', reason: r.reason };
|
|
211
|
+
if (r.ok) return { status: 'valid' };
|
|
212
|
+
return { status: 'error', reason: `HTTP ${r.status}` };
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
package/src/lib/session-state.js
CHANGED
|
@@ -1,91 +1,91 @@
|
|
|
1
|
-
// In-flight deploy state — survives `/clear` and new sessions.
|
|
2
|
-
//
|
|
3
|
-
// What clawform persists is NOT conversation (that's the consumer's kit's job) —
|
|
4
|
-
// it's the AWS operation left mid-flight: a changeset created but not yet
|
|
5
|
-
// executed. `clawform deploy` records it here right after CreateChangeSet and
|
|
6
|
-
// clears it after ExecuteChangeSet; if the operator aborts at the confirm
|
|
7
|
-
// prompt (or runs `/clear`), the entry survives so the SessionStart context
|
|
8
|
-
// hook can warn the next session that a changeset is sitting unexecuted.
|
|
9
|
-
//
|
|
10
|
-
// State file: <projectDir>/.clawform-state.json (gitignored). Stores only
|
|
11
|
-
// stack name + changeset name + env + createdAt — never account IDs, ARNs,
|
|
12
|
-
// or template bodies. All reads fail-open: a missing or corrupt file yields
|
|
13
|
-
// an empty state, never an exception, so a bad state file never blocks a
|
|
14
|
-
// deploy or a session start.
|
|
15
|
-
|
|
16
|
-
import { readFileSync as fsReadFileSync, writeFileSync as fsWriteFileSync } from 'node:fs';
|
|
17
|
-
import { join } from 'node:path';
|
|
18
|
-
|
|
19
|
-
export const STATE_FILENAME = '.clawform-state.json';
|
|
20
|
-
export const STATE_VERSION = 1;
|
|
21
|
-
export const STALE_CHANGESET_DAYS = 7;
|
|
22
|
-
|
|
23
|
-
function statePath(projectDir) {
|
|
24
|
-
return join(projectDir, STATE_FILENAME);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function emptyState() {
|
|
28
|
-
return { v: STATE_VERSION, pendingChangesets: [] };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function readState(projectDir, { readFileSync = fsReadFileSync } = {}) {
|
|
32
|
-
let raw;
|
|
33
|
-
try {
|
|
34
|
-
raw = readFileSync(statePath(projectDir), 'utf8');
|
|
35
|
-
} catch {
|
|
36
|
-
return emptyState();
|
|
37
|
-
}
|
|
38
|
-
try {
|
|
39
|
-
const parsed = JSON.parse(raw);
|
|
40
|
-
if (!parsed || parsed.v !== STATE_VERSION || !Array.isArray(parsed.pendingChangesets)) {
|
|
41
|
-
return emptyState();
|
|
42
|
-
}
|
|
43
|
-
return parsed;
|
|
44
|
-
} catch {
|
|
45
|
-
return emptyState();
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function writeState(projectDir, state, { writeFileSync = fsWriteFileSync } = {}) {
|
|
50
|
-
writeFileSync(statePath(projectDir), JSON.stringify(state, null, 2));
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const changesetKey = (e) => `${e.stack} ${e.changeSetName}`;
|
|
54
|
-
|
|
55
|
-
const DAY_MS = 86_400_000;
|
|
56
|
-
|
|
57
|
-
// Entries older than the stale threshold are treated as abandoned — a
|
|
58
|
-
// changeset the operator never executed and never will. We can't clear those
|
|
59
|
-
// on the read path (the SessionStart hook must not write), so we self-heal on
|
|
60
|
-
// the write path: every `addPendingChangeset` (i.e. every new deploy) drops any
|
|
61
|
-
// entry past the threshold. This bounds accumulation and means a phantom entry
|
|
62
|
-
// disappears on the next deploy without the operator hand-editing the file.
|
|
63
|
-
function isStale(entry, now) {
|
|
64
|
-
const created = Date.parse(entry?.createdAt);
|
|
65
|
-
if (!Number.isFinite(created)) return true; // unparseable createdAt → treat as junk, prune
|
|
66
|
-
return now - created > STALE_CHANGESET_DAYS * DAY_MS;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Record (or refresh) a pending changeset. Stores only the safe fields; a
|
|
70
|
-
// repeated stack+changeset replaces the prior entry (latest createdAt wins).
|
|
71
|
-
// Prunes stale/abandoned entries in the same write (self-healing).
|
|
72
|
-
export function addPendingChangeset(projectDir, entry, deps = {}) {
|
|
73
|
-
const now = typeof deps.now === 'number' ? deps.now : Date.now();
|
|
74
|
-
const state = readState(projectDir, deps);
|
|
75
|
-
const next = state.pendingChangesets
|
|
76
|
-
.filter((e) => changesetKey(e) !== changesetKey(entry))
|
|
77
|
-
.filter((e) => !isStale(e, now));
|
|
78
|
-
next.push({
|
|
79
|
-
stack: entry.stack,
|
|
80
|
-
changeSetName: entry.changeSetName,
|
|
81
|
-
env: entry.env,
|
|
82
|
-
createdAt: entry.createdAt,
|
|
83
|
-
});
|
|
84
|
-
writeState(projectDir, { ...state, pendingChangesets: next }, deps);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function clearPendingChangeset(projectDir, { stack, changeSetName }, deps = {}) {
|
|
88
|
-
const state = readState(projectDir, deps);
|
|
89
|
-
const next = state.pendingChangesets.filter((e) => !(e.stack === stack && e.changeSetName === changeSetName));
|
|
90
|
-
writeState(projectDir, { ...state, pendingChangesets: next }, deps);
|
|
91
|
-
}
|
|
1
|
+
// In-flight deploy state — survives `/clear` and new sessions.
|
|
2
|
+
//
|
|
3
|
+
// What clawform persists is NOT conversation (that's the consumer's kit's job) —
|
|
4
|
+
// it's the AWS operation left mid-flight: a changeset created but not yet
|
|
5
|
+
// executed. `clawform deploy` records it here right after CreateChangeSet and
|
|
6
|
+
// clears it after ExecuteChangeSet; if the operator aborts at the confirm
|
|
7
|
+
// prompt (or runs `/clear`), the entry survives so the SessionStart context
|
|
8
|
+
// hook can warn the next session that a changeset is sitting unexecuted.
|
|
9
|
+
//
|
|
10
|
+
// State file: <projectDir>/.clawform-state.json (gitignored). Stores only
|
|
11
|
+
// stack name + changeset name + env + createdAt — never account IDs, ARNs,
|
|
12
|
+
// or template bodies. All reads fail-open: a missing or corrupt file yields
|
|
13
|
+
// an empty state, never an exception, so a bad state file never blocks a
|
|
14
|
+
// deploy or a session start.
|
|
15
|
+
|
|
16
|
+
import { readFileSync as fsReadFileSync, writeFileSync as fsWriteFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
export const STATE_FILENAME = '.clawform-state.json';
|
|
20
|
+
export const STATE_VERSION = 1;
|
|
21
|
+
export const STALE_CHANGESET_DAYS = 7;
|
|
22
|
+
|
|
23
|
+
function statePath(projectDir) {
|
|
24
|
+
return join(projectDir, STATE_FILENAME);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function emptyState() {
|
|
28
|
+
return { v: STATE_VERSION, pendingChangesets: [] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readState(projectDir, { readFileSync = fsReadFileSync } = {}) {
|
|
32
|
+
let raw;
|
|
33
|
+
try {
|
|
34
|
+
raw = readFileSync(statePath(projectDir), 'utf8');
|
|
35
|
+
} catch {
|
|
36
|
+
return emptyState();
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (!parsed || parsed.v !== STATE_VERSION || !Array.isArray(parsed.pendingChangesets)) {
|
|
41
|
+
return emptyState();
|
|
42
|
+
}
|
|
43
|
+
return parsed;
|
|
44
|
+
} catch {
|
|
45
|
+
return emptyState();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function writeState(projectDir, state, { writeFileSync = fsWriteFileSync } = {}) {
|
|
50
|
+
writeFileSync(statePath(projectDir), JSON.stringify(state, null, 2));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const changesetKey = (e) => `${e.stack} ${e.changeSetName}`;
|
|
54
|
+
|
|
55
|
+
const DAY_MS = 86_400_000;
|
|
56
|
+
|
|
57
|
+
// Entries older than the stale threshold are treated as abandoned — a
|
|
58
|
+
// changeset the operator never executed and never will. We can't clear those
|
|
59
|
+
// on the read path (the SessionStart hook must not write), so we self-heal on
|
|
60
|
+
// the write path: every `addPendingChangeset` (i.e. every new deploy) drops any
|
|
61
|
+
// entry past the threshold. This bounds accumulation and means a phantom entry
|
|
62
|
+
// disappears on the next deploy without the operator hand-editing the file.
|
|
63
|
+
function isStale(entry, now) {
|
|
64
|
+
const created = Date.parse(entry?.createdAt);
|
|
65
|
+
if (!Number.isFinite(created)) return true; // unparseable createdAt → treat as junk, prune
|
|
66
|
+
return now - created > STALE_CHANGESET_DAYS * DAY_MS;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Record (or refresh) a pending changeset. Stores only the safe fields; a
|
|
70
|
+
// repeated stack+changeset replaces the prior entry (latest createdAt wins).
|
|
71
|
+
// Prunes stale/abandoned entries in the same write (self-healing).
|
|
72
|
+
export function addPendingChangeset(projectDir, entry, deps = {}) {
|
|
73
|
+
const now = typeof deps.now === 'number' ? deps.now : Date.now();
|
|
74
|
+
const state = readState(projectDir, deps);
|
|
75
|
+
const next = state.pendingChangesets
|
|
76
|
+
.filter((e) => changesetKey(e) !== changesetKey(entry))
|
|
77
|
+
.filter((e) => !isStale(e, now));
|
|
78
|
+
next.push({
|
|
79
|
+
stack: entry.stack,
|
|
80
|
+
changeSetName: entry.changeSetName,
|
|
81
|
+
env: entry.env,
|
|
82
|
+
createdAt: entry.createdAt,
|
|
83
|
+
});
|
|
84
|
+
writeState(projectDir, { ...state, pendingChangesets: next }, deps);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function clearPendingChangeset(projectDir, { stack, changeSetName }, deps = {}) {
|
|
88
|
+
const state = readState(projectDir, deps);
|
|
89
|
+
const next = state.pendingChangesets.filter((e) => !(e.stack === stack && e.changeSetName === changeSetName));
|
|
90
|
+
writeState(projectDir, { ...state, pendingChangesets: next }, deps);
|
|
91
|
+
}
|