@thejoseki/clawform 2.3.0 โ 2.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -120
- package/bin/clawform.js +161 -151
- package/package.json +65 -65
- package/src/aws/catalog.js +116 -116
- package/src/aws/changeset.js +231 -231
- package/src/aws/drift.js +59 -59
- package/src/aws/exports.js +213 -213
- package/src/aws/inventory.js +156 -156
- package/src/commands/activate.js +34 -2
- package/src/commands/ci-init.js +393 -212
- package/src/commands/cost-report.js +163 -163
- package/src/commands/deactivate.js +9 -2
- package/src/commands/deploy.js +413 -413
- package/src/commands/diff.js +39 -39
- package/src/commands/drift.js +35 -35
- package/src/commands/init-plugin.js +6 -6
- package/src/commands/init.js +360 -360
- package/src/commands/rollback.js +126 -126
- package/src/commands/status.js +147 -147
- package/src/commands/sync.js +50 -50
- package/src/commands/update.js +24 -0
- package/src/commands/validate.js +349 -349
- package/src/hooks/block-dangerous.js +62 -62
- package/src/hooks/cfn-lint-after-edit-eval.js +30 -30
- package/src/hooks/cfn-lint-after-edit.js +81 -81
- package/src/hooks/check-catalog-fresh-eval.js +63 -63
- package/src/hooks/check-catalog-fresh.js +33 -33
- package/src/hooks/session-context-eval.js +81 -81
- package/src/hooks/session-context.js +41 -41
- package/src/hooks/subagent-context-eval.js +44 -44
- package/src/hooks/subagent-context.js +48 -48
- package/src/lib/audit-synthesis.js +24 -24
- package/src/lib/aws-client.js +15 -15
- package/src/lib/cfn-yaml.js +92 -92
- package/src/lib/config.js +76 -76
- package/src/lib/constants.js +85 -85
- package/src/lib/defaults.js +16 -16
- package/src/lib/install-workflow.js +28 -28
- package/src/lib/kit-source.js +43 -5
- package/src/lib/license-client.js +84 -84
- package/src/lib/license-config.js +162 -162
- package/src/lib/license-store.js +43 -8
- package/src/lib/license.js +15 -2
- package/src/lib/lint-overrides.js +226 -226
- package/src/lib/logger.js +16 -16
- package/src/lib/project-config.js +145 -145
- package/src/lib/providers/polar.js +215 -215
- package/src/lib/session-state.js +91 -91
package/src/aws/inventory.js
CHANGED
|
@@ -1,156 +1,156 @@
|
|
|
1
|
-
import { paginateListStacks } from '@aws-sdk/client-cloudformation';
|
|
2
|
-
|
|
3
|
-
export const STALE_DAYS = 180;
|
|
4
|
-
|
|
5
|
-
const LIVE_STATUSES = [
|
|
6
|
-
'CREATE_COMPLETE',
|
|
7
|
-
'UPDATE_COMPLETE',
|
|
8
|
-
'UPDATE_ROLLBACK_COMPLETE',
|
|
9
|
-
'IMPORT_COMPLETE',
|
|
10
|
-
];
|
|
11
|
-
|
|
12
|
-
const hasLegacyPrefix = (name, prefixes) => prefixes.some((p) => name.startsWith(p + '-'));
|
|
13
|
-
|
|
14
|
-
const SERVERLESS_DESC_RE = /Serverless application/i;
|
|
15
|
-
|
|
16
|
-
export async function fetchStacks(client) {
|
|
17
|
-
const stacks = [];
|
|
18
|
-
for await (const page of paginateListStacks(
|
|
19
|
-
{ client },
|
|
20
|
-
{ StackStatusFilter: LIVE_STATUSES },
|
|
21
|
-
)) {
|
|
22
|
-
for (const s of page.StackSummaries ?? []) {
|
|
23
|
-
stacks.push({
|
|
24
|
-
name: s.StackName,
|
|
25
|
-
status: s.StackStatus,
|
|
26
|
-
templateDescription: s.TemplateDescription,
|
|
27
|
-
creationTime: s.CreationTime,
|
|
28
|
-
lastUpdatedTime: s.LastUpdatedTime,
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return stacks;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function classifyStacks(stacks, now = new Date(), legacyPrefixes = []) {
|
|
36
|
-
// legacyPrefixes comes from the consumer's clawform.config.json. Empty = a fresh
|
|
37
|
-
// consumer with no legacy stacks; nothing gets routed to the `legacy` bucket.
|
|
38
|
-
const result = { legacy: [], serverless: [], active: [], stale: [] };
|
|
39
|
-
const staleMs = STALE_DAYS * 24 * 60 * 60 * 1000;
|
|
40
|
-
for (const s of stacks) {
|
|
41
|
-
if (hasLegacyPrefix(s.name, legacyPrefixes)) {
|
|
42
|
-
result.legacy.push(s);
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
if (SERVERLESS_DESC_RE.test(s.templateDescription ?? '')) {
|
|
46
|
-
result.serverless.push(s);
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
const lastTouch = s.lastUpdatedTime ?? s.creationTime;
|
|
50
|
-
const ageMs = lastTouch ? now.getTime() - new Date(lastTouch).getTime() : 0;
|
|
51
|
-
if (ageMs > staleMs) result.stale.push(s);
|
|
52
|
-
else result.active.push(s);
|
|
53
|
-
}
|
|
54
|
-
return result;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function fmtDate(d) {
|
|
58
|
-
if (!d) return '';
|
|
59
|
-
return new Date(d).toISOString().slice(0, 10);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function escapePipe(s) {
|
|
63
|
-
return String(s ?? '').replace(/\|/g, '\\|');
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function renderSection(lines, title, items, headers, fmtRow) {
|
|
67
|
-
lines.push(`## ${title} (${items.length})`, '');
|
|
68
|
-
if (items.length === 0) {
|
|
69
|
-
lines.push('_None_', '');
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
lines.push('| ' + headers.join(' | ') + ' |');
|
|
73
|
-
lines.push('|' + headers.map(() => '---').join('|') + '|');
|
|
74
|
-
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
|
|
75
|
-
for (const it of sorted) {
|
|
76
|
-
lines.push('| ' + fmtRow(it).map((v) => `\`${escapePipe(v)}\``).join(' | ') + ' |');
|
|
77
|
-
}
|
|
78
|
-
lines.push('');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function renderInventory(classified, meta) {
|
|
82
|
-
const lines = [
|
|
83
|
-
'# CloudFormation Stacks Inventory',
|
|
84
|
-
'',
|
|
85
|
-
`> Generated by \`clawform sync\` at ${meta.generatedAt}`,
|
|
86
|
-
`> Account: ${meta.account} ยท Region: ${meta.region}`,
|
|
87
|
-
'',
|
|
88
|
-
];
|
|
89
|
-
|
|
90
|
-
renderSection(
|
|
91
|
-
lines,
|
|
92
|
-
'๐ Legacy (refuse modify โ see rules/legacy-freeze.md)',
|
|
93
|
-
classified.legacy,
|
|
94
|
-
['Stack', 'Status', 'Last update'],
|
|
95
|
-
(s) => [s.name, s.status, fmtDate(s.lastUpdatedTime ?? s.creationTime)],
|
|
96
|
-
);
|
|
97
|
-
|
|
98
|
-
renderSection(
|
|
99
|
-
lines,
|
|
100
|
-
'๐ ๏ธ Serverless Framework (managed by `sls` tool โ modify via serverless.yml)',
|
|
101
|
-
classified.serverless,
|
|
102
|
-
['Stack', 'Status'],
|
|
103
|
-
(s) => [s.name, s.status],
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
renderSection(
|
|
107
|
-
lines,
|
|
108
|
-
'โ
Active (Clawform-modifiable)',
|
|
109
|
-
classified.active,
|
|
110
|
-
['Stack', 'Status', 'Last update'],
|
|
111
|
-
(s) => [s.name, s.status, fmtDate(s.lastUpdatedTime ?? s.creationTime)],
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
renderSection(
|
|
115
|
-
lines,
|
|
116
|
-
`๐ค Stale (โฅ${STALE_DAYS} days no update โ review/decommission)`,
|
|
117
|
-
classified.stale,
|
|
118
|
-
['Stack', 'Last update'],
|
|
119
|
-
(s) => [s.name, fmtDate(s.lastUpdatedTime ?? s.creationTime)],
|
|
120
|
-
);
|
|
121
|
-
|
|
122
|
-
return lines.join('\n');
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const SECTION_KEY_BY_TITLE_FRAGMENT = [
|
|
126
|
-
['Legacy', 'legacy'],
|
|
127
|
-
['Serverless', 'serverless'],
|
|
128
|
-
['Active', 'active'],
|
|
129
|
-
['Stale', 'stale'],
|
|
130
|
-
];
|
|
131
|
-
|
|
132
|
-
function sectionKeyFromTitle(title) {
|
|
133
|
-
const hit = SECTION_KEY_BY_TITLE_FRAGMENT.find(([fragment]) => title.includes(fragment));
|
|
134
|
-
return hit ? hit[1] : null;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Read-back counterpart to renderInventory โ recovers the stack list from a
|
|
138
|
-
// committed .clawform/catalog/stacks-inventory.md without a live AWS call. Used by the
|
|
139
|
-
// project-audit workflow (Phase 3), which runs with no network access.
|
|
140
|
-
export function parseInventory(markdown) {
|
|
141
|
-
const result = { legacy: [], serverless: [], active: [], stale: [] };
|
|
142
|
-
let currentKey = null;
|
|
143
|
-
for (const line of markdown.split(/\r?\n/)) {
|
|
144
|
-
const heading = line.match(/^## (.+?) \(\d+\)$/);
|
|
145
|
-
if (heading) {
|
|
146
|
-
currentKey = sectionKeyFromTitle(heading[1]);
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
if (!currentKey || !line.startsWith('| ')) continue;
|
|
150
|
-
const cells = line.split('|').map((c) => c.trim()).filter(Boolean);
|
|
151
|
-
if (cells.length === 0 || cells[0] === 'Stack') continue;
|
|
152
|
-
const name = cells[0].replace(/^`|`$/g, '');
|
|
153
|
-
result[currentKey].push(name);
|
|
154
|
-
}
|
|
155
|
-
return result;
|
|
156
|
-
}
|
|
1
|
+
import { paginateListStacks } from '@aws-sdk/client-cloudformation';
|
|
2
|
+
|
|
3
|
+
export const STALE_DAYS = 180;
|
|
4
|
+
|
|
5
|
+
const LIVE_STATUSES = [
|
|
6
|
+
'CREATE_COMPLETE',
|
|
7
|
+
'UPDATE_COMPLETE',
|
|
8
|
+
'UPDATE_ROLLBACK_COMPLETE',
|
|
9
|
+
'IMPORT_COMPLETE',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const hasLegacyPrefix = (name, prefixes) => prefixes.some((p) => name.startsWith(p + '-'));
|
|
13
|
+
|
|
14
|
+
const SERVERLESS_DESC_RE = /Serverless application/i;
|
|
15
|
+
|
|
16
|
+
export async function fetchStacks(client) {
|
|
17
|
+
const stacks = [];
|
|
18
|
+
for await (const page of paginateListStacks(
|
|
19
|
+
{ client },
|
|
20
|
+
{ StackStatusFilter: LIVE_STATUSES },
|
|
21
|
+
)) {
|
|
22
|
+
for (const s of page.StackSummaries ?? []) {
|
|
23
|
+
stacks.push({
|
|
24
|
+
name: s.StackName,
|
|
25
|
+
status: s.StackStatus,
|
|
26
|
+
templateDescription: s.TemplateDescription,
|
|
27
|
+
creationTime: s.CreationTime,
|
|
28
|
+
lastUpdatedTime: s.LastUpdatedTime,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return stacks;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function classifyStacks(stacks, now = new Date(), legacyPrefixes = []) {
|
|
36
|
+
// legacyPrefixes comes from the consumer's clawform.config.json. Empty = a fresh
|
|
37
|
+
// consumer with no legacy stacks; nothing gets routed to the `legacy` bucket.
|
|
38
|
+
const result = { legacy: [], serverless: [], active: [], stale: [] };
|
|
39
|
+
const staleMs = STALE_DAYS * 24 * 60 * 60 * 1000;
|
|
40
|
+
for (const s of stacks) {
|
|
41
|
+
if (hasLegacyPrefix(s.name, legacyPrefixes)) {
|
|
42
|
+
result.legacy.push(s);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (SERVERLESS_DESC_RE.test(s.templateDescription ?? '')) {
|
|
46
|
+
result.serverless.push(s);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const lastTouch = s.lastUpdatedTime ?? s.creationTime;
|
|
50
|
+
const ageMs = lastTouch ? now.getTime() - new Date(lastTouch).getTime() : 0;
|
|
51
|
+
if (ageMs > staleMs) result.stale.push(s);
|
|
52
|
+
else result.active.push(s);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fmtDate(d) {
|
|
58
|
+
if (!d) return '';
|
|
59
|
+
return new Date(d).toISOString().slice(0, 10);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function escapePipe(s) {
|
|
63
|
+
return String(s ?? '').replace(/\|/g, '\\|');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderSection(lines, title, items, headers, fmtRow) {
|
|
67
|
+
lines.push(`## ${title} (${items.length})`, '');
|
|
68
|
+
if (items.length === 0) {
|
|
69
|
+
lines.push('_None_', '');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
lines.push('| ' + headers.join(' | ') + ' |');
|
|
73
|
+
lines.push('|' + headers.map(() => '---').join('|') + '|');
|
|
74
|
+
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
|
|
75
|
+
for (const it of sorted) {
|
|
76
|
+
lines.push('| ' + fmtRow(it).map((v) => `\`${escapePipe(v)}\``).join(' | ') + ' |');
|
|
77
|
+
}
|
|
78
|
+
lines.push('');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function renderInventory(classified, meta) {
|
|
82
|
+
const lines = [
|
|
83
|
+
'# CloudFormation Stacks Inventory',
|
|
84
|
+
'',
|
|
85
|
+
`> Generated by \`clawform sync\` at ${meta.generatedAt}`,
|
|
86
|
+
`> Account: ${meta.account} ยท Region: ${meta.region}`,
|
|
87
|
+
'',
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
renderSection(
|
|
91
|
+
lines,
|
|
92
|
+
'๐ Legacy (refuse modify โ see rules/legacy-freeze.md)',
|
|
93
|
+
classified.legacy,
|
|
94
|
+
['Stack', 'Status', 'Last update'],
|
|
95
|
+
(s) => [s.name, s.status, fmtDate(s.lastUpdatedTime ?? s.creationTime)],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
renderSection(
|
|
99
|
+
lines,
|
|
100
|
+
'๐ ๏ธ Serverless Framework (managed by `sls` tool โ modify via serverless.yml)',
|
|
101
|
+
classified.serverless,
|
|
102
|
+
['Stack', 'Status'],
|
|
103
|
+
(s) => [s.name, s.status],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
renderSection(
|
|
107
|
+
lines,
|
|
108
|
+
'โ
Active (Clawform-modifiable)',
|
|
109
|
+
classified.active,
|
|
110
|
+
['Stack', 'Status', 'Last update'],
|
|
111
|
+
(s) => [s.name, s.status, fmtDate(s.lastUpdatedTime ?? s.creationTime)],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
renderSection(
|
|
115
|
+
lines,
|
|
116
|
+
`๐ค Stale (โฅ${STALE_DAYS} days no update โ review/decommission)`,
|
|
117
|
+
classified.stale,
|
|
118
|
+
['Stack', 'Last update'],
|
|
119
|
+
(s) => [s.name, fmtDate(s.lastUpdatedTime ?? s.creationTime)],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return lines.join('\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const SECTION_KEY_BY_TITLE_FRAGMENT = [
|
|
126
|
+
['Legacy', 'legacy'],
|
|
127
|
+
['Serverless', 'serverless'],
|
|
128
|
+
['Active', 'active'],
|
|
129
|
+
['Stale', 'stale'],
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
function sectionKeyFromTitle(title) {
|
|
133
|
+
const hit = SECTION_KEY_BY_TITLE_FRAGMENT.find(([fragment]) => title.includes(fragment));
|
|
134
|
+
return hit ? hit[1] : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Read-back counterpart to renderInventory โ recovers the stack list from a
|
|
138
|
+
// committed .clawform/catalog/stacks-inventory.md without a live AWS call. Used by the
|
|
139
|
+
// project-audit workflow (Phase 3), which runs with no network access.
|
|
140
|
+
export function parseInventory(markdown) {
|
|
141
|
+
const result = { legacy: [], serverless: [], active: [], stale: [] };
|
|
142
|
+
let currentKey = null;
|
|
143
|
+
for (const line of markdown.split(/\r?\n/)) {
|
|
144
|
+
const heading = line.match(/^## (.+?) \(\d+\)$/);
|
|
145
|
+
if (heading) {
|
|
146
|
+
currentKey = sectionKeyFromTitle(heading[1]);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!currentKey || !line.startsWith('| ')) continue;
|
|
150
|
+
const cells = line.split('|').map((c) => c.trim()).filter(Boolean);
|
|
151
|
+
if (cells.length === 0 || cells[0] === 'Stack') continue;
|
|
152
|
+
const name = cells[0].replace(/^`|`$/g, '');
|
|
153
|
+
result[currentKey].push(name);
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
package/src/commands/activate.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { logger } from '../lib/logger.js';
|
|
6
6
|
import { resolveClient, resolveTrialBenefitIds } from '../lib/license-client.js';
|
|
7
|
-
import { writeLicense } from '../lib/license-store.js';
|
|
7
|
+
import { writeLicense, readLicenseRaw } from '../lib/license-store.js';
|
|
8
8
|
import { machineFingerprint } from '../lib/fingerprint.js';
|
|
9
9
|
import { claimTrialDevice } from '../lib/trial-claim.js';
|
|
10
10
|
|
|
@@ -12,6 +12,7 @@ const maskKey = (key) => `โฆ${String(key).slice(-4)}`;
|
|
|
12
12
|
|
|
13
13
|
export default async function activate({ key } = {}, {
|
|
14
14
|
client = resolveClient(),
|
|
15
|
+
read = readLicenseRaw,
|
|
15
16
|
write = writeLicense,
|
|
16
17
|
fingerprint = machineFingerprint,
|
|
17
18
|
trialBenefitIds = resolveTrialBenefitIds(),
|
|
@@ -25,6 +26,37 @@ export default async function activate({ key } = {}, {
|
|
|
25
26
|
|
|
26
27
|
const fp = fingerprint();
|
|
27
28
|
|
|
29
|
+
// Idempotent re-activation. Polar offers no key-only "list activations", so a
|
|
30
|
+
// re-run would otherwise POST a brand-new activation every time and burn a
|
|
31
|
+
// slot โ the leak that locks a user out after `limit_activations` upgrades.
|
|
32
|
+
// Instead, if this machine's local record already points at an activation for
|
|
33
|
+
// THIS key, probe it with `validate(key, instanceId)`; if the vendor still
|
|
34
|
+
// honours it, re-adopt it (and re-sign the record, healing a stale one and
|
|
35
|
+
// stamping the CURRENT fingerprint) without minting a second slot. Only when
|
|
36
|
+
// the stored slot is gone/invalid do we fall through to a fresh activation.
|
|
37
|
+
//
|
|
38
|
+
// The slot's validity is judged SERVER-SIDE, not by matching the stored
|
|
39
|
+
// fingerprint: on Windows/macOS the fingerprint has no stable machine-id and
|
|
40
|
+
// shifts when the hostname changes (rename, MDM, DHCP), so a fingerprint guard
|
|
41
|
+
// would skip the reuse and re-create exactly the leak this fixes. The record
|
|
42
|
+
// is per-machine local state (~/.clawform), so a key match is the identity
|
|
43
|
+
// that matters. Deliberately BEFORE the trial-claim block: an already-activated
|
|
44
|
+
// machine must not re-claim a trial.
|
|
45
|
+
const prior = read();
|
|
46
|
+
if (prior.status === 'active' && prior.key === key && prior.instanceId) {
|
|
47
|
+
let check = null;
|
|
48
|
+
try { check = await client.validate(key, prior.instanceId); } catch { /* fall through to a fresh activate */ }
|
|
49
|
+
if (check?.status === 'valid') {
|
|
50
|
+
write({
|
|
51
|
+
status: 'active', key, instanceId: prior.instanceId, holder: prior.holder,
|
|
52
|
+
fingerprint: fp, lastValidatedAt: new Date(now()).toISOString(),
|
|
53
|
+
...(check.expiresAt ? { expiresAt: check.expiresAt } : (prior.expiresAt ? { expiresAt: prior.expiresAt } : {})),
|
|
54
|
+
});
|
|
55
|
+
log(`License ${maskKey(key)} โ re-adopted this machine's existing activation. No extra seat used.`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
28
60
|
// Which tier this key belongs to decides whether our own service is consulted
|
|
29
61
|
// at all. A PAID activation must never depend on it: someone who has just
|
|
30
62
|
// paid, blocked by our downtime, is the failure the deploy/rollback invariant
|
|
@@ -49,7 +81,7 @@ export default async function activate({ key } = {}, {
|
|
|
49
81
|
if (claim && claim.allowed === false) {
|
|
50
82
|
throw new Error(
|
|
51
83
|
'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, '
|
|
84
|
+
+ 'Buy a seat at https://clawform.thejoseki.com/?utm_source=cli โ or, if you believe this is a mistake, '
|
|
53
85
|
+ 'write to support@thejoseki.com and it will be sorted out by a person.',
|
|
54
86
|
);
|
|
55
87
|
}
|