@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.
- package/LICENSE +95 -4
- package/README.md +93 -10
- package/bin/clawform.js +151 -16
- package/package.json +47 -11
- package/src/aws/catalog.js +116 -0
- package/src/aws/changeset.js +231 -0
- package/src/aws/drift.js +59 -0
- package/src/aws/exports.js +213 -0
- package/src/aws/inventory.js +156 -0
- package/src/commands/activate.js +105 -0
- package/src/commands/ci-init.js +212 -0
- package/src/commands/cost-report.js +166 -0
- package/src/commands/deactivate.js +36 -0
- package/src/commands/deploy.js +413 -0
- package/src/commands/diff.js +39 -0
- package/src/commands/drift.js +35 -0
- package/src/commands/init-plugin.js +168 -0
- package/src/commands/init.js +360 -0
- package/src/commands/license-status.js +28 -0
- package/src/commands/rollback.js +126 -0
- package/src/commands/status.js +150 -0
- package/src/commands/sync.js +53 -0
- package/src/commands/validate.js +349 -0
- package/src/hooks/block-dangerous-eval.js +160 -0
- package/src/hooks/block-dangerous.js +62 -0
- package/src/hooks/cfn-lint-after-edit-eval.js +30 -0
- package/src/hooks/cfn-lint-after-edit.js +81 -0
- package/src/hooks/check-catalog-fresh-eval.js +63 -0
- package/src/hooks/check-catalog-fresh.js +33 -0
- package/src/hooks/session-context-eval.js +81 -0
- package/src/hooks/session-context.js +41 -0
- package/src/hooks/subagent-context-eval.js +44 -0
- package/src/hooks/subagent-context.js +48 -0
- package/src/lib/audit-synthesis.js +24 -0
- package/src/lib/aws-client.js +15 -0
- package/src/lib/cfn-yaml.js +92 -0
- package/src/lib/config.js +76 -0
- package/src/lib/constants.js +85 -0
- package/src/lib/defaults.js +16 -0
- package/src/lib/fingerprint.js +65 -0
- package/src/lib/install-workflow.js +28 -0
- package/src/lib/kit-source.js +81 -0
- package/src/lib/license-client.js +84 -0
- package/src/lib/license-config.js +162 -0
- package/src/lib/license-store.js +107 -0
- package/src/lib/license.js +150 -0
- package/src/lib/lint-overrides.js +226 -0
- package/src/lib/logger.js +17 -0
- package/src/lib/payload.js +74 -0
- package/src/lib/project-config.js +145 -0
- package/src/lib/providers/polar.js +215 -0
- package/src/lib/rename-compat.js +50 -0
- package/src/lib/session-state.js +91 -0
- package/src/lib/trial-claim.js +65 -0
- 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
|
+
}
|