@thejoseki/clawform 2.2.1 → 2.3.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/README.md +120 -101
- package/package.json +65 -63
- package/src/commands/activate.js +105 -105
- package/src/lib/license.js +150 -150
package/README.md
CHANGED
|
@@ -1,101 +1,120 @@
|
|
|
1
|
-
# Clawform
|
|
2
|
-
|
|
3
|
-
**Safe CloudFormation workflows for Claude Code.**
|
|
4
|
-
|
|
5
|
-

|
|
6
|
-
|
|
7
|
-
Claude writes CloudFormation fast — and just as confidently when it's wrong.
|
|
8
|
-
Clawform is the layer between the agent and your AWS account.
|
|
9
|
-
|
|
10
|
-
It gives Claude the conventions to read before it writes, so templates come out
|
|
11
|
-
named to your standard, tagged, and with retention set on every stateful
|
|
12
|
-
resource. Every AWS change then goes through the `clawform` CLI: create a
|
|
13
|
-
changeset, show you the diff, execute only after you confirm. Destructive
|
|
14
|
-
operations and the stacks you've marked frozen are refused.
|
|
15
|
-
|
|
16
|
-
CloudFormation only — by design. Not Terraform, not CDK, not Pulumi.
|
|
17
|
-
|
|
18
|
-
**Try it free for 14 days** — the full kit, email-only checkout, no card:
|
|
19
|
-
<https://clawform.thejoseki.com>. When the trial lapses, `deploy` and
|
|
20
|
-
`rollback` keep working; an expired licence never strands a stack.
|
|
21
|
-
|
|
22
|
-
## Quickstart (10 minutes)
|
|
23
|
-
|
|
24
|
-
```bash
|
|
25
|
-
# 1. Install the CLI (adds `clawform` to your PATH)
|
|
26
|
-
npm i -g @thejoseki/clawform
|
|
27
|
-
|
|
28
|
-
# 2. Activate your key — free 14-day trial or purchase, both from the site
|
|
29
|
-
clawform activate <your-license-key>
|
|
30
|
-
|
|
31
|
-
# 3. Install the Claude Code plugin — the CLI unpacks it from your activated copy
|
|
32
|
-
clawform init plugin # --global (default) or --project
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
Activation claims one of your seat's device slots; free one with
|
|
36
|
-
`clawform deactivate` when you retire a machine. **In CI, do not activate** —
|
|
37
|
-
set `CLAWFORM_LICENSE_KEY` in the environment instead, which validates per-run
|
|
38
|
-
without taking a slot.
|
|
39
|
-
|
|
40
|
-
Then, **in your own project**:
|
|
41
|
-
|
|
42
|
-
```bash
|
|
43
|
-
# 4. Create your config — customer, region, accounts, legacy prefixes
|
|
44
|
-
clawform init # interactive wizard, writes clawform.config.json
|
|
45
|
-
|
|
46
|
-
# 5. Point Clawform at your AWS profile (per-user, gitignored)
|
|
47
|
-
# .claude/settings.local.json → env block → AWS_PROFILE
|
|
48
|
-
|
|
49
|
-
# 6. Cache your account's stacks and exports (run before your first deploy,
|
|
50
|
-
# and after every successful one)
|
|
51
|
-
clawform sync
|
|
52
|
-
|
|
53
|
-
# 7. Open Claude Code and scaffold your first stack
|
|
54
|
-
claude
|
|
55
|
-
> /clawform:scaffold-project # propose architecture + deploy order
|
|
56
|
-
> /clawform:new-stack networking d
|
|
57
|
-
> /clawform:validate cfn/d-acme-networking.yaml
|
|
58
|
-
> /clawform:deploy cfn/d-acme-networking.yaml d
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
That's a real stack, deployed through a changeset you confirmed.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
1
|
+
# Clawform
|
|
2
|
+
|
|
3
|
+
**Safe CloudFormation workflows for Claude Code.**
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
Claude writes CloudFormation fast — and just as confidently when it's wrong.
|
|
8
|
+
Clawform is the layer between the agent and your AWS account.
|
|
9
|
+
|
|
10
|
+
It gives Claude the conventions to read before it writes, so templates come out
|
|
11
|
+
named to your standard, tagged, and with retention set on every stateful
|
|
12
|
+
resource. Every AWS change then goes through the `clawform` CLI: create a
|
|
13
|
+
changeset, show you the diff, execute only after you confirm. Destructive
|
|
14
|
+
operations and the stacks you've marked frozen are refused.
|
|
15
|
+
|
|
16
|
+
CloudFormation only — by design. Not Terraform, not CDK, not Pulumi.
|
|
17
|
+
|
|
18
|
+
**Try it free for 14 days** — the full kit, email-only checkout, no card:
|
|
19
|
+
<https://clawform.thejoseki.com/?utm_source=npm>. When the trial lapses, `deploy` and
|
|
20
|
+
`rollback` keep working; an expired licence never strands a stack.
|
|
21
|
+
|
|
22
|
+
## Quickstart (10 minutes)
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# 1. Install the CLI (adds `clawform` to your PATH)
|
|
26
|
+
npm i -g @thejoseki/clawform
|
|
27
|
+
|
|
28
|
+
# 2. Activate your key — free 14-day trial or purchase, both from the site
|
|
29
|
+
clawform activate <your-license-key>
|
|
30
|
+
|
|
31
|
+
# 3. Install the Claude Code plugin — the CLI unpacks it from your activated copy
|
|
32
|
+
clawform init plugin # --global (default) or --project
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Activation claims one of your seat's device slots; free one with
|
|
36
|
+
`clawform deactivate` when you retire a machine. **In CI, do not activate** —
|
|
37
|
+
set `CLAWFORM_LICENSE_KEY` in the environment instead, which validates per-run
|
|
38
|
+
without taking a slot.
|
|
39
|
+
|
|
40
|
+
Then, **in your own project**:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# 4. Create your config — customer, region, accounts, legacy prefixes
|
|
44
|
+
clawform init # interactive wizard, writes clawform.config.json
|
|
45
|
+
|
|
46
|
+
# 5. Point Clawform at your AWS profile (per-user, gitignored)
|
|
47
|
+
# .claude/settings.local.json → env block → AWS_PROFILE
|
|
48
|
+
|
|
49
|
+
# 6. Cache your account's stacks and exports (run before your first deploy,
|
|
50
|
+
# and after every successful one)
|
|
51
|
+
clawform sync
|
|
52
|
+
|
|
53
|
+
# 7. Open Claude Code and scaffold your first stack
|
|
54
|
+
claude
|
|
55
|
+
> /clawform:scaffold-project # propose architecture + deploy order
|
|
56
|
+
> /clawform:new-stack networking d
|
|
57
|
+
> /clawform:validate cfn/d-acme-networking.yaml
|
|
58
|
+
> /clawform:deploy cfn/d-acme-networking.yaml d
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
That's a real stack, deployed through a changeset you confirmed.
|
|
62
|
+
|
|
63
|
+
### Don't know which services you need?
|
|
64
|
+
|
|
65
|
+
`/clawform:scaffold-project` detects that case and asks a different set of questions —
|
|
66
|
+
about consequences rather than services. *"If this were down for two hours on a Sunday
|
|
67
|
+
night, would anyone lose money?"* rather than *"Fargate or Lambda?"* Every question has an
|
|
68
|
+
**"I'm not sure"** option that states the default it applies and what that default costs.
|
|
69
|
+
|
|
70
|
+
Your monthly spending ceiling is the first question, and it eliminates candidate
|
|
71
|
+
architectures **before** any design work — so you never spend half an hour agreeing to
|
|
72
|
+
something you cannot pay for. Nothing is priced from memory: figures come from the AWS
|
|
73
|
+
Pricing API, and if one cannot be fetched you are told so instead of being given a guess.
|
|
74
|
+
|
|
75
|
+
The interview runs in at most three rounds and its answers are written to
|
|
76
|
+
`docs/scaffold/*.json`, so an interrupted session resumes where it stopped rather than
|
|
77
|
+
starting over. **Those files are committed** — that is what makes the design reviewable in
|
|
78
|
+
a pull request — and `requirements.json` contains your spending ceiling and a
|
|
79
|
+
plain-language description of what you are building. On a public repository, decide
|
|
80
|
+
whether you want that before you push.
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
Clawform reads two files. Neither hardcodes anything about your organization.
|
|
85
|
+
|
|
86
|
+
| File | Scope | Committed? | Holds |
|
|
87
|
+
|---|---|---|---|
|
|
88
|
+
| `clawform.config.json` | per-project | yes | customer, region, accounts, `legacy_prefixes` |
|
|
89
|
+
| `.claude/settings.local.json` | per-user | no (gitignored) | `AWS_PROFILE`, owner/cost-center overrides |
|
|
90
|
+
|
|
91
|
+
`clawform init` writes the first one. For CI or scripted bootstrap, set
|
|
92
|
+
`CLAWFORM_INIT_CUSTOMER=… CLAWFORM_INIT_REGION=… clawform init`.
|
|
93
|
+
|
|
94
|
+
A fresh project with an empty `legacy_prefixes` has no legacy-freeze gate — that
|
|
95
|
+
is correct. Clawform never imposes another project's freeze list on you.
|
|
96
|
+
|
|
97
|
+
## Requirements
|
|
98
|
+
|
|
99
|
+
- Node.js ≥ 20 LTS
|
|
100
|
+
- AWS CLI v2
|
|
101
|
+
- Claude Code CLI
|
|
102
|
+
- *(recommended)* `cfn-lint` — `pip install cfn-lint`
|
|
103
|
+
- *(optional)* `cfn-guard` 3.x for Clawform's custom rules
|
|
104
|
+
|
|
105
|
+
## Links
|
|
106
|
+
|
|
107
|
+
- **[clawform.thejoseki.com](https://clawform.thejoseki.com)** — the product
|
|
108
|
+
page: what Clawform does, per-seat pricing, and how to get a license.
|
|
109
|
+
- **[thejoseki.com](https://thejoseki.com)** — Joseki, the vendor behind
|
|
110
|
+
Clawform. Known-good moves for agentic work.
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
Commercial, per-seat. One seat per developer; no resale or redistribution.
|
|
115
|
+
Provided **as is**, with no warranty and no liability for AWS charges — you are
|
|
116
|
+
responsible for reviewing every change before it deploys and for your own cloud
|
|
117
|
+
spend. Full terms in the `LICENSE` file.
|
|
118
|
+
|
|
119
|
+
Clawform is not affiliated with Amazon Web Services, Inc. "AWS" and
|
|
120
|
+
"CloudFormation" are trademarks of Amazon.com, Inc. or its affiliates.
|
package/package.json
CHANGED
|
@@ -1,63 +1,65 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@thejoseki/clawform",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Clawform — safe CloudFormation workflows for Claude Code",
|
|
5
|
-
"homepage": "https://clawform.thejoseki.com",
|
|
6
|
-
"type": "module",
|
|
7
|
-
"license": "SEE LICENSE IN LICENSE",
|
|
8
|
-
"author": "Joseki <support@thejoseki.com>",
|
|
9
|
-
"publishConfig": {
|
|
10
|
-
"access": "public"
|
|
11
|
-
},
|
|
12
|
-
"bin": {
|
|
13
|
-
"clawform": "bin/clawform.js"
|
|
14
|
-
},
|
|
15
|
-
"engines": {
|
|
16
|
-
"node": ">=20.10"
|
|
17
|
-
},
|
|
18
|
-
"scripts": {
|
|
19
|
-
"test": "vitest run",
|
|
20
|
-
"test:watch": "vitest",
|
|
21
|
-
"test:eval": "vitest run tests/eval",
|
|
22
|
-
"lint": "node scripts/lint-content.js",
|
|
23
|
-
"package": "node scripts/package-plugin.js && node scripts/pack-payload.js",
|
|
24
|
-
"kit:build": "node scripts/package-plugin.js && node scripts/build-kit-worker.js",
|
|
25
|
-
"kit:deploy": "npm run kit:build && npm --prefix services/kit-worker run deploy",
|
|
26
|
-
"prepublishOnly": "node scripts/check-release-mode.js && npm run package",
|
|
27
|
-
"clean-room": "node scripts/clean-room.js",
|
|
28
|
-
"e2e": "node scripts/e2e-licensed-install.js",
|
|
29
|
-
"e2e:trial": "node scripts/e2e-trial.js"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"@aws-sdk/client-
|
|
34
|
-
"@aws-sdk/client-
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@thejoseki/clawform",
|
|
3
|
+
"version": "2.3.1",
|
|
4
|
+
"description": "Clawform — safe CloudFormation workflows for Claude Code",
|
|
5
|
+
"homepage": "https://clawform.thejoseki.com",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
8
|
+
"author": "Joseki <support@thejoseki.com>",
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"clawform": "bin/clawform.js"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20.10"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest",
|
|
21
|
+
"test:eval": "vitest run tests/eval",
|
|
22
|
+
"lint": "node scripts/lint-content.js",
|
|
23
|
+
"package": "node scripts/package-plugin.js && node scripts/pack-payload.js",
|
|
24
|
+
"kit:build": "node scripts/package-plugin.js && node scripts/build-kit-worker.js",
|
|
25
|
+
"kit:deploy": "npm run kit:build && npm --prefix services/kit-worker run deploy",
|
|
26
|
+
"prepublishOnly": "node scripts/check-release-mode.js && npm run package",
|
|
27
|
+
"clean-room": "node scripts/clean-room.js",
|
|
28
|
+
"e2e": "node scripts/e2e-licensed-install.js",
|
|
29
|
+
"e2e:trial": "node scripts/e2e-trial.js",
|
|
30
|
+
"test:coverage": "vitest run --coverage --coverage.reporter=text-summary --coverage.include=\"**/*.js\" --coverage.exclude=\"tests/**\" --coverage.exclude=\"skills/**\""
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@aws-sdk/client-cloudformation": "^3.700.0",
|
|
34
|
+
"@aws-sdk/client-cost-explorer": "^3.1066.0",
|
|
35
|
+
"@aws-sdk/client-sts": "^3.700.0",
|
|
36
|
+
"chalk": "^5.3.0",
|
|
37
|
+
"commander": "^12.1.0",
|
|
38
|
+
"diff": "^7.0.0",
|
|
39
|
+
"execa": "^9.5.0",
|
|
40
|
+
"inquirer": "^12.0.0",
|
|
41
|
+
"js-yaml": "^4.1.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@vitest/coverage-v8": "^2.1.9",
|
|
45
|
+
"vitest": "^2.1.9"
|
|
46
|
+
},
|
|
47
|
+
"files": [
|
|
48
|
+
"bin/",
|
|
49
|
+
"src/"
|
|
50
|
+
],
|
|
51
|
+
"keywords": [
|
|
52
|
+
"claude-code",
|
|
53
|
+
"claude",
|
|
54
|
+
"cloudformation",
|
|
55
|
+
"aws",
|
|
56
|
+
"iac",
|
|
57
|
+
"infrastructure-as-code",
|
|
58
|
+
"aws-cdk-alternative",
|
|
59
|
+
"changeset",
|
|
60
|
+
"cfn-lint",
|
|
61
|
+
"devops",
|
|
62
|
+
"guardrails",
|
|
63
|
+
"ai-agent"
|
|
64
|
+
]
|
|
65
|
+
}
|
package/src/commands/activate.js
CHANGED
|
@@ -1,105 +1,105 @@
|
|
|
1
|
-
// clawform activate <key> — claim one activation slot for this machine and
|
|
2
|
-
// cache the verdict locally. All I/O seams are injectable for the tests; the
|
|
3
|
-
// CLI passes nothing and gets the real store, fingerprint and vendor client.
|
|
4
|
-
|
|
5
|
-
import { logger } from '../lib/logger.js';
|
|
6
|
-
import { resolveClient, resolveTrialBenefitIds } from '../lib/license-client.js';
|
|
7
|
-
import { writeLicense } from '../lib/license-store.js';
|
|
8
|
-
import { machineFingerprint } from '../lib/fingerprint.js';
|
|
9
|
-
import { claimTrialDevice } from '../lib/trial-claim.js';
|
|
10
|
-
|
|
11
|
-
const maskKey = (key) => `…${String(key).slice(-4)}`;
|
|
12
|
-
|
|
13
|
-
export default async function activate({ key } = {}, {
|
|
14
|
-
client = resolveClient(),
|
|
15
|
-
write = writeLicense,
|
|
16
|
-
fingerprint = machineFingerprint,
|
|
17
|
-
trialBenefitIds = resolveTrialBenefitIds(),
|
|
18
|
-
claimTrial = claimTrialDevice,
|
|
19
|
-
now = Date.now,
|
|
20
|
-
log = (m) => logger.ok(m),
|
|
21
|
-
} = {}) {
|
|
22
|
-
if (!key || typeof key !== 'string') {
|
|
23
|
-
throw new Error('A license key is required: clawform activate <key> (it is in your purchase email).');
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const fp = fingerprint();
|
|
27
|
-
|
|
28
|
-
// Which tier this key belongs to decides whether our own service is consulted
|
|
29
|
-
// at all. A PAID activation must never depend on it: someone who has just
|
|
30
|
-
// paid, blocked by our downtime, is the failure the deploy/rollback invariant
|
|
31
|
-
// exists to prevent — moved to the worst possible moment.
|
|
32
|
-
//
|
|
33
|
-
// Costs one extra validate call. That is a once-per-machine command, and the
|
|
34
|
-
// alternative is threading a callback through the vendor adapter so the
|
|
35
|
-
// branch can happen mid-activation.
|
|
36
|
-
let pre = null;
|
|
37
|
-
try { pre = await client.validate(key); } catch { /* fall through to activate, which reports it */ }
|
|
38
|
-
|
|
39
|
-
if (pre?.status === 'valid' && trialBenefitIds.includes(pre.benefitId)) {
|
|
40
|
-
// Claim BEFORE the slot is taken. A device refused after activation would
|
|
41
|
-
// keep the activation it had already spent.
|
|
42
|
-
//
|
|
43
|
-
// Fail-open on any error: our downtime must cost a lead, never a purchase.
|
|
44
|
-
// The window allows 14 more days of the cheap CLI commands — a far smaller
|
|
45
|
-
// loss than a would-be buyer who hit an error once and never came back.
|
|
46
|
-
let claim = { allowed: true };
|
|
47
|
-
try { claim = await claimTrial({ licenseKey: key, fingerprint: fp }); } catch { /* fail open */ }
|
|
48
|
-
|
|
49
|
-
if (claim && claim.allowed === false) {
|
|
50
|
-
throw new Error(
|
|
51
|
-
'This machine has already used a Clawform trial, so a second one cannot start here. '
|
|
52
|
-
+ 'Buy a seat at https://clawform.thejoseki.com — or, if you believe this is a mistake, '
|
|
53
|
-
+ 'write to support@thejoseki.com and it will be sorted out by a person.',
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Hand the walk we already paid for to the adapter, so it does not repeat it.
|
|
59
|
-
// Without this one command made about ten Polar calls and got rate-limited.
|
|
60
|
-
const res = await client.activate(key, fp, pre?.status === 'valid' ? { validated: pre } : {});
|
|
61
|
-
|
|
62
|
-
if (res.status === 'valid') {
|
|
63
|
-
write({
|
|
64
|
-
status: 'active',
|
|
65
|
-
key,
|
|
66
|
-
instanceId: res.instanceId,
|
|
67
|
-
holder: res.holder,
|
|
68
|
-
fingerprint: fp,
|
|
69
|
-
lastValidatedAt: new Date(now()).toISOString(),
|
|
70
|
-
// Only when the vendor gave one. A perpetual seat carrying
|
|
71
|
-
// `expiresAt: undefined` would read as "expired at the epoch" to the
|
|
72
|
-
// gate's date arithmetic — every paying customer refused on day one.
|
|
73
|
-
...(res.expiresAt ? { expiresAt: res.expiresAt } : {}),
|
|
74
|
-
});
|
|
75
|
-
log(`License ${maskKey(key)} activated on this machine${res.holder ? ` for ${res.holder}` : ''}.`);
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (res.status === 'invalid') {
|
|
80
|
-
if (res.reason === 'limit_reached') {
|
|
81
|
-
throw new Error(
|
|
82
|
-
'This key has used all of its activation slots. Free one with `clawform deactivate` on a ' +
|
|
83
|
-
'machine you no longer use — CI should use the CLAWFORM_LICENSE_KEY env var, which never ' +
|
|
84
|
-
'takes a slot.',
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
throw new Error(`The license service rejected this key (${res.reason ?? 'invalid'}). Check for typos in the key from your purchase email.`);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Throttling reads as unreachable to the generic branch below, and its
|
|
91
|
-
// advice — "try again once you are online" — is false for someone who is
|
|
92
|
-
// online and whose own retries caused this. Say what happened instead.
|
|
93
|
-
if (res.reason === 'rate_limited') {
|
|
94
|
-
throw new Error(
|
|
95
|
-
'The license service is rate-limiting this key. Nothing was activated. ' +
|
|
96
|
-
'Wait about a minute and run the same command again — repeating it immediately ' +
|
|
97
|
-
'extends the limit rather than clearing it.',
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
throw new Error(
|
|
102
|
-
`Could not reach the license service (${res.reason ?? 'network error'}). ` +
|
|
103
|
-
'Nothing was activated — try again once you are online.',
|
|
104
|
-
);
|
|
105
|
-
}
|
|
1
|
+
// clawform activate <key> — claim one activation slot for this machine and
|
|
2
|
+
// cache the verdict locally. All I/O seams are injectable for the tests; the
|
|
3
|
+
// CLI passes nothing and gets the real store, fingerprint and vendor client.
|
|
4
|
+
|
|
5
|
+
import { logger } from '../lib/logger.js';
|
|
6
|
+
import { resolveClient, resolveTrialBenefitIds } from '../lib/license-client.js';
|
|
7
|
+
import { writeLicense } from '../lib/license-store.js';
|
|
8
|
+
import { machineFingerprint } from '../lib/fingerprint.js';
|
|
9
|
+
import { claimTrialDevice } from '../lib/trial-claim.js';
|
|
10
|
+
|
|
11
|
+
const maskKey = (key) => `…${String(key).slice(-4)}`;
|
|
12
|
+
|
|
13
|
+
export default async function activate({ key } = {}, {
|
|
14
|
+
client = resolveClient(),
|
|
15
|
+
write = writeLicense,
|
|
16
|
+
fingerprint = machineFingerprint,
|
|
17
|
+
trialBenefitIds = resolveTrialBenefitIds(),
|
|
18
|
+
claimTrial = claimTrialDevice,
|
|
19
|
+
now = Date.now,
|
|
20
|
+
log = (m) => logger.ok(m),
|
|
21
|
+
} = {}) {
|
|
22
|
+
if (!key || typeof key !== 'string') {
|
|
23
|
+
throw new Error('A license key is required: clawform activate <key> (it is in your purchase email).');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const fp = fingerprint();
|
|
27
|
+
|
|
28
|
+
// Which tier this key belongs to decides whether our own service is consulted
|
|
29
|
+
// at all. A PAID activation must never depend on it: someone who has just
|
|
30
|
+
// paid, blocked by our downtime, is the failure the deploy/rollback invariant
|
|
31
|
+
// exists to prevent — moved to the worst possible moment.
|
|
32
|
+
//
|
|
33
|
+
// Costs one extra validate call. That is a once-per-machine command, and the
|
|
34
|
+
// alternative is threading a callback through the vendor adapter so the
|
|
35
|
+
// branch can happen mid-activation.
|
|
36
|
+
let pre = null;
|
|
37
|
+
try { pre = await client.validate(key); } catch { /* fall through to activate, which reports it */ }
|
|
38
|
+
|
|
39
|
+
if (pre?.status === 'valid' && trialBenefitIds.includes(pre.benefitId)) {
|
|
40
|
+
// Claim BEFORE the slot is taken. A device refused after activation would
|
|
41
|
+
// keep the activation it had already spent.
|
|
42
|
+
//
|
|
43
|
+
// Fail-open on any error: our downtime must cost a lead, never a purchase.
|
|
44
|
+
// The window allows 14 more days of the cheap CLI commands — a far smaller
|
|
45
|
+
// loss than a would-be buyer who hit an error once and never came back.
|
|
46
|
+
let claim = { allowed: true };
|
|
47
|
+
try { claim = await claimTrial({ licenseKey: key, fingerprint: fp }); } catch { /* fail open */ }
|
|
48
|
+
|
|
49
|
+
if (claim && claim.allowed === false) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
'This machine has already used a Clawform trial, so a second one cannot start here. '
|
|
52
|
+
+ 'Buy a seat at https://clawform.thejoseki.com/?utm_source=cli — or, if you believe this is a mistake, '
|
|
53
|
+
+ 'write to support@thejoseki.com and it will be sorted out by a person.',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Hand the walk we already paid for to the adapter, so it does not repeat it.
|
|
59
|
+
// Without this one command made about ten Polar calls and got rate-limited.
|
|
60
|
+
const res = await client.activate(key, fp, pre?.status === 'valid' ? { validated: pre } : {});
|
|
61
|
+
|
|
62
|
+
if (res.status === 'valid') {
|
|
63
|
+
write({
|
|
64
|
+
status: 'active',
|
|
65
|
+
key,
|
|
66
|
+
instanceId: res.instanceId,
|
|
67
|
+
holder: res.holder,
|
|
68
|
+
fingerprint: fp,
|
|
69
|
+
lastValidatedAt: new Date(now()).toISOString(),
|
|
70
|
+
// Only when the vendor gave one. A perpetual seat carrying
|
|
71
|
+
// `expiresAt: undefined` would read as "expired at the epoch" to the
|
|
72
|
+
// gate's date arithmetic — every paying customer refused on day one.
|
|
73
|
+
...(res.expiresAt ? { expiresAt: res.expiresAt } : {}),
|
|
74
|
+
});
|
|
75
|
+
log(`License ${maskKey(key)} activated on this machine${res.holder ? ` for ${res.holder}` : ''}.`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (res.status === 'invalid') {
|
|
80
|
+
if (res.reason === 'limit_reached') {
|
|
81
|
+
throw new Error(
|
|
82
|
+
'This key has used all of its activation slots. Free one with `clawform deactivate` on a ' +
|
|
83
|
+
'machine you no longer use — CI should use the CLAWFORM_LICENSE_KEY env var, which never ' +
|
|
84
|
+
'takes a slot.',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`The license service rejected this key (${res.reason ?? 'invalid'}). Check for typos in the key from your purchase email.`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Throttling reads as unreachable to the generic branch below, and its
|
|
91
|
+
// advice — "try again once you are online" — is false for someone who is
|
|
92
|
+
// online and whose own retries caused this. Say what happened instead.
|
|
93
|
+
if (res.reason === 'rate_limited') {
|
|
94
|
+
throw new Error(
|
|
95
|
+
'The license service is rate-limiting this key. Nothing was activated. ' +
|
|
96
|
+
'Wait about a minute and run the same command again — repeating it immediately ' +
|
|
97
|
+
'extends the limit rather than clearing it.',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Could not reach the license service (${res.reason ?? 'network error'}). ` +
|
|
103
|
+
'Nothing was activated — try again once you are online.',
|
|
104
|
+
);
|
|
105
|
+
}
|
package/src/lib/license.js
CHANGED
|
@@ -1,150 +1,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
|
-
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. ' +
|
|
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 and run '
|
|
100
|
-
+ '`clawform activate <key>`. Note that `clawform deploy` and `clawform rollback` keep '
|
|
101
|
-
+ 'working either way — an 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
|
+
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 way — an 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
|
+
}
|