@thejoseki/clawform 0.0.1 → 2.2.1

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 (55) hide show
  1. package/LICENSE +95 -4
  2. package/README.md +93 -10
  3. package/bin/clawform.js +151 -16
  4. package/package.json +47 -11
  5. package/src/aws/catalog.js +116 -0
  6. package/src/aws/changeset.js +231 -0
  7. package/src/aws/drift.js +59 -0
  8. package/src/aws/exports.js +213 -0
  9. package/src/aws/inventory.js +156 -0
  10. package/src/commands/activate.js +105 -0
  11. package/src/commands/ci-init.js +212 -0
  12. package/src/commands/cost-report.js +166 -0
  13. package/src/commands/deactivate.js +36 -0
  14. package/src/commands/deploy.js +413 -0
  15. package/src/commands/diff.js +39 -0
  16. package/src/commands/drift.js +35 -0
  17. package/src/commands/init-plugin.js +168 -0
  18. package/src/commands/init.js +360 -0
  19. package/src/commands/license-status.js +28 -0
  20. package/src/commands/rollback.js +126 -0
  21. package/src/commands/status.js +150 -0
  22. package/src/commands/sync.js +53 -0
  23. package/src/commands/validate.js +349 -0
  24. package/src/hooks/block-dangerous-eval.js +160 -0
  25. package/src/hooks/block-dangerous.js +62 -0
  26. package/src/hooks/cfn-lint-after-edit-eval.js +30 -0
  27. package/src/hooks/cfn-lint-after-edit.js +81 -0
  28. package/src/hooks/check-catalog-fresh-eval.js +63 -0
  29. package/src/hooks/check-catalog-fresh.js +33 -0
  30. package/src/hooks/session-context-eval.js +81 -0
  31. package/src/hooks/session-context.js +41 -0
  32. package/src/hooks/subagent-context-eval.js +44 -0
  33. package/src/hooks/subagent-context.js +48 -0
  34. package/src/lib/audit-synthesis.js +24 -0
  35. package/src/lib/aws-client.js +15 -0
  36. package/src/lib/cfn-yaml.js +92 -0
  37. package/src/lib/config.js +76 -0
  38. package/src/lib/constants.js +85 -0
  39. package/src/lib/defaults.js +16 -0
  40. package/src/lib/fingerprint.js +65 -0
  41. package/src/lib/install-workflow.js +28 -0
  42. package/src/lib/kit-source.js +81 -0
  43. package/src/lib/license-client.js +84 -0
  44. package/src/lib/license-config.js +162 -0
  45. package/src/lib/license-store.js +107 -0
  46. package/src/lib/license.js +150 -0
  47. package/src/lib/lint-overrides.js +226 -0
  48. package/src/lib/logger.js +17 -0
  49. package/src/lib/payload.js +74 -0
  50. package/src/lib/project-config.js +145 -0
  51. package/src/lib/providers/polar.js +215 -0
  52. package/src/lib/rename-compat.js +50 -0
  53. package/src/lib/session-state.js +91 -0
  54. package/src/lib/trial-claim.js +65 -0
  55. package/src/lib/watermark.js +65 -0
