@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.
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,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
+ }
@@ -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
+ }