@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
@@ -31,7 +31,11 @@ export const LICENSE_VERSION = 1;
31
31
  function licenseDir(home) { return join(home, LICENSE_DIR); }
32
32
  function licensePath(home) { return join(licenseDir(home), LICENSE_FILENAME); }
33
33
 
34
- function unlicensed() { return { v: LICENSE_VERSION, status: 'unlicensed' }; }
34
+ // `reason` lets a caller tell an ABSENT record (never activated) from a STALE
35
+ // one (an older Clawform wrote it; this version can't verify its signature) so
36
+ // the CLI can say "refresh it" instead of "your key is gone". It carries no
37
+ // grant — status is still 'unlicensed'.
38
+ function unlicensed(reason = 'absent') { return { v: LICENSE_VERSION, status: 'unlicensed', reason }; }
35
39
 
36
40
  // Fields that a signature covers. Fixed order, so the digest depends on the
37
41
  // content and not on the order the caller happened to build the object in.
@@ -66,19 +70,50 @@ export function readLicense({ homedir = osHomedir, readFileSync = fsRead } = {})
66
70
  try {
67
71
  raw = readFileSync(licensePath(homedir()), 'utf8');
68
72
  } catch {
69
- return unlicensed();
73
+ return unlicensed('absent');
70
74
  }
71
75
  try {
72
76
  const parsed = JSON.parse(raw);
73
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return unlicensed();
74
- if (parsed.v !== LICENSE_VERSION || typeof parsed.status !== 'string') return unlicensed();
77
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return unlicensed('corrupt');
78
+ if (parsed.v !== LICENSE_VERSION || typeof parsed.status !== 'string') {
79
+ // A parseable record that still LOOKS like a licence but was written under
80
+ // a different schema version is STALE, not junk — the caller can offer a
81
+ // cheap refresh rather than "your key is gone".
82
+ return (parsed.status === 'active' && parsed.key) ? unlicensed('stale') : unlicensed('corrupt');
83
+ }
75
84
  // An unsigned or altered 'active' record is not a licence — it is exactly
76
- // the forgery the signature exists to catch. 'unlicensed' claims nothing,
77
- // so it needs no proof.
78
- if (parsed.status === 'active' && !signatureValid(parsed)) return unlicensed();
85
+ // the forgery the signature exists to catch. But a record we WROTE whose
86
+ // signed field-set later changed (e.g. a new field was added to the schema)
87
+ // also fails here; both surface as 'stale' so re-activation, not accusation,
88
+ // is offered. 'unlicensed' claims nothing, so this grants nothing.
89
+ if (parsed.status === 'active' && !signatureValid(parsed)) return unlicensed('stale');
79
90
  return parsed;
80
91
  } catch {
81
- return unlicensed();
92
+ return unlicensed('corrupt');
93
+ }
94
+ }
95
+
96
+ // Parse the record WITHOUT verifying the signature. RECOVERY ONLY: callers use
97
+ // it to recover `instanceId`/`key` so an activation can be re-adopted or an
98
+ // orphaned slot released. It grants NOTHING — an 'active' status here is not
99
+ // proof of a licence (only a server-side `validate` is), and the worst a forged
100
+ // record achieves through this path is deactivating an activation, never a free
101
+ // entitlement. That asymmetry is why bypassing the signature is safe here and
102
+ // never in `readLicense`.
103
+ export function readLicenseRaw({ homedir = osHomedir, readFileSync = fsRead } = {}) {
104
+ let raw;
105
+ try {
106
+ raw = readFileSync(licensePath(homedir()), 'utf8');
107
+ } catch {
108
+ return unlicensed('absent');
109
+ }
110
+ try {
111
+ const parsed = JSON.parse(raw);
112
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return unlicensed('corrupt');
113
+ if (parsed.status === 'active' && parsed.key) return parsed;
114
+ return unlicensed('absent');
115
+ } catch {
116
+ return unlicensed('corrupt');
82
117
  }
83
118
  }
