@thejoseki/clawform 0.0.1 → 2.2.0
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 +87 -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 +146 -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,85 @@
|
|
|
1
|
+
export const ENVS = ['d', 'stg', 'p'];
|
|
2
|
+
|
|
3
|
+
// The customer token is the widest variable slot in every generated name, and
|
|
4
|
+
// the tightest consumer is the 32-char cap AWS puts on load-balancer and
|
|
5
|
+
// target-group names. Eight characters is what that budget leaves once the
|
|
6
|
+
// longest env, the fixed infix, and a usable project shortname are accounted
|
|
7
|
+
// for — see the arithmetic in templates/alb.yaml. The archetypes'
|
|
8
|
+
// AllowedPattern and the init wizard both have to agree with this number, or
|
|
9
|
+
// the wizard writes a config that fails at deploy time.
|
|
10
|
+
export const CUSTOMER_MAX_LEN = 8;
|
|
11
|
+
export const CUSTOMER_RE = new RegExp(`^[a-z0-9]{1,${CUSTOMER_MAX_LEN}}$`);
|
|
12
|
+
|
|
13
|
+
// Catalog + inventory are generated from the consumer's own AWS account, so
|
|
14
|
+
// they belong in the consumer's project — not in the plugin checkout, where
|
|
15
|
+
// they would be shared across every project, wiped by `npm update`, and out of
|
|
16
|
+
// reach of the deploy-gate hook (which resolves them relative to the project).
|
|
17
|
+
// Dotted and single-rooted so a buyer gitignores one entry.
|
|
18
|
+
export const CATALOG_DIR_REL = '.clawform/catalog';
|
|
19
|
+
|
|
20
|
+
export const VISIBILITY_PREFIXES = ['pri', 'pub'];
|
|
21
|
+
|
|
22
|
+
// When adding: update rules/cfn-standards.md service-shortname table in the same change.
|
|
23
|
+
// Consumers can extend this list via `service_shortnames_extra` in clawform.config.json
|
|
24
|
+
// without forking the plugin.
|
|
25
|
+
export const SERVICE_SHORTNAMES = [
|
|
26
|
+
'vpc', 'snet', 'sg', 'rt', 'vpce', 'igw', 'nat', 'eip',
|
|
27
|
+
'alb', 'nlb', 'tg', 'lstn',
|
|
28
|
+
'ecsc', 'ecss', 'ecr',
|
|
29
|
+
'rds', 'aur', 'ddb', 'ecache',
|
|
30
|
+
's3', 'lmbd',
|
|
31
|
+
'sns', 'sqs', 'glue',
|
|
32
|
+
'cf', 'r53', 'acm', 'cgnp', 'wafa',
|
|
33
|
+
'sm', 'cwlg', 'cwa', 'bgt',
|
|
34
|
+
'iamr', 'kms', 'ec2',
|
|
35
|
+
'ccmt', 'cbld', 'cpl', 'cdpl',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// The legacy-prefix list moved out of the plugin and into the consumer's
|
|
39
|
+
// clawform.config.json (`legacy_prefixes`). See src/lib/project-config.js
|
|
40
|
+
// and docs/config-schema.md. A fresh consumer with no config has no legacy
|
|
41
|
+
// stacks, so the built-in default is an empty list — we don't impose Acme's
|
|
42
|
+
// LEGACY/OLDAPP freeze on someone else's account.
|
|
43
|
+
|
|
44
|
+
// Anchored to line-start to defeat prompt-injection via "FORCE LEGACY" inside narrative content.
|
|
45
|
+
export const LEGACY_OVERRIDE_PHRASE = /^\s*#\s*FORCE\s+LEGACY\b/m;
|
|
46
|
+
|
|
47
|
+
// Stateful CFN resource types per CLAUDE.md hard rule #4. Replace on any of these
|
|
48
|
+
// requires the data-resource gate in src/commands/deploy.js (user must retype stack name).
|
|
49
|
+
//
|
|
50
|
+
// IMPORTANT: list only types that actually hold data. The RDS/EFS/ElastiCache namespaces
|
|
51
|
+
// each contain config types (SubnetGroup, ParameterGroup, MountTarget, OptionGroup, ...)
|
|
52
|
+
// that hold no data — including them via a bare-prefix match would over-fire the safety
|
|
53
|
+
// gate on harmless config Replaces and train operators to ignore the warning. Add new
|
|
54
|
+
// stateful types here explicitly; don't reintroduce DATA_RESOURCE_PREFIXES.
|
|
55
|
+
export const DATA_RESOURCE_EXACT = [
|
|
56
|
+
// Cognito — a user pool holds every user account; a Replace repoints the
|
|
57
|
+
// export at a brand-new EMPTY pool. Treated as data per hard rule #4.
|
|
58
|
+
'AWS::Cognito::UserPool',
|
|
59
|
+
// S3 / DynamoDB / Logs / Secrets / KMS
|
|
60
|
+
'AWS::S3::Bucket',
|
|
61
|
+
'AWS::DynamoDB::Table',
|
|
62
|
+
'AWS::DynamoDB::GlobalTable',
|
|
63
|
+
'AWS::Logs::LogGroup',
|
|
64
|
+
'AWS::SecretsManager::Secret',
|
|
65
|
+
'AWS::KMS::Key',
|
|
66
|
+
// RDS — stateful only (DBInstance/Cluster + snapshots; NOT *Group, *Subscription, DBProxy)
|
|
67
|
+
'AWS::RDS::DBInstance',
|
|
68
|
+
'AWS::RDS::DBCluster',
|
|
69
|
+
'AWS::RDS::GlobalCluster',
|
|
70
|
+
'AWS::RDS::DBClusterSnapshot',
|
|
71
|
+
'AWS::RDS::DBSnapshot',
|
|
72
|
+
// EFS — stateful only (FileSystem; NOT MountTarget, AccessPoint, FileSystemPolicy)
|
|
73
|
+
'AWS::EFS::FileSystem',
|
|
74
|
+
// ElastiCache — "when persistent" per CLAUDE.md hard rule #4
|
|
75
|
+
// (NOT SubnetGroup, ParameterGroup, SecurityGroup, User, UserGroup)
|
|
76
|
+
'AWS::ElastiCache::CacheCluster',
|
|
77
|
+
'AWS::ElastiCache::ReplicationGroup',
|
|
78
|
+
'AWS::ElastiCache::GlobalReplicationGroup',
|
|
79
|
+
'AWS::ElastiCache::ServerlessCache',
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
export function isDataResource(resourceType) {
|
|
83
|
+
if (!resourceType) return false;
|
|
84
|
+
return DATA_RESOURCE_EXACT.includes(resourceType);
|
|
85
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Built-in defaults for Clawform's consumer config. Merged FIRST (lowest precedence)
|
|
2
|
+
// by src/lib/project-config.js. A fresh consumer (no clawform.config.json) sees these
|
|
3
|
+
// values; the dogfood clawform.config.json in this repo overlays the Acme-specific bits.
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_ENVS = ['d', 'stg', 'p'];
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_CONFIG = {
|
|
8
|
+
customer: '',
|
|
9
|
+
envs: DEFAULT_ENVS,
|
|
10
|
+
region: '',
|
|
11
|
+
accounts: {},
|
|
12
|
+
// Empty by default — we never impose Acme's legacy list on someone else's account.
|
|
13
|
+
legacy_prefixes: [],
|
|
14
|
+
service_shortnames_extra: [],
|
|
15
|
+
tags_defaults: {},
|
|
16
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Machine fingerprint for license device-binding. The hash is what Clawform
|
|
2
|
+
// sends to Polar as an activation instance label, so one physical machine maps
|
|
3
|
+
// to one activation slot (see docs — device limit is per human machine; CI uses
|
|
4
|
+
// key-validate-only and never activates).
|
|
5
|
+
//
|
|
6
|
+
// The impure gathering (gatherMachineParts) is split from the pure hasher
|
|
7
|
+
// (computeFingerprint) so the hash is unit-testable without touching the OS —
|
|
8
|
+
// same pattern the rest of src/lib uses (pure core, injected I/O).
|
|
9
|
+
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import { readFileSync as fsRead } from 'node:fs';
|
|
13
|
+
|
|
14
|
+
// Pure: given the raw identifying parts, return a stable short hex fingerprint.
|
|
15
|
+
// Order-fixed and NUL-delimited so two machines that merely share one field
|
|
16
|
+
// (e.g. platform) never collide, and so 'a'+'bc' can't alias 'ab'+'c'.
|
|
17
|
+
export function computeFingerprint({ hostname = '', platform = '', arch = '', machineId = '' } = {}) {
|
|
18
|
+
// '\u0000' as an escape, not a literal NUL byte — a raw control character in
|
|
19
|
+
// the source makes grep/diff treat the whole file as binary.
|
|
20
|
+
const material = [hostname, platform, arch, machineId].join('\u0000');
|
|
21
|
+
return createHash('sha256').update(material).digest('hex').slice(0, 32);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Impure: gather the parts from the OS. machineId is best-effort — a stable
|
|
25
|
+
// per-install ID when the platform exposes one cheaply, else '' (the
|
|
26
|
+
// hostname/platform/arch trio still yields a usable, if slightly weaker,
|
|
27
|
+
// fingerprint). Never throws: any probe failure degrades to ''.
|
|
28
|
+
export function gatherMachineParts({ readFileSync = fsRead, osImpl = os } = {}) {
|
|
29
|
+
return {
|
|
30
|
+
hostname: safe(() => osImpl.hostname()),
|
|
31
|
+
platform: safe(() => osImpl.platform()),
|
|
32
|
+
arch: safe(() => osImpl.arch()),
|
|
33
|
+
machineId: readMachineId(readFileSync, osImpl),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Convenience: gather + hash in one call. This is what `activate` uses.
|
|
38
|
+
export function machineFingerprint(deps = {}) {
|
|
39
|
+
return computeFingerprint(gatherMachineParts(deps));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readMachineId(readFileSync, osImpl) {
|
|
43
|
+
const platform = safe(() => osImpl.platform());
|
|
44
|
+
// Linux exposes a stable per-install ID via systemd/dbus — cheap to read.
|
|
45
|
+
if (platform === 'linux') {
|
|
46
|
+
for (const p of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
|
47
|
+
const v = safe(() => readFileSync(p, 'utf8').trim());
|
|
48
|
+
if (v) return v;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// macOS/Windows machine-id needs a subprocess (ioreg / registry query). To
|
|
52
|
+
// keep this layer dependency-free and side-effect-light, fall back to '' and
|
|
53
|
+
// lean on hostname+platform+arch — good enough for a device deterrent at this
|
|
54
|
+
// price point. Revisit with node-machine-id only if real collisions appear.
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function safe(fn) {
|
|
59
|
+
try {
|
|
60
|
+
const v = fn();
|
|
61
|
+
return typeof v === 'string' ? v : String(v ?? '');
|
|
62
|
+
} catch {
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Claude Code has no "workflows" plugin component type — saved workflow
|
|
5
|
+
// scripts are discovered only from a project's own .claude/workflows/ (or
|
|
6
|
+
// ~/.claude/workflows/), never from a plugin's shipped files. This copies
|
|
7
|
+
// the plugin-shipped workflows/audit-all-stacks.js into the CONSUMER
|
|
8
|
+
// project's .claude/workflows/ so `/audit-all-stacks` (or `/workflows`)
|
|
9
|
+
// actually finds it there. Idempotent: never overwrites an existing copy,
|
|
10
|
+
// so a consumer's local customization survives repeated `clawform sync` runs.
|
|
11
|
+
export function ensureWorkflowInstalled({
|
|
12
|
+
repoRoot,
|
|
13
|
+
projectDir,
|
|
14
|
+
exists = existsSync,
|
|
15
|
+
mkdir = mkdirSync,
|
|
16
|
+
copyFile = copyFileSync,
|
|
17
|
+
} = {}) {
|
|
18
|
+
const src = join(repoRoot, 'workflows', 'audit-all-stacks.js');
|
|
19
|
+
if (!exists(src)) return { installed: false, reason: 'source-missing' };
|
|
20
|
+
|
|
21
|
+
const destDir = join(projectDir, '.claude', 'workflows');
|
|
22
|
+
const dest = join(destDir, 'audit-all-stacks.js');
|
|
23
|
+
if (exists(dest)) return { installed: false, reason: 'already-present', dest };
|
|
24
|
+
|
|
25
|
+
mkdir(destDir, { recursive: true });
|
|
26
|
+
copyFile(src, dest);
|
|
27
|
+
return { installed: true, dest };
|
|
28
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Where the plugin content kit comes from.
|
|
2
|
+
//
|
|
3
|
+
// It used to come from the npm tarball, alongside the key that opened it —
|
|
4
|
+
// which meant `npm i` handed a non-buyer the entire library. The kit now lives
|
|
5
|
+
// behind a licence check: the service verifies the key with the vendor and
|
|
6
|
+
// streams the bundle over TLS, so no key ships in the CLI at all.
|
|
7
|
+
//
|
|
8
|
+
// Fetched once and cached. After that the plugin can be re-assembled or
|
|
9
|
+
// repaired with no network, which matters because `init plugin` is not
|
|
10
|
+
// something a buyer should have to be online to re-run.
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { unpackFiles } from './payload.js';
|
|
15
|
+
|
|
16
|
+
export const KIT_CACHE_FILENAME = 'kit.bin';
|
|
17
|
+
|
|
18
|
+
// Said on every service failure. Someone hitting this may have a stack stuck
|
|
19
|
+
// mid-update, and the first thing they need to know is that the emergency path
|
|
20
|
+
// is not what just broke. Those two commands are never licence-gated, and they
|
|
21
|
+
// do not touch this module.
|
|
22
|
+
const STILL_WORKS = 'clawform deploy and clawform rollback are unaffected and keep working.';
|
|
23
|
+
|
|
24
|
+
function outage(reason) {
|
|
25
|
+
return new Error(
|
|
26
|
+
`Could not reach the Clawform kit service (${reason}). This is our side, not your licence. `
|
|
27
|
+
+ `Try again shortly — ${STILL_WORKS}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function refusal(reason) {
|
|
32
|
+
return new Error(
|
|
33
|
+
`The kit service would not serve this licence (${reason}). `
|
|
34
|
+
+ 'Run `clawform license` to see what this machine holds, or contact support@thejoseki.com.',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function detailOf(resp) {
|
|
39
|
+
try {
|
|
40
|
+
const body = await resp.json();
|
|
41
|
+
return String(body?.detail ?? `HTTP ${resp.status}`);
|
|
42
|
+
} catch {
|
|
43
|
+
return `HTTP ${resp.status}`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the content kit, from cache or from the service.
|
|
49
|
+
* @returns {Promise<Array<{path: string, content: string}>>}
|
|
50
|
+
*/
|
|
51
|
+
export async function obtainKit({ licenseKey, serviceUrl, cacheDir, fetch: fetchImpl = fetch }) {
|
|
52
|
+
const cachePath = join(cacheDir, KIT_CACHE_FILENAME);
|
|
53
|
+
if (existsSync(cachePath)) return unpackFiles(readFileSync(cachePath));
|
|
54
|
+
|
|
55
|
+
let resp;
|
|
56
|
+
try {
|
|
57
|
+
resp = await fetchImpl(`${serviceUrl}/kit`, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'content-type': 'application/json' },
|
|
60
|
+
body: JSON.stringify({ license_key: licenseKey }),
|
|
61
|
+
});
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw outage(err?.message ?? 'network error');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A throttle or a 5xx is the service failing, not the licence being judged —
|
|
67
|
+
// the same distinction the vendor client draws, and for the same reason.
|
|
68
|
+
if (resp.status === 429 || resp.status >= 500) throw outage(`HTTP ${resp.status}`);
|
|
69
|
+
if (!resp.ok) throw refusal(await detailOf(resp));
|
|
70
|
+
|
|
71
|
+
const bundle = Buffer.from(await resp.arrayBuffer());
|
|
72
|
+
|
|
73
|
+
// Unpack BEFORE caching. A truncated or mismatched bundle written to disk
|
|
74
|
+
// would be served as a real kit on every later run, turning one bad response
|
|
75
|
+
// into a permanently broken install with no obvious cause.
|
|
76
|
+
const entries = unpackFiles(bundle);
|
|
77
|
+
|
|
78
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
79
|
+
writeFileSync(cachePath, bundle);
|
|
80
|
+
return entries;
|
|
81
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Vendor-agnostic license client seam. Every consumer talks to this contract:
|
|
2
|
+
//
|
|
3
|
+
// activate(key, fingerprint) → { status:'valid', instanceId, holder? }
|
|
4
|
+
// | { status:'invalid', reason } // limit_reached | expired | not_found
|
|
5
|
+
// | { status:'error', reason } // vendor unreachable
|
|
6
|
+
// validate(key, activationId?) → { status:'valid'|'invalid'|'error', reason? }
|
|
7
|
+
// deactivate(key, activationId)→ { status:'valid'|'invalid'|'error', reason? }
|
|
8
|
+
//
|
|
9
|
+
// resolveClient() picks the implementation from the license mode:
|
|
10
|
+
// 'off' (default) → a dormant stub (configured:false) — assertLicensed
|
|
11
|
+
// passes everything, so the repo's own dev/CI and every
|
|
12
|
+
// buyer before launch is unaffected.
|
|
13
|
+
// 'sandbox'/'live' → the real Polar adapter (configured:true) against the
|
|
14
|
+
// matching base URL.
|
|
15
|
+
//
|
|
16
|
+
// Flipping the shipped default to a live mode is the single reviewed decision
|
|
17
|
+
// that turns enforcement on (see license-config.js).
|
|
18
|
+
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
20
|
+
import { join, dirname } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { readEnv } from './rename-compat.js';
|
|
23
|
+
import { createPolarClient } from './providers/polar.js';
|
|
24
|
+
import {
|
|
25
|
+
LICENSE_MODE_DEFAULT,
|
|
26
|
+
DEV_MARKER,
|
|
27
|
+
POLAR_ORGANIZATION_ID,
|
|
28
|
+
POLAR_BENEFIT_ID,
|
|
29
|
+
POLAR_TRIAL_BENEFIT_ID,
|
|
30
|
+
POLAR_BASE_URL,
|
|
31
|
+
} from './license-config.js';
|
|
32
|
+
|
|
33
|
+
const DORMANT = {
|
|
34
|
+
configured: false,
|
|
35
|
+
async activate() { return { status: 'error', reason: 'license enforcement is off in this build' }; },
|
|
36
|
+
async validate() { return { status: 'error', reason: 'license enforcement is off in this build' }; },
|
|
37
|
+
async deactivate() { return { status: 'error', reason: 'license enforcement is off in this build' }; },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Resolved from THIS module's location, never from process.cwd(): with cwd, a
|
|
41
|
+
// buyer running clawform inside any repo that happens to carry the dev marker
|
|
42
|
+
// would be handed the dormant client — a bypass needing no edit at all.
|
|
43
|
+
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
44
|
+
|
|
45
|
+
export function isSourceCheckout(packageRoot = PACKAGE_ROOT) {
|
|
46
|
+
return existsSync(join(packageRoot, DEV_MARKER));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// `env` stays POSITIONAL: callers pass an env object, and folding it into an
|
|
50
|
+
// options bag would silently reinterpret every existing call as `{env:
|
|
51
|
+
// undefined}` — falling back to process.env and passing for the wrong reason.
|
|
52
|
+
export function resolveMode(env = process.env, { packageRoot = PACKAGE_ROOT } = {}) {
|
|
53
|
+
const fromEnv = readEnv('LICENSE_MODE', env);
|
|
54
|
+
// The environment may pick BETWEEN enforcing modes; it can never turn
|
|
55
|
+
// enforcement down. Anything else — 'off', a typo, an empty string — is
|
|
56
|
+
// ignored rather than treated as a request to disable.
|
|
57
|
+
if (fromEnv === 'sandbox' || fromEnv === 'live') return fromEnv;
|
|
58
|
+
if (isSourceCheckout(packageRoot)) return 'off';
|
|
59
|
+
return LICENSE_MODE_DEFAULT;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Which benefits are trials, for the mode this install is running in. Resolved
|
|
63
|
+
// here rather than in license-config because that is where mode resolution
|
|
64
|
+
// already lives, and a second copy of it would be free to disagree.
|
|
65
|
+
//
|
|
66
|
+
// A dormant install has no trials: nothing to claim, nothing to refuse.
|
|
67
|
+
export function resolveTrialBenefitIds({ env = process.env, packageRoot = PACKAGE_ROOT } = {}) {
|
|
68
|
+
const mode = resolveMode(env, { packageRoot });
|
|
69
|
+
return POLAR_TRIAL_BENEFIT_ID[mode] ?? [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resolveClient({ env = process.env, packageRoot = PACKAGE_ROOT, fetch } = {}) {
|
|
73
|
+
const mode = resolveMode(env, { packageRoot });
|
|
74
|
+
if (mode !== 'sandbox' && mode !== 'live') return DORMANT;
|
|
75
|
+
// Both looked up by the same mode, so they cannot drift apart: a base URL
|
|
76
|
+
// for one environment with an organization from the other is the failure
|
|
77
|
+
// this shape exists to make impossible.
|
|
78
|
+
return createPolarClient({
|
|
79
|
+
baseUrl: POLAR_BASE_URL[mode],
|
|
80
|
+
organizationId: POLAR_ORGANIZATION_ID[mode],
|
|
81
|
+
benefitIds: POLAR_BENEFIT_ID[mode],
|
|
82
|
+
...(fetch ? { fetch } : {}),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Licensing time policy. Vendor identifiers (org/product IDs) join this file
|
|
2
|
+
// once the payout test settles which vendor we ship with — the time policy is
|
|
3
|
+
// vendor-independent and the grace/revalidate logic only needs these two.
|
|
4
|
+
|
|
5
|
+
// How long a cached "valid" verdict is trusted before the CLI re-checks with
|
|
6
|
+
// the vendor in the background. Once per day keeps revocation reasonably fresh
|
|
7
|
+
// without turning every command into a network call.
|
|
8
|
+
export const REVALIDATE_HOURS = 24;
|
|
9
|
+
|
|
10
|
+
// How long a buyer keeps working after the LAST successful validation when the
|
|
11
|
+
// vendor cannot be reached (offline dev box, vendor outage). Two weeks: long
|
|
12
|
+
// enough to survive a vacation offline, short enough that a revoked-but-
|
|
13
|
+
// unreachable key does not run forever.
|
|
14
|
+
export const GRACE_DAYS = 14;
|
|
15
|
+
|
|
16
|
+
// --- Vendor: Polar -------------------------------------------------------
|
|
17
|
+
// The organization id is not a secret — it is a public parameter of every
|
|
18
|
+
// customer-portal call, and the API needs no server token besides the buyer's
|
|
19
|
+
// own key. Safe to ship in the tarball.
|
|
20
|
+
//
|
|
21
|
+
// Keyed by mode, and it has to be. Polar's sandbox is a separate system rather
|
|
22
|
+
// than a flag on the same one: you sign up for it separately, the organization
|
|
23
|
+
// you create there does not exist in production, and its id is different. A
|
|
24
|
+
// single id shared across both modes builds a client that talks to
|
|
25
|
+
// api.polar.sh while naming a sandbox organization — every activation by a
|
|
26
|
+
// real buyer fails, and it fails on the first day of selling rather than in
|
|
27
|
+
// any amount of sandbox testing. Paired with POLAR_BASE_URL below so the two
|
|
28
|
+
// can only be changed together.
|
|
29
|
+
export const POLAR_ORGANIZATION_ID = {
|
|
30
|
+
live: '75b98aba-8768-4534-9b76-dd7f16cba3d3',
|
|
31
|
+
sandbox: 'c234fc61-20e4-4d6f-b919-0f5823f7984d',
|
|
32
|
+
};
|
|
33
|
+
export const POLAR_BASE_URL = {
|
|
34
|
+
live: 'https://api.polar.sh',
|
|
35
|
+
sandbox: 'https://sandbox-api.polar.sh',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// The License Keys benefit this product sells. The organization is not enough
|
|
39
|
+
// to identify a key: every product under the same organization validates
|
|
40
|
+
// against the same endpoint with the same organization id, so the moment a
|
|
41
|
+
// second product sells from here, a key bought for THAT product activates this
|
|
42
|
+
// one. A paying customer of one tool silently unlocking another is not a bug
|
|
43
|
+
// anyone reports.
|
|
44
|
+
//
|
|
45
|
+
// Polar returns the benefit on both activate and validate, so the check costs
|
|
46
|
+
// nothing and closes it. Keyed by mode for the same reason the organization is
|
|
47
|
+
// — a sandbox benefit does not exist in production.
|
|
48
|
+
//
|
|
49
|
+
// A LIST per mode, one entry per paid tier. Polar carries limit_activations on
|
|
50
|
+
// the benefit, not the product, so tiers with different device caps cannot share
|
|
51
|
+
// one — the individual seat and the team seat are necessarily separate benefits.
|
|
52
|
+
// A key is accepted when it matches ANY entry; the tier it belongs to is Polar's
|
|
53
|
+
// business, not the CLI's.
|
|
54
|
+
//
|
|
55
|
+
// Nothing may appear in both lists: an id reachable from either environment
|
|
56
|
+
// would let a sandbox key unlock a live install.
|
|
57
|
+
export const POLAR_BENEFIT_ID = {
|
|
58
|
+
// [0] individual seat, [1] team seat. Order is not significance — a key is
|
|
59
|
+
// accepted on any match — but it is the order the walk tries, so the tier
|
|
60
|
+
// that sells most often belongs first.
|
|
61
|
+
live: [
|
|
62
|
+
'b85a9271-22ac-45b3-ab0a-b0c93df6b13c',
|
|
63
|
+
'be046860-7be4-43bd-ac8b-cf47d08f77d0',
|
|
64
|
+
'a3fc4e93-9e93-4317-bed5-5b44a5af9e19',
|
|
65
|
+
],
|
|
66
|
+
sandbox: [
|
|
67
|
+
'69d1ccaa-65c4-48da-ad4a-64961772c17e',
|
|
68
|
+
'a540043d-da17-48ae-bee6-e9a900fbe1cc',
|
|
69
|
+
'9c5fe82d-09a7-490c-91e3-47d164603423',
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Which of the benefits above are TRIALS. A trial key validates like any other
|
|
74
|
+
// — it has to appear in POLAR_BENEFIT_ID as well — but it additionally claims
|
|
75
|
+
// the machine, so one device cannot start a second trial under a new email.
|
|
76
|
+
//
|
|
77
|
+
// Listed separately rather than encoded in the entries above so phase 01's
|
|
78
|
+
// shape and its tests stay untouched. A test pins that every id here also
|
|
79
|
+
// appears in POLAR_BENEFIT_ID: an id in only one list is a trial key nobody can
|
|
80
|
+
// activate, or a paid key that silently burns a device claim.
|
|
81
|
+
//
|
|
82
|
+
// The licence-key benefit on each trial product must be VISIBLE in Polar: the
|
|
83
|
+
// buyer has to read the key out of the portal or the confirmation email to type
|
|
84
|
+
// it into `clawform activate`. A hidden benefit is granted and unusable.
|
|
85
|
+
export const POLAR_TRIAL_BENEFIT_ID = {
|
|
86
|
+
live: ['a3fc4e93-9e93-4317-bed5-5b44a5af9e19'],
|
|
87
|
+
sandbox: ['9c5fe82d-09a7-490c-91e3-47d164603423'],
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// --- Content kit delivery -----------------------------------------------
|
|
91
|
+
// The plugin content (skills/commands/agents/rules/templates) is NOT in the npm
|
|
92
|
+
// tarball. It is fetched by `clawform init plugin` from this service, which
|
|
93
|
+
// checks the licence with the vendor before serving anything.
|
|
94
|
+
//
|
|
95
|
+
// The previous model shipped the content as an encrypted blob with its key
|
|
96
|
+
// beside it in the same tarball — which protected nothing: `npm i` handed a
|
|
97
|
+
// non-buyer both halves. There is no key here now because the CLI never
|
|
98
|
+
// decrypts; the bundle lives in a private bucket and travels over TLS.
|
|
99
|
+
//
|
|
100
|
+
// Not keyed by licence mode. There is one kit, and which licences may have it
|
|
101
|
+
// is the service's judgement, not a build-time constant.
|
|
102
|
+
export const KIT_SERVICE_URL = 'https://kit.thejoseki.com';
|
|
103
|
+
|
|
104
|
+
// Written by scripts/pack-payload.js for upload, and cached under the vendor
|
|
105
|
+
// home after a successful fetch. Never present in the published package.
|
|
106
|
+
export const KIT_FILENAME = 'kit.bin';
|
|
107
|
+
|
|
108
|
+
// Signs the local licence record. The gate trusts an 'active' record with a
|
|
109
|
+
// fresh timestamp without calling the vendor, so an unsigned cache would make
|
|
110
|
+
// `echo '{"status":"active",…}' > ~/.clawform/license.json` a complete bypass —
|
|
111
|
+
// and a shareable one-liner at that. Signing raises it to "extract this secret
|
|
112
|
+
// from the CLI first", the same bar as the dev marker.
|
|
113
|
+
//
|
|
114
|
+
// Same 1a honesty as PAYLOAD_KEY_HEX: the secret ships with the code, so a
|
|
115
|
+
// determined attacker can still forge. The goal is killing the one-liner class.
|
|
116
|
+
// Distinct from the payload key on purpose — keys with different jobs.
|
|
117
|
+
//
|
|
118
|
+
// Rotates alongside the payload key, and for the same reason: a release that
|
|
119
|
+
// published this value hands every later release a forged 'active' record.
|
|
120
|
+
// The cost is that records signed with the old secret read as unlicensed, so a
|
|
121
|
+
// buyer re-activates — cheap now, and it only gets dearer with every seat sold.
|
|
122
|
+
export const LICENSE_RECORD_SECRET_HEX = 'c4179ff7c3e2ebd223fdb2653ed1163f5b8cb89c7900af2a88f186fbb55818e5';
|
|
123
|
+
|
|
124
|
+
// Tolerance for a machine clock running slightly ahead of the vendor's. Beyond
|
|
125
|
+
// it, a future `lastValidatedAt` is treated as needing revalidation rather than
|
|
126
|
+
// as an eternally fresh verdict.
|
|
127
|
+
export const CLOCK_SKEW_HOURS = 1;
|
|
128
|
+
|
|
129
|
+
// How a build tells whether it is a source checkout or an installed package.
|
|
130
|
+
// This file is the payload build script — `package.json` `files` never ships
|
|
131
|
+
// `scripts/`, so it is present in a clone and absent in `npm i -g @thejoseki/clawform`.
|
|
132
|
+
//
|
|
133
|
+
// Dormancy is granted by the install SHAPE, not by anything a buyer can set:
|
|
134
|
+
// a `CLAWFORM_LICENSE_MODE=off` that disabled the gate would be a
|
|
135
|
+
// copy-pasteable one-liner, the one bypass class that actually spreads.
|
|
136
|
+
// Faking dormancy now means creating this file inside the installed package —
|
|
137
|
+
// an edit, not a one-liner.
|
|
138
|
+
//
|
|
139
|
+
// ⚠️ Adding `scripts/` to `package.json` `files` would ship the marker and
|
|
140
|
+
// silently disable enforcement for everyone. A packaging test asserts it stays
|
|
141
|
+
// out of the tarball.
|
|
142
|
+
export const DEV_MARKER = 'scripts/pack-payload.js';
|
|
143
|
+
|
|
144
|
+
// The master switch, and the single decision that turns enforcement on.
|
|
145
|
+
//
|
|
146
|
+
// Held at 'off' through development, because enforcing before a key could be
|
|
147
|
+
// bought would have locked out every user with no way back in. It moved to
|
|
148
|
+
// 'live' only once all three preconditions were verified against the
|
|
149
|
+
// production vendor rather than assumed:
|
|
150
|
+
//
|
|
151
|
+
// - a Polar product exists with a License Keys benefit, activation limit 3
|
|
152
|
+
// - a real purchased key drives activate / validate / deactivate, the
|
|
153
|
+
// three-device cap refuses a fourth machine, and deactivate demonstrably
|
|
154
|
+
// frees a slot (proved by re-activating, not by the return code)
|
|
155
|
+
// - the sandbox and production environments reject each other's keys, which
|
|
156
|
+
// is what makes the per-mode organization and benefit ids load-bearing
|
|
157
|
+
//
|
|
158
|
+
// A source checkout stays dormant regardless: that exemption comes from the
|
|
159
|
+
// dev marker's presence, not from this value, so contributors need no key.
|
|
160
|
+
// Overridable per run via CLAWFORM_LICENSE_MODE, which can raise enforcement
|
|
161
|
+
// but never lower it.
|
|
162
|
+
export const LICENSE_MODE_DEFAULT = 'live';
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Local license cache — ~/.clawform/license.json. This is per-MACHINE / per-user
|
|
2
|
+
// state (unlike the per-project .clawform-state.json in session-state.js), so it
|
|
3
|
+
// lives in the home dir, not the project. It records what license enforcement
|
|
4
|
+
// needs between runs: the key, its Polar activation instance, when it was last
|
|
5
|
+
// validated, and who holds it. It never stores a Polar API secret — validation
|
|
6
|
+
// uses the customer's key alone, there is no server-side credential in the CLI.
|
|
7
|
+
//
|
|
8
|
+
// All reads fail-open: a missing or corrupt file yields an 'unlicensed' state,
|
|
9
|
+
// never an exception, so a bad cache can never crash a command (same discipline
|
|
10
|
+
// as session-state.js). Writes create ~/.clawform/ on demand.
|
|
11
|
+
//
|
|
12
|
+
// Stored record shape (all optional except v/status):
|
|
13
|
+
// { v, status: 'active'|'unlicensed', key, instanceId, holder,
|
|
14
|
+
// fingerprint, lastValidatedAt (ISO string) }
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
readFileSync as fsRead,
|
|
18
|
+
writeFileSync as fsWrite,
|
|
19
|
+
mkdirSync as fsMkdir,
|
|
20
|
+
rmSync as fsRm,
|
|
21
|
+
} from 'node:fs';
|
|
22
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
23
|
+
import { homedir as osHomedir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { LICENSE_RECORD_SECRET_HEX } from './license-config.js';
|
|
26
|
+
|
|
27
|
+
export const LICENSE_DIR = '.clawform';
|
|
28
|
+
export const LICENSE_FILENAME = 'license.json';
|
|
29
|
+
export const LICENSE_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
function licenseDir(home) { return join(home, LICENSE_DIR); }
|
|
32
|
+
function licensePath(home) { return join(licenseDir(home), LICENSE_FILENAME); }
|
|
33
|
+
|
|
34
|
+
function unlicensed() { return { v: LICENSE_VERSION, status: 'unlicensed' }; }
|
|
35
|
+
|
|
36
|
+
// Fields that a signature covers. Fixed order, so the digest depends on the
|
|
37
|
+
// content and not on the order the caller happened to build the object in.
|
|
38
|
+
//
|
|
39
|
+
// `expiresAt` is here for the same reason `lastValidatedAt` is: a trial's end
|
|
40
|
+
// date that a text editor can push out is not an end date. Adding a field
|
|
41
|
+
// changes every existing signature, so records written before this read as
|
|
42
|
+
// unlicensed and their owner re-activates once — cheap while the seat count is
|
|
43
|
+
// small, and dearer with every seat sold.
|
|
44
|
+
const SIGNED_FIELDS = ['v', 'status', 'key', 'instanceId', 'holder', 'fingerprint', 'lastValidatedAt', 'expiresAt'];
|
|
45
|
+
|
|
46
|
+
function signatureOf(record) {
|
|
47
|
+
const canonical = JSON.stringify(SIGNED_FIELDS.map((f) => record[f] ?? null));
|
|
48
|
+
return createHmac('sha256', Buffer.from(LICENSE_RECORD_SECRET_HEX, 'hex'))
|
|
49
|
+
.update(canonical)
|
|
50
|
+
.digest('hex');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Only an 'active' record makes a claim worth forging, so only that one has to
|
|
54
|
+
// prove itself. Timing-safe compare keeps the check from leaking the expected
|
|
55
|
+
// digest byte by byte.
|
|
56
|
+
function signatureValid(record) {
|
|
57
|
+
const expected = Buffer.from(signatureOf(record), 'utf8');
|
|
58
|
+
const actual = Buffer.from(String(record.sig ?? ''), 'utf8');
|
|
59
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Read the cached license. Fail-open on every error path: missing file, bad
|
|
63
|
+
// JSON, wrong version, or a shape that isn't an object → 'unlicensed'.
|
|
64
|
+
export function readLicense({ homedir = osHomedir, readFileSync = fsRead } = {}) {
|
|
65
|
+
let raw;
|
|
66
|
+
try {
|
|
67
|
+
raw = readFileSync(licensePath(homedir()), 'utf8');
|
|
68
|
+
} catch {
|
|
69
|
+
return unlicensed();
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(raw);
|
|
73
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return unlicensed();
|
|
74
|
+
if (parsed.v !== LICENSE_VERSION || typeof parsed.status !== 'string') return unlicensed();
|
|
75
|
+
// An unsigned or altered 'active' record is not a licence — it is exactly
|
|
76
|
+
// the forgery the signature exists to catch. 'unlicensed' claims nothing,
|
|
77
|
+
// so it needs no proof.
|
|
78
|
+
if (parsed.status === 'active' && !signatureValid(parsed)) return unlicensed();
|
|
79
|
+
return parsed;
|
|
80
|
+
} catch {
|
|
81
|
+
return unlicensed();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Persist a license record, creating ~/.clawform/ if needed. The version is
|
|
86
|
+
// always stamped by us, so callers pass only the payload fields.
|
|
87
|
+
export function writeLicense(record, {
|
|
88
|
+
homedir = osHomedir,
|
|
89
|
+
writeFileSync = fsWrite,
|
|
90
|
+
mkdirSync = fsMkdir,
|
|
91
|
+
} = {}) {
|
|
92
|
+
const home = homedir();
|
|
93
|
+
mkdirSync(licenseDir(home), { recursive: true });
|
|
94
|
+
const out = { ...record, v: LICENSE_VERSION };
|
|
95
|
+
out.sig = signatureOf(out);
|
|
96
|
+
writeFileSync(licensePath(home), JSON.stringify(out, null, 2));
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Remove the cache (used by `deactivate`). Fail-open: a missing file is success.
|
|
101
|
+
export function clearLicense({ homedir = osHomedir, rmSync = fsRm } = {}) {
|
|
102
|
+
try {
|
|
103
|
+
rmSync(licensePath(homedir()), { force: true });
|
|
104
|
+
} catch {
|
|
105
|
+
// fail-open — nothing to clear, or an unwritable path we can't help here.
|
|
106
|
+
}
|
|
107
|
+
}
|