staticx 0.1.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/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # STATICX CLI
2
+
3
+ `staticx` is the public CLI for STATICX. It uses the same token-authenticated `/api/v1` contract as the dashboard, the agent instructions, and the MCP server.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g staticx
9
+ ```
10
+
11
+ ## Login
12
+
13
+ ```bash
14
+ staticx login --base-url "https://staticx.site/api/v1" --token "STATICX_API_TOKEN"
15
+ staticx whoami
16
+ ```
17
+
18
+ ## Core commands
19
+
20
+ ```bash
21
+ staticx workspaces
22
+ staticx sites --workspace-id WORKSPACE_ID
23
+ staticx create --workspace-id WORKSPACE_ID --name "Marketing Site"
24
+ staticx deploy --site-id SITE_ID --dir dist
25
+ staticx logs --site-id SITE_ID
26
+ ```
27
+
28
+ ## Token scopes
29
+
30
+ - Global token: account-wide access for internal operator tools.
31
+ - Site token: one-site deploys, logs, and release verification.
32
+ - Workspace token: several sites inside one workspace.
33
+
34
+ Generate those tokens from:
35
+
36
+ - `Settings → API tokens` for Global
37
+ - `Project Settings → Agent deploy` for Site
38
+ - `Workspace → Agent deploy` for Workspace
39
+
40
+ ## Notes
41
+
42
+ - `staticx login` stores the base URL and bearer token locally.
43
+ - `staticx whoami` verifies the token with `GET /user`.
44
+ - `staticx deploy` zips the contents of the given build directory, uploads them, runs a build check, and publishes the successful build.
package/bin/staticx.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { run } from '../src/index.js';
4
+
5
+ run(process.argv).catch((error) => {
6
+ const message = error instanceof Error ? error.message : String(error);
7
+ console.error(message);
8
+ process.exitCode = 1;
9
+ });
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "staticx",
3
+ "version": "0.1.0",
4
+ "description": "Public CLI for STATICX using token-authenticated /api/v1 routes.",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/madprodworks-coder/static_site.git",
12
+ "directory": "packages/staticx-cli"
13
+ },
14
+ "homepage": "https://staticx.site/documentation/cli",
15
+ "bin": {
16
+ "staticx": "./bin/staticx.js"
17
+ },
18
+ "files": [
19
+ "bin",
20
+ "src",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "test": "node --test tests/*.test.js"
25
+ },
26
+ "keywords": [
27
+ "staticx",
28
+ "cli",
29
+ "static-sites",
30
+ "deployment",
31
+ "laravel"
32
+ ],
33
+ "license": "UNLICENSED",
34
+ "engines": {
35
+ "node": ">=18.17"
36
+ },
37
+ "dependencies": {
38
+ "commander": "^12.1.0",
39
+ "yazl": "^3.3.1"
40
+ }
41
+ }
package/src/archive.js ADDED
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { once } from 'node:events';
6
+ import yazl from 'yazl';
7
+
8
+ export async function createZipFromDirectory(directoryPath) {
9
+ const sourceDirectory = path.resolve(directoryPath);
10
+ const stats = await fsp.stat(sourceDirectory).catch(() => null);
11
+
12
+ if (!stats || !stats.isDirectory()) {
13
+ throw new Error(`Build directory not found: ${sourceDirectory}`);
14
+ }
15
+
16
+ await assertEntryHtmlExists(sourceDirectory);
17
+
18
+ const tempPath = path.join(
19
+ await fsp.mkdtemp(path.join(os.tmpdir(), 'staticx-cli-')),
20
+ `site-${Date.now()}.zip`,
21
+ );
22
+
23
+ const zipFile = new yazl.ZipFile();
24
+ const output = fs.createWriteStream(tempPath);
25
+ zipFile.outputStream.pipe(output);
26
+
27
+ for (const filePath of await collectFiles(sourceDirectory)) {
28
+ const relativePath = path.relative(sourceDirectory, filePath).replaceAll(path.sep, '/');
29
+ zipFile.addFile(filePath, relativePath);
30
+ }
31
+
32
+ zipFile.end();
33
+ await once(output, 'close');
34
+
35
+ return tempPath;
36
+ }
37
+
38
+ async function assertEntryHtmlExists(directoryPath) {
39
+ const candidates = ['index.html', 'index.htm'];
40
+
41
+ for (const candidate of candidates) {
42
+ try {
43
+ const stats = await fsp.stat(path.join(directoryPath, candidate));
44
+ if (stats.isFile()) {
45
+ return;
46
+ }
47
+ } catch {
48
+ // Keep checking.
49
+ }
50
+ }
51
+
52
+ throw new Error('Build directory must contain index.html or index.htm at its root.');
53
+ }
54
+
55
+ async function collectFiles(directoryPath) {
56
+ const entries = await fsp.readdir(directoryPath, { withFileTypes: true });
57
+ const files = [];
58
+
59
+ for (const entry of entries) {
60
+ const entryPath = path.join(directoryPath, entry.name);
61
+
62
+ if (entry.isDirectory()) {
63
+ files.push(...await collectFiles(entryPath));
64
+ continue;
65
+ }
66
+
67
+ if (entry.isFile()) {
68
+ files.push(entryPath);
69
+ }
70
+ }
71
+
72
+ return files;
73
+ }
package/src/auth.js ADDED
@@ -0,0 +1,28 @@
1
+ import { loadConfig, normalizeBaseUrl, saveConfig } from './config.js';
2
+
3
+ export async function resolveRuntimeAuth(options = {}) {
4
+ const config = await loadConfig();
5
+ const baseUrl = options.baseUrl || process.env.STATICX_API_BASE_URL || config.baseUrl;
6
+ const token = options.token || process.env.STATICX_API_TOKEN || config.token;
7
+
8
+ if (!baseUrl) {
9
+ throw new Error('No STATICX base URL found. Pass --base-url or run `staticx login` first.');
10
+ }
11
+
12
+ if (!token) {
13
+ throw new Error('No STATICX token found. Pass --token or run `staticx login` first.');
14
+ }
15
+
16
+ return {
17
+ baseUrl: normalizeBaseUrl(baseUrl),
18
+ token: String(token).trim(),
19
+ };
20
+ }
21
+
22
+ export async function persistLogin(baseUrl, token) {
23
+ await saveConfig({
24
+ baseUrl: normalizeBaseUrl(baseUrl),
25
+ token: String(token).trim(),
26
+ updatedAt: new Date().toISOString(),
27
+ });
28
+ }
package/src/config.js ADDED
@@ -0,0 +1,77 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const APP_DIR = 'staticx';
6
+ const CONFIG_FILE = 'config.json';
7
+
8
+ export function resolveConfigDir() {
9
+ if (process.env.STATICX_CONFIG_DIR) {
10
+ return process.env.STATICX_CONFIG_DIR;
11
+ }
12
+
13
+ if (process.platform === 'win32') {
14
+ return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), APP_DIR);
15
+ }
16
+
17
+ if (process.env.XDG_CONFIG_HOME) {
18
+ return path.join(process.env.XDG_CONFIG_HOME, APP_DIR);
19
+ }
20
+
21
+ return path.join(os.homedir(), '.config', APP_DIR);
22
+ }
23
+
24
+ export function resolveConfigPath() {
25
+ return path.join(resolveConfigDir(), CONFIG_FILE);
26
+ }
27
+
28
+ export async function loadConfig() {
29
+ try {
30
+ const content = await fs.readFile(resolveConfigPath(), 'utf8');
31
+ return JSON.parse(content);
32
+ } catch (error) {
33
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
34
+ return {};
35
+ }
36
+
37
+ throw new Error(`Could not read STATICX CLI config: ${error instanceof Error ? error.message : String(error)}`);
38
+ }
39
+ }
40
+
41
+ export async function saveConfig(config) {
42
+ const configDir = resolveConfigDir();
43
+ await fs.mkdir(configDir, { recursive: true });
44
+ await fs.writeFile(resolveConfigPath(), JSON.stringify(config, null, 2) + '\n', 'utf8');
45
+ }
46
+
47
+ export async function clearConfig() {
48
+ try {
49
+ await fs.unlink(resolveConfigPath());
50
+ } catch (error) {
51
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
52
+ return;
53
+ }
54
+
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ export function normalizeBaseUrl(baseUrl) {
60
+ const value = String(baseUrl || '').trim().replace(/\/+$/, '');
61
+
62
+ if (value === '') {
63
+ throw new Error('STATICX base URL is required. Pass --base-url or run `staticx login` first.');
64
+ }
65
+
66
+ try {
67
+ const url = new URL(value);
68
+
69
+ if (!['http:', 'https:'].includes(url.protocol)) {
70
+ throw new Error('STATICX base URL must use http or https.');
71
+ }
72
+
73
+ return url.toString().replace(/\/+$/, '');
74
+ } catch (error) {
75
+ throw new Error(`Invalid STATICX base URL: ${value}`);
76
+ }
77
+ }
package/src/files.js ADDED
@@ -0,0 +1,10 @@
1
+ import fs from 'node:fs/promises';
2
+
3
+ export async function fileToBlob(filePath, type = 'application/octet-stream') {
4
+ if (typeof fs.openAsBlob === 'function') {
5
+ return fs.openAsBlob(filePath, { type });
6
+ }
7
+
8
+ const buffer = await fs.readFile(filePath);
9
+ return new Blob([buffer], { type });
10
+ }
package/src/http.js ADDED
@@ -0,0 +1,101 @@
1
+ export class StaticxApiError extends Error {
2
+ constructor(message, { status, payload } = {}) {
3
+ super(message);
4
+ this.name = 'StaticxApiError';
5
+ this.status = status;
6
+ this.payload = payload;
7
+ }
8
+ }
9
+
10
+ export class StaticxApiClient {
11
+ constructor({ baseUrl, token, fetchImpl = fetch }) {
12
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
13
+ this.token = token;
14
+ this.fetchImpl = fetchImpl;
15
+ }
16
+
17
+ async get(path, options = {}) {
18
+ return this.request(path, { method: 'GET', ...options });
19
+ }
20
+
21
+ async post(path, options = {}) {
22
+ return this.request(path, { method: 'POST', ...options });
23
+ }
24
+
25
+ async delete(path, options = {}) {
26
+ return this.request(path, { method: 'DELETE', ...options });
27
+ }
28
+
29
+ async request(path, { method = 'GET', query, json, formData } = {}) {
30
+ const url = new URL(path.replace(/^\//, ''), `${this.baseUrl}/`);
31
+
32
+ if (query) {
33
+ for (const [key, value] of Object.entries(query)) {
34
+ if (value === undefined || value === null || value === '') {
35
+ continue;
36
+ }
37
+
38
+ url.searchParams.set(key, String(value));
39
+ }
40
+ }
41
+
42
+ const headers = {
43
+ Accept: 'application/json',
44
+ Authorization: `Bearer ${this.token}`,
45
+ };
46
+
47
+ let body;
48
+
49
+ if (formData) {
50
+ body = formData;
51
+ } else if (json !== undefined) {
52
+ body = JSON.stringify(json);
53
+ headers['Content-Type'] = 'application/json';
54
+ }
55
+
56
+ const response = await this.fetchImpl(url, {
57
+ method,
58
+ headers,
59
+ body,
60
+ });
61
+
62
+ const payload = await parsePayload(response);
63
+
64
+ if (!response.ok) {
65
+ throw new StaticxApiError(apiErrorMessage(payload, response), {
66
+ status: response.status,
67
+ payload,
68
+ });
69
+ }
70
+
71
+ return payload;
72
+ }
73
+ }
74
+
75
+ async function parsePayload(response) {
76
+ const contentType = response.headers.get('content-type') || '';
77
+
78
+ if (contentType.includes('application/json')) {
79
+ return response.json();
80
+ }
81
+
82
+ const text = await response.text();
83
+
84
+ if (text === '') {
85
+ return null;
86
+ }
87
+
88
+ try {
89
+ return JSON.parse(text);
90
+ } catch {
91
+ return { message: text };
92
+ }
93
+ }
94
+
95
+ function apiErrorMessage(payload, response) {
96
+ if (payload && typeof payload === 'object' && 'message' in payload && payload.message) {
97
+ return String(payload.message);
98
+ }
99
+
100
+ return `${response.status} ${response.statusText}`.trim();
101
+ }
package/src/index.js ADDED
@@ -0,0 +1,283 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { Command } from 'commander';
4
+ import { createZipFromDirectory } from './archive.js';
5
+ import { resolveRuntimeAuth, persistLogin } from './auth.js';
6
+ import { clearConfig, normalizeBaseUrl } from './config.js';
7
+ import { fileToBlob } from './files.js';
8
+ import { StaticxApiClient } from './http.js';
9
+ import { formatTokenSummary, printJson, printKeyValue, printTable } from './output.js';
10
+
11
+ export async function run(argv) {
12
+ const program = new Command();
13
+
14
+ program
15
+ .name('staticx')
16
+ .description('Public CLI for STATICX using token-authenticated /api/v1 routes.')
17
+ .version('0.1.0')
18
+ .option('--base-url <url>', 'STATICX API base URL, for example https://staticx.site/api/v1')
19
+ .option('--token <token>', 'STATICX bearer token')
20
+ .showHelpAfterError();
21
+
22
+ program
23
+ .command('login')
24
+ .description('Store the base URL and token locally, then verify them with GET /user.')
25
+ .requiredOption('--base-url <url>', 'STATICX API base URL')
26
+ .requiredOption('--token <token>', 'STATICX bearer token')
27
+ .option('--json', 'Print JSON output')
28
+ .action(async (options) => {
29
+ const baseUrl = normalizeBaseUrl(options.baseUrl);
30
+ const token = String(options.token).trim();
31
+ const client = new StaticxApiClient({ baseUrl, token });
32
+ const response = await client.get('/user');
33
+
34
+ await persistLogin(baseUrl, token);
35
+
36
+ if (options.json) {
37
+ printJson({
38
+ message: 'Login successful.',
39
+ base_url: baseUrl,
40
+ user: response?.data ?? null,
41
+ });
42
+ return;
43
+ }
44
+
45
+ console.log('Login successful.');
46
+ printKeyValue('Base URL', baseUrl);
47
+ printKeyValue('User', response?.data?.email || response?.data?.name || 'Unknown');
48
+ printKeyValue('Token', formatTokenSummary(response?.data?.current_token || response?.data?.token));
49
+ });
50
+
51
+ program
52
+ .command('logout')
53
+ .description('Remove the locally stored STATICX CLI credentials.')
54
+ .action(async () => {
55
+ await clearConfig();
56
+ console.log('Local STATICX CLI credentials removed.');
57
+ });
58
+
59
+ program
60
+ .command('whoami')
61
+ .description('Verify the stored token and print the current user/token metadata.')
62
+ .option('--json', 'Print JSON output')
63
+ .action(async (options, command) => {
64
+ const client = await clientFromCommand(command);
65
+ const response = await client.get('/user');
66
+
67
+ if (options.json) {
68
+ printJson(response?.data ?? null);
69
+ return;
70
+ }
71
+
72
+ const user = response?.data ?? {};
73
+ console.log(user.name || user.email || 'Unknown user');
74
+ if (user.email) {
75
+ printKeyValue('Email', user.email);
76
+ }
77
+ printKeyValue('Token', formatTokenSummary(user.current_token || user.token));
78
+ });
79
+
80
+ program
81
+ .command('workspaces')
82
+ .description('List workspaces visible to the current token.')
83
+ .option('--json', 'Print JSON output')
84
+ .action(async (options, command) => {
85
+ const client = await clientFromCommand(command);
86
+ const response = await client.get('/workspaces');
87
+ const workspaces = response?.data ?? [];
88
+
89
+ if (options.json) {
90
+ printJson(workspaces);
91
+ return;
92
+ }
93
+
94
+ printTable(workspaces.map((workspace) => ({
95
+ id: workspace.id,
96
+ name: workspace.name,
97
+ role: workspace.role,
98
+ sites: workspace.projects_count,
99
+ })));
100
+ });
101
+
102
+ program
103
+ .command('sites')
104
+ .description('List sites visible to the current token, optionally filtered by workspace.')
105
+ .option('--workspace-id <id>', 'Filter by workspace ID')
106
+ .option('--json', 'Print JSON output')
107
+ .action(async (options, command) => {
108
+ const client = await clientFromCommand(command);
109
+ const response = await client.get('/projects', {
110
+ query: options.workspaceId ? { workspace_id: options.workspaceId } : undefined,
111
+ });
112
+ const sites = response?.data ?? [];
113
+
114
+ if (options.json) {
115
+ printJson(sites);
116
+ return;
117
+ }
118
+
119
+ printTable(sites.map((site) => ({
120
+ id: site.id,
121
+ name: site.name,
122
+ host: site.host,
123
+ workspace: site.workspace?.name || '',
124
+ status: site.status,
125
+ })));
126
+ });
127
+
128
+ program
129
+ .command('create')
130
+ .description('Create a new site, optionally inside a workspace or from a ZIP/source URL.')
131
+ .requiredOption('--name <name>', 'Site name')
132
+ .option('--workspace-id <id>', 'Workspace ID')
133
+ .option('--description <description>', 'Site description')
134
+ .option('--archive <path>', 'ZIP archive path to seed the site')
135
+ .option('--source-url <url>', 'Public URL to import into the site')
136
+ .option('--json', 'Print JSON output')
137
+ .action(async (options, command) => {
138
+ if (options.archive && options.sourceUrl) {
139
+ throw new Error('Use either --archive or --source-url, not both.');
140
+ }
141
+
142
+ const client = await clientFromCommand(command);
143
+ const response = options.archive || options.sourceUrl
144
+ ? await client.post('/projects', {
145
+ formData: await createProjectFormData(options),
146
+ })
147
+ : await client.post('/projects', {
148
+ json: compactObject({
149
+ workspace_id: options.workspaceId,
150
+ name: options.name,
151
+ description: options.description,
152
+ }),
153
+ });
154
+
155
+ if (options.json) {
156
+ printJson(response);
157
+ return;
158
+ }
159
+
160
+ const project = response?.data ?? {};
161
+ console.log('Site created.');
162
+ printKeyValue('ID', project.id ?? 'Unknown');
163
+ printKeyValue('Name', project.name ?? options.name);
164
+ printKeyValue('Host', project.host || 'Pending');
165
+ printKeyValue('Public URL', project.public_url || 'Pending');
166
+ });
167
+
168
+ program
169
+ .command('deploy')
170
+ .description('Zip a local build directory, upload it, build-check it, and publish it to one site.')
171
+ .requiredOption('--site-id <id>', 'Site ID')
172
+ .requiredOption('--dir <path>', 'Built site directory, for example dist')
173
+ .option('--json', 'Print JSON output')
174
+ .action(async (options, command) => {
175
+ const client = await clientFromCommand(command);
176
+ const zipPath = await createZipFromDirectory(options.dir);
177
+
178
+ try {
179
+ const uploadFormData = new FormData();
180
+ uploadFormData.set('mode', 'zip');
181
+ uploadFormData.set('overwrite_confirmed', '1');
182
+ uploadFormData.set('archive', await fileToBlob(zipPath, 'application/zip'), path.basename(zipPath));
183
+
184
+ const upload = await client.post(`/projects/${options.siteId}/files`, { formData: uploadFormData });
185
+ const build = await client.post(`/projects/${options.siteId}/builds`);
186
+ const buildId = build?.data?.id;
187
+
188
+ if (!buildId) {
189
+ throw new Error('Build response did not include a build ID.');
190
+ }
191
+
192
+ const deployment = await client.post(`/projects/${options.siteId}/deployments`, {
193
+ json: { build_id: buildId },
194
+ });
195
+ const project = await client.get(`/projects/${options.siteId}`);
196
+
197
+ const result = {
198
+ project: project?.data ?? null,
199
+ upload: upload?.data ?? null,
200
+ build: build?.data ?? null,
201
+ deployment: deployment?.data ?? null,
202
+ };
203
+
204
+ if (options.json) {
205
+ printJson(result);
206
+ return;
207
+ }
208
+
209
+ console.log('Deployment completed.');
210
+ printKeyValue('Site', result.project?.name || options.siteId);
211
+ printKeyValue('Build', result.build?.id || 'Unknown');
212
+ printKeyValue('Deployment', result.deployment?.id || 'Unknown');
213
+ printKeyValue('Public URL', result.project?.public_url || 'Pending');
214
+ } finally {
215
+ await fs.rm(path.dirname(zipPath), { recursive: true, force: true });
216
+ }
217
+ });
218
+
219
+ program
220
+ .command('logs')
221
+ .description('Read recent activity logs for one site.')
222
+ .requiredOption('--site-id <id>', 'Site ID')
223
+ .option('--limit <number>', 'Maximum number of log rows', '25')
224
+ .option('--json', 'Print JSON output')
225
+ .action(async (options, command) => {
226
+ const client = await clientFromCommand(command);
227
+ const response = await client.get(`/projects/${options.siteId}/logs`, {
228
+ query: { limit: options.limit },
229
+ });
230
+ const logs = response?.data ?? [];
231
+
232
+ if (options.json) {
233
+ printJson(logs);
234
+ return;
235
+ }
236
+
237
+ printTable(logs.map((entry) => ({
238
+ time: entry.created_at,
239
+ level: entry.level,
240
+ event: entry.event,
241
+ message: entry.message,
242
+ })));
243
+ });
244
+
245
+ await program.parseAsync(argv);
246
+ }
247
+
248
+ async function clientFromCommand(command) {
249
+ const options = command.optsWithGlobals ? command.optsWithGlobals() : command.opts();
250
+ const auth = await resolveRuntimeAuth(options);
251
+
252
+ return new StaticxApiClient(auth);
253
+ }
254
+
255
+ async function createProjectFormData(options) {
256
+ const formData = new FormData();
257
+ formData.set('name', options.name);
258
+
259
+ if (options.workspaceId) {
260
+ formData.set('workspace_id', String(options.workspaceId));
261
+ }
262
+
263
+ if (options.description) {
264
+ formData.set('description', options.description);
265
+ }
266
+
267
+ if (options.archive) {
268
+ const archivePath = path.resolve(options.archive);
269
+ formData.set('archive', await fileToBlob(archivePath, 'application/zip'), path.basename(archivePath));
270
+ }
271
+
272
+ if (options.sourceUrl) {
273
+ formData.set('source_url', options.sourceUrl);
274
+ }
275
+
276
+ return formData;
277
+ }
278
+
279
+ function compactObject(values) {
280
+ return Object.fromEntries(
281
+ Object.entries(values).filter(([, value]) => value !== undefined && value !== null && value !== ''),
282
+ );
283
+ }
package/src/output.js ADDED
@@ -0,0 +1,23 @@
1
+ export function printJson(value) {
2
+ console.log(JSON.stringify(value, null, 2));
3
+ }
4
+
5
+ export function printKeyValue(label, value) {
6
+ console.log(`${label}: ${value}`);
7
+ }
8
+
9
+ export function printTable(rows) {
10
+ console.table(rows);
11
+ }
12
+
13
+ export function formatTokenSummary(token) {
14
+ if (!token) {
15
+ return 'No token metadata returned.';
16
+ }
17
+
18
+ const scope = token.scope_label || token.scope_type || token.kind || 'Unknown scope';
19
+ const preset = token.preset_label || token.preset || 'Unknown access level';
20
+ const expiry = token.expires_at || 'Never expires';
21
+
22
+ return `${scope} · ${preset} · ${expiry}`;
23
+ }