@@ -0,0 +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
+ }
@@ -0,0 +1,50 @@
1
+ // Backward compatibility for the awsclaw -> clawform rename.
2
+ //
3
+ // Everything in this module exists so a project set up before the rename keeps
4
+ // working, and is meant to be deleted wholesale at v3 — which is why it lives
5
+ // here rather than being sprinkled through the loaders it serves.
6
+
7
+ const DEPRECATION_PREFIX = '[clawform]';
8
+
9
+ // Precedence is the array order: the current name first, the pre-rename name
10
+ // as a fallback.
11
+ export const CONFIG_FILENAMES = ['clawform.config.json', 'awsclaw.config.json'];
12
+
13
+ export const ENV_PREFIX = 'CLAWFORM_';
14
+ export const DEPRECATED_ENV_PREFIX = 'AWSCLAW_';
15
+
16
+ export function isDeprecatedConfigFilename(name) {
17
+ return name === 'awsclaw.config.json';
18
+ }
19
+
20
+ export function warnDeprecatedConfigFilename(path) {
21
+ process.stderr.write(
22
+ `${DEPRECATION_PREFIX} ${path} uses the pre-rename filename. ` +
23
+ 'Rename it to clawform.config.json — awsclaw.config.json stops being read in v3.\n',
24
+ );
25
+ }
26
+
27
+ // One warning per variable name, not per read: several call sites read the same
28
+ // flag, and a wall of identical lines trains people to ignore it.
29
+ const warnedEnvNames = new Set();
30
+
31
+ // `name` is the suffix shared by both spellings, e.g. 'DEBUG' for
32
+ // CLAWFORM_DEBUG / AWSCLAW_DEBUG.
33
+ export function readEnv(name, env = process.env) {
34
+ const current = env[ENV_PREFIX + name];
35
+ // Presence, not truthiness: an explicit CLAWFORM_X="" is the user turning the
36
+ // flag off, and falling through to a stale AWSCLAW_X would overrule them.
37
+ if (current !== undefined) return current;
38
+
39
+ const deprecated = env[DEPRECATED_ENV_PREFIX + name];
40
+ if (deprecated === undefined) return undefined;
41
+
42
+ if (!warnedEnvNames.has(name)) {
43
+ warnedEnvNames.add(name);
44
+ process.stderr.write(
45
+ `${DEPRECATION_PREFIX} ${DEPRECATED_ENV_PREFIX}${name} is deprecated — ` +
46
+ `use ${ENV_PREFIX}${name}. The old name stops being read in v3.\n`,
47
+ );
48
+ }
49
+ return deprecated;
50
+ }
@@ -0,0 +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
+ }
@@ -0,0 +1,65 @@
1
+ // Claims one trial per machine, against the vendor's own delivery service.
2
+ //
3
+ // The abuse this exists for: trial expires, new email, new trial key, same
4
+ // laptop. Polar cannot see that — it knows customers, not devices — so the
5
+ // check has to happen somewhere we control, and it has to happen at ACTIVATE.
6
+ // Claiming at the kit fetch would miss the case entirely: a machine that has
7
+ // already trialled has the kit cached, so it never calls out again.
8
+ //
9
+ // Honest about its own reach: the fingerprint is computed on the client and
10
+ // self-reported, so a changed hostname, a VM, or a patched CLI all pass. This
11
+ // stops the casual repeat and produces a record of how often it is tried. It is
12
+ // the same posture the product states publicly — a deterrent at the point of
13
+ // action, not a sandbox.
14
+
15
+ import { KIT_SERVICE_URL } from './license-config.js';
16
+
17
+ // The one answer that is about the DEVICE. Shared with the service so the two
18
+ // halves cannot drift into disagreeing about the word.
19
+ export const DEVICE_REFUSED = 'device_already_trialled';
20
+
21
+ /**
22
+ * @returns {Promise<{allowed: boolean, reason?: string}>}
23
+ * `allowed: false` ONLY when the service deliberately refused. Every other
24
+ * outcome — unreachable, throttled, 5xx — resolves to allowed, because our
25
+ * downtime must cost a lead and never a purchase. The caller additionally
26
+ * catches, so a thrown error is equally fail-open.
27
+ */
28
+ export async function claimTrialDevice({
29
+ licenseKey,
30
+ fingerprint,
31
+ serviceUrl = KIT_SERVICE_URL,
32
+ fetch: fetchImpl = fetch,
33
+ }) {
34
+ let resp;
35
+ try {
36
+ resp = await fetchImpl(`${serviceUrl}/trial-claim`, {
37
+ method: 'POST',
38
+ headers: { 'content-type': 'application/json' },
39
+ body: JSON.stringify({ license_key: licenseKey, fingerprint }),
40
+ });
41
+ } catch {
42
+ return { allowed: true, unverified: true };
43
+ }
44
+
45
+ // ONLY the device answer refuses here. The service also returns 403 when the
46
+ // licence itself is no good — wrong product, not found, expired — and reading
47
+ // those as "this machine already trialled" tells the user something false with
48
+ // full confidence. It happened for real: a service deployed before the trial
49
+ // benefit existed answered `wrong_product`, and the CLI announced a used
50
+ // trial. Licence problems are Polar's to report, and `activate` does report
51
+ // them accurately a moment later.
52
+ if (resp.status === 403) {
53
+ let detail = '';
54
+ try {
55
+ const body = await resp.json();
56
+ detail = String(body?.detail ?? '');
57
+ } catch { /* no body: cannot be attributed to the device */ }
58
+
59
+ if (detail === DEVICE_REFUSED) return { allowed: false, reason: DEVICE_REFUSED };
60
+ return { allowed: true, unverified: true };
61
+ }
62
+
63
+ // Anything else — a throttle, a 5xx, a bad gateway — lets the trial through.
64
+ return { allowed: true, unverified: !resp.ok };
65
+ }
@@ -0,0 +1,65 @@
1
+ // Ownership watermarks. A shared copy identifies its source on sight — the
2
+ // cheapest deterrent in the licensing stack, and the only one that reaches the
3
+ // biggest leak path: a paying buyer zipping their materialised plugin folder
4
+ // for the rest of the team.
5
+ //
6
+ // Two layers, deliberately at two exposure levels:
7
+ //
8
+ // .licensed-to full holder address. It is the buyer's own receipt on
9
+ // their own machine, and it is the first thing anyone
10
+ // leaking on purpose deletes — so it rarely travels.
11
+ // per-file comment MASKED holder + key suffix. This is the layer that
12
+ // SURVIVES a deliberate leak, so it exposes as little as
13
+ // possible while staying instantly recognisable to the
14
+ // buyer. Traceability does not depend on the address at
15
+ // all: the key suffix is enough to look the buyer up with
16
+ // the vendor.
17
+ //
18
+ // Neither layer ever carries a full licence key.
19
+
20
+ const suffixOf = (key) => String(key ?? '').slice(-4);
21
+
22
+ const isActive = (record) => Boolean(record && record.status === 'active' && record.key);
23
+
24
+ // 'jane@acme.com' → 'j***@acme.com'. Anything that isn't an address → ''.
25
+ export function maskEmail(email) {
26
+ const at = String(email ?? '').indexOf('@');
27
+ if (at < 1) return '';
28
+ return `${String(email)[0]}***${String(email).slice(at)}`;
29
+ }
30
+
31
+ // Appended to `--version`, where the buyer sees it every day.
32
+ export function versionWatermark(record) {
33
+ if (!isActive(record)) return '';
34
+ const suffix = `…${suffixOf(record.key)}`;
35
+ return record.holder
36
+ ? ` — licensed to ${record.holder} (${suffix})`
37
+ : ` — licensed (${suffix})`;
38
+ }
39
+
40
+ // Appended to each materialised SKILL.md. Deliberately timestamp-free: a
41
+ // timestamp would change every file's hash on each `init plugin`, destroying
42
+ // the install manifest's ability to tell our files from the user's edits.
43
+ export function contentWatermark(record) {
44
+ if (!isActive(record)) return '';
45
+ const who = maskEmail(record.holder);
46
+ const parts = ['Clawform', who && `licensed to ${who}`, `…${suffixOf(record.key)}`].filter(Boolean);
47
+ return `<!-- ${parts.join(' · ')} -->`;
48
+ }
49
+
50
+ // Contents of ~/.clawform/plugin/.licensed-to — the receipt. Full address here
51
+ // on purpose; see the module header for why that is the safer split.
52
+ export function licensedToBody(record, nowIso) {
53
+ const lines = [
54
+ 'Clawform — this copy is licensed to a single seat.',
55
+ '',
56
+ `Holder: ${record?.holder ?? '(unknown)'}`,
57
+ `Key: …${suffixOf(record?.key)}`,
58
+ `Activation: ${record?.instanceId ?? '(unknown)'}`,
59
+ `Materialised: ${nowIso}`,
60
+ '',
61
+ 'Redistributing this directory is a licence violation. Every skill file',
62
+ 'carries the same licence identity, so a shared copy remains traceable.',
63
+ ];
64
+ return `${lines.join('\n')}\n`;
65
+ }