84
119
 
@@ -1,150 +1,163 @@
1
- // License enforcement decision — the one function value commands call before
2
- // doing work. Everything impure is injected (store, clock, validation client,
3
- // env) so the state matrix tests deterministically; production call sites pass
4
- // nothing and get the real store + adapter.
5
- //
6
- // The client seam is vendor-agnostic on purpose: whichever vendor survives the
7
- // payout test implements `validate(key) → { status, reason? }` with statuses
8
- // 'valid' — vendor confirmed the key
9
- // 'invalid' — vendor answered and said no (revoked / refunded / not found)
10
- // 'error' — vendor unreachable (network down, vendor outage)
11
- // The distinction between 'invalid' and 'error' is the whole design: outages
12
- // get the offline grace window, revocations do not.
13
-
14
- import { readLicense as storeRead, writeLicense as storeWrite } from './license-store.js';
15
- import { readEnv } from './rename-compat.js';
16
- import { logger } from './logger.js';
17
- import { GRACE_DAYS, REVALIDATE_HOURS, CLOCK_SKEW_HOURS } from './license-config.js';
18
-
19
- // Emergency operations are never license-gated: an expired card must not be
20
- // the reason a stuck production stack cannot be rolled back. Additions to this
21
- // list are a product decision, not a convenience — a test pins its contents.
22
- export const NEVER_GATED = new Set(['deploy', 'rollback']);
23
-
24
- const HOUR = 3_600_000;
25
- const DAY = 24 * HOUR;
26
-
27
- function hoursSince(iso, nowMs) {
28
- const t = Date.parse(iso ?? '');
29
- return Number.isFinite(t) ? (nowMs - t) / HOUR : Infinity;
30
- }
31
-
32
- export async function assertLicensed({
33
- command,
34
- readLicense = storeRead,
35
- writeLicense = storeWrite,
36
- client,
37
- now = Date.now,
38
- env = process.env,
39
- warn = (m) => logger.warn(m),
40
- } = {}) {
41
- if (NEVER_GATED.has(command)) return;
42
-
43
- // Dormant gate: until a vendor adapter ships, there is no way to activate a
44
- // key at all, so enforcement would lock every user out. A client that marks
45
- // itself configured:false keeps the gate open; the adapter flips this, and a
46
- // pinned test makes that flip a reviewed decision.
47
- if (client?.configured === false) return;
48
-
49
- // CI path: a key in the environment is validated on every run and nothing is
50
- // persisted — CI boxes are ephemeral, so a local cache (and with it the
51
- // offline grace window) would be meaningless there.
52
- const envKey = readEnv('LICENSE_KEY', env);
53
- if (envKey) {
54
- const res = await client.validate(envKey);
55
- if (res.status === 'valid') return;
56
- if (res.status === 'invalid') {
57
- throw new Error(
58
- `CLAWFORM_LICENSE_KEY was rejected (${res.reason ?? 'invalid'}). ` +
59
- 'Check the key, or buy a seat — see the README for where.',
60
- );
61
- }
62
- throw new Error(
63
- `Could not verify CLAWFORM_LICENSE_KEY (${res.reason ?? 'network error'}) and CI has no ` +
64
- 'offline grace. Retry when the license service is reachable.',
65
- );
66
- }
67
-
68
- const record = readLicense();
69
- if (record.status !== 'active' || !record.key) {
70
- throw new Error(
71
- // The one message every npm installer without a key will read the only
72
- // audience the product reliably has. It leads with the door that costs
73
- // nothing, then the one for a key already bought.
74
- `'clawform ${command}' needs an activated license. ` +
75
- 'New here? There is a free 14-day trial — full kit, no card at https://clawform.thejoseki.com/?utm_source=cli. ' +
76
- 'Already have a key (trial or purchase email)? Run: clawform activate <key>',
77
- );
78
- }
79
-
80
- // A trial carries its own end date, handed over by the vendor at activation
81
- // and covered by the record's signature so it cannot be edited outward.
82
- //
83
- // Checked BEFORE the freshness shortcut below, which returns without touching
84
- // the network: a recently-validated record would otherwise sail past its own
85
- // expiry for another day.
86
- //
87
- // Expiry is a property of the licence, not of connectivity, so the offline
88
- // grace never reaches it and the message says "trial", never "offline". A
89
- // buyer told their connection failed will go looking for a fault that is not
90
- // there.
91
- //
92
- // A record with no expiresAt is a perpetual seat, not one that expired at the
93
- // epoch. Absence has to be checked explicitly.
94
- if (record.expiresAt) {
95
- const endsAt = Date.parse(record.expiresAt);
96
- if (Number.isFinite(endsAt) && now() >= endsAt) {
97
- throw new Error(
98
- `Your Clawform trial ended on ${record.expiresAt.slice(0, 10)}, so 'clawform ${command}' `
99
- + 'is no longer available. Buy a seat at https://clawform.thejoseki.com/?utm_source=cli and run '
100
- + '`clawform activate <key>`. Note that `clawform deploy` and `clawform rollback` keep '
101
- + 'working either wayan expired licence never strands a stack.',
102
- );
103
- }
104
- }
105
-
106
- // Freshness has a floor as well as a ceiling. A record stamped in the future
107
- // yields a negative age, which would read as "verified moments ago" forever —
108
- // a permanent bypass that never contacts the vendor again. Below the floor we
109
- // revalidate rather than refuse, so a genuinely fast clock costs a network
110
- // call instead of an accusation.
111
- const age = hoursSince(record.lastValidatedAt, now());
112
- if (age >= -CLOCK_SKEW_HOURS && age < REVALIDATE_HOURS) return; // fresh verdict — no network
113
-
114
- const res = await client.validate(record.key);
115
-
116
- if (res.status === 'valid') {
117
- // Carry a refreshed expiry through: the vendor is authoritative about it,
118
- // and an extended trial should take effect without a re-activation.
119
- writeLicense({
120
- ...record,
121
- lastValidatedAt: new Date(now()).toISOString(),
122
- ...(res.expiresAt ? { expiresAt: res.expiresAt } : {}),
123
- });
124
- return;
125
- }
126
-
127
- if (res.status === 'invalid') {
128
- throw new Error(
129
- `This license is no longer valid (${res.reason ?? 'revoked'}). ` +
130
- 'If you believe this is a mistake, contact support with your purchase email.',
131
- );
132
- }
133
-
134
- // Vendor unreachable — fall back on the grace window measured from the last
135
- // SUCCESSFUL validation, so a machine that keeps failing to validate does not
136
- // keep re-arming its own grace.
137
- const graceLeftDays = GRACE_DAYS - age / 24;
138
- if (graceLeftDays > 0) {
139
- warn(
140
- `Could not reach the license service to verify (${res.reason ?? 'offline'}). ` +
141
- `Working offline — ${Math.ceil(graceLeftDays)} day(s) of grace remaining.`,
142
- );
143
- return;
144
- }
145
-
146
- throw new Error(
147
- `The license could not be verified for more than ${GRACE_DAYS} days (offline or the ` +
148
- 'license service was unreachable). Reconnect once and the grace window resets.',
149
- );
150
- }
1
+ // License enforcement decision — the one function value commands call before
2
+ // doing work. Everything impure is injected (store, clock, validation client,
3
+ // env) so the state matrix tests deterministically; production call sites pass
4
+ // nothing and get the real store + adapter.
5
+ //
6
+ // The client seam is vendor-agnostic on purpose: whichever vendor survives the
7
+ // payout test implements `validate(key) → { status, reason? }` with statuses
8
+ // 'valid' — vendor confirmed the key
9
+ // 'invalid' — vendor answered and said no (revoked / refunded / not found)
10
+ // 'error' — vendor unreachable (network down, vendor outage)
11
+ // The distinction between 'invalid' and 'error' is the whole design: outages
12
+ // get the offline grace window, revocations do not.
13
+
14
+ import { readLicense as storeRead, writeLicense as storeWrite } from './license-store.js';
15
+ import { readEnv } from './rename-compat.js';
16
+ import { logger } from './logger.js';
17
+ import { GRACE_DAYS, REVALIDATE_HOURS, CLOCK_SKEW_HOURS } from './license-config.js';
18
+
19
+ // Emergency operations are never license-gated: an expired card must not be
20
+ // the reason a stuck production stack cannot be rolled back. Additions to this
21
+ // list are a product decision, not a convenience — a test pins its contents.
22
+ export const NEVER_GATED = new Set(['deploy', 'rollback']);
23
+
24
+ const HOUR = 3_600_000;
25
+ const DAY = 24 * HOUR;
26
+
27
+ function hoursSince(iso, nowMs) {
28
+ const t = Date.parse(iso ?? '');
29
+ return Number.isFinite(t) ? (nowMs - t) / HOUR : Infinity;
30
+ }
31
+
32
+ export async function assertLicensed({
33
+ command,
34
+ readLicense = storeRead,
35
+ writeLicense = storeWrite,
36
+ client,
37
+ now = Date.now,
38
+ env = process.env,
39
+ warn = (m) => logger.warn(m),
40
+ } = {}) {
41
+ if (NEVER_GATED.has(command)) return;
42
+
43
+ // Dormant gate: until a vendor adapter ships, there is no way to activate a
44
+ // key at all, so enforcement would lock every user out. A client that marks
45
+ // itself configured:false keeps the gate open; the adapter flips this, and a
46
+ // pinned test makes that flip a reviewed decision.
47
+ if (client?.configured === false) return;
48
+
49
+ // CI path: a key in the environment is validated on every run and nothing is
50
+ // persisted — CI boxes are ephemeral, so a local cache (and with it the
51
+ // offline grace window) would be meaningless there.
52
+ const envKey = readEnv('LICENSE_KEY', env);
53
+ if (envKey) {
54
+ const res = await client.validate(envKey);
55
+ if (res.status === 'valid') return;
56
+ if (res.status === 'invalid') {
57
+ throw new Error(
58
+ `CLAWFORM_LICENSE_KEY was rejected (${res.reason ?? 'invalid'}). ` +
59
+ 'Check the key, or buy a seat — see the README for where.',
60
+ );
61
+ }
62
+ throw new Error(
63
+ `Could not verify CLAWFORM_LICENSE_KEY (${res.reason ?? 'network error'}) and CI has no ` +
64
+ 'offline grace. Retry when the license service is reachable.',
65
+ );
66
+ }
67
+
68
+ const record = readLicense();
69
+ if (record.status !== 'active' || !record.key) {
70
+ if (record.reason === 'stale') {
71
+ // The record is on disk and looks like a licence, but an older Clawform
72
+ // wrote it and this version can't verify it. Saying "needs a license"
73
+ // reads as "my key is gone" — the exact panic that filed this bug. Point
74
+ // at the cheap fix: activate re-adopts the existing slot when it is still
75
+ // valid, so no extra seat is spent.
76
+ throw new Error(
77
+ `'clawform ${command}' can't run: this machine has a Clawform license record an older version ` +
78
+ `wrote, which this version cannot read. Run: clawform activate <key> — it re-adopts this machine's ` +
79
+ `existing activation when that is still valid, so it will not use another seat. ` +
80
+ `(Your key is in your trial or purchase email.)`,
81
+ );
82
+ }
83
+ throw new Error(
84
+ // The one message every npm installer without a key will read — the only
85
+ // audience the product reliably has. It leads with the door that costs
86
+ // nothing, then the one for a key already bought.
87
+ `'clawform ${command}' needs an activated license. ` +
88
+ 'New here? There is a free 14-day trial full kit, no card — at https://clawform.thejoseki.com/?utm_source=cli. ' +
89
+ 'Already have a key (trial or purchase email)? Run: clawform activate <key>',
90
+ );
91
+ }
92
+
93
+ // A trial carries its own end date, handed over by the vendor at activation
94
+ // and covered by the record's signature so it cannot be edited outward.
95
+ //
96
+ // Checked BEFORE the freshness shortcut below, which returns without touching
97
+ // the network: a recently-validated record would otherwise sail past its own
98
+ // expiry for another day.
99
+ //
100
+ // Expiry is a property of the licence, not of connectivity, so the offline
101
+ // grace never reaches it and the message says "trial", never "offline". A
102
+ // buyer told their connection failed will go looking for a fault that is not
103
+ // there.
104
+ //
105
+ // A record with no expiresAt is a perpetual seat, not one that expired at the
106
+ // epoch. Absence has to be checked explicitly.
107
+ if (record.expiresAt) {
108
+ const endsAt = Date.parse(record.expiresAt);
109
+ if (Number.isFinite(endsAt) && now() >= endsAt) {
110
+ throw new Error(
111
+ `Your Clawform trial ended on ${record.expiresAt.slice(0, 10)}, so 'clawform ${command}' `
112
+ + 'is no longer available. Buy a seat at https://clawform.thejoseki.com/?utm_source=cli and run '
113
+ + '`clawform activate <key>`. Note that `clawform deploy` and `clawform rollback` keep '
114
+ + 'working either way — an expired licence never strands a stack.',
115
+ );
116
+ }
117
+ }
118
+
119
+ // Freshness has a floor as well as a ceiling. A record stamped in the future
120
+ // yields a negative age, which would read as "verified moments ago" forever —
121
+ // a permanent bypass that never contacts the vendor again. Below the floor we
122
+ // revalidate rather than refuse, so a genuinely fast clock costs a network
123
+ // call instead of an accusation.
124
+ const age = hoursSince(record.lastValidatedAt, now());
125
+ if (age >= -CLOCK_SKEW_HOURS && age < REVALIDATE_HOURS) return; // fresh verdict — no network
126
+
127
+ const res = await client.validate(record.key);
128
+
129
+ if (res.status === 'valid') {
130
+ // Carry a refreshed expiry through: the vendor is authoritative about it,
131
+ // and an extended trial should take effect without a re-activation.
132
+ writeLicense({
133
+ ...record,
134
+ lastValidatedAt: new Date(now()).toISOString(),
135
+ ...(res.expiresAt ? { expiresAt: res.expiresAt } : {}),
136
+ });
137
+ return;
138
+ }
139
+
140
+ if (res.status === 'invalid') {
141
+ throw new Error(
142
+ `This license is no longer valid (${res.reason ?? 'revoked'}). ` +
143
+ 'If you believe this is a mistake, contact support with your purchase email.',
144
+ );
145
+ }
146
+
147
+ // Vendor unreachable fall back on the grace window measured from the last
148
+ // SUCCESSFUL validation, so a machine that keeps failing to validate does not
149
+ // keep re-arming its own grace.
150
+ const graceLeftDays = GRACE_DAYS - age / 24;
151
+ if (graceLeftDays > 0) {
152
+ warn(
153
+ `Could not reach the license service to verify (${res.reason ?? 'offline'}). ` +
154
+ `Working offline — ${Math.ceil(graceLeftDays)} day(s) of grace remaining.`,
155
+ );
156
+ return;
157
+ }
158
+
159
+ throw new Error(
160
+ `The license could not be verified for more than ${GRACE_DAYS} days (offline or the ` +
161
+ 'license service was unreachable). Reconnect once and the grace window resets.',
162
+ );
163
+ }