@loopress/cli 0.10.0 → 0.12.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 +41 -35
- package/dist/commands/init.js +24 -10
- package/dist/commands/project/config.js +1 -1
- package/dist/commands/project/remove.js +3 -2
- package/dist/commands/project/switch.d.ts +1 -2
- package/dist/commands/project/switch.js +28 -34
- package/dist/commands/project/sync.d.ts +13 -0
- package/dist/commands/project/sync.js +220 -0
- package/dist/commands/snippet/list.d.ts +0 -1
- package/dist/commands/snippet/list.js +4 -7
- package/dist/commands/snippet/pull.d.ts +2 -2
- package/dist/commands/snippet/pull.js +44 -14
- package/dist/commands/snippet/push.d.ts +1 -1
- package/dist/commands/snippet/push.js +56 -26
- package/dist/config/project-config.manager.d.ts +6 -1
- package/dist/config/project-config.manager.js +39 -3
- package/dist/help.js +1 -1
- package/dist/lib/api-client.d.ts +14 -0
- package/dist/lib/api-client.js +46 -0
- package/dist/lib/base.d.ts +0 -1
- package/dist/lib/base.js +0 -5
- package/dist/lib/push-command.js +1 -1
- package/dist/lib/wp-client.d.ts +1 -0
- package/dist/lib/wp-client.js +22 -1
- package/dist/types/global-config.generated.d.ts +8 -0
- package/dist/types/project-config.generated.d.ts +0 -4
- package/dist/types/snippet.d.ts +1 -1
- package/dist/utils/{snippet-plugin.d.ts → snippet-format.d.ts} +3 -19
- package/dist/utils/snippet-format.js +57 -0
- package/oclif.manifest.json +124 -139
- package/package.json +6 -1
- package/schemas/global-config.schema.json +8 -0
- package/schemas/project-config.schema.json +0 -6
- package/dist/utils/snippet-plugin-flag.d.ts +0 -3
- package/dist/utils/snippet-plugin-flag.js +0 -8
- package/dist/utils/snippet-plugin.js +0 -232
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
import { Flags } from '@oclif/core';
|
|
2
2
|
import { LoopressCommand } from '../../lib/base.js';
|
|
3
|
-
import {
|
|
4
|
-
import { getSnippetPlugin } from '../../utils/snippet-plugin.js';
|
|
3
|
+
import { normalizeSnippet, SNIPPETS_ENDPOINT } from '../../utils/snippet-format.js';
|
|
5
4
|
export default class List extends LoopressCommand {
|
|
6
5
|
static description = 'List snippets from WordPress';
|
|
7
|
-
static examples = ['$ lps snippet list'
|
|
6
|
+
static examples = ['$ lps snippet list'];
|
|
8
7
|
static flags = {
|
|
9
8
|
json: Flags.boolean({ char: 'j', description: 'Output in JSON format' }),
|
|
10
|
-
...snippetPluginFlag,
|
|
11
9
|
};
|
|
12
10
|
async run() {
|
|
13
11
|
const { flags } = await this.parse(List);
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const snippets = remoteList.map((r) => adapter.fromRemote(r));
|
|
12
|
+
const remoteList = await this.wp.get(SNIPPETS_ENDPOINT);
|
|
13
|
+
const snippets = remoteList.map((r) => normalizeSnippet(r));
|
|
17
14
|
if (flags.json) {
|
|
18
15
|
this.log(JSON.stringify(snippets, null, 2));
|
|
19
16
|
return;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
-
import { NormalizedSnippet } from '../../utils/snippet-
|
|
2
|
+
import { NormalizedSnippet } from '../../utils/snippet-format.js';
|
|
3
3
|
export declare function buildSnippetFile(snippet: NormalizedSnippet): string;
|
|
4
4
|
export declare function buildMetaFile(snippet: NormalizedSnippet): string;
|
|
5
5
|
export default class Pull extends LoopressCommand {
|
|
@@ -9,8 +9,8 @@ export default class Pull extends LoopressCommand {
|
|
|
9
9
|
static description: string;
|
|
10
10
|
static examples: string[];
|
|
11
11
|
static flags: {
|
|
12
|
-
plugin: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
12
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
13
|
};
|
|
15
14
|
run(): Promise<void>;
|
|
15
|
+
private findOrphanedFiles;
|
|
16
16
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
2
|
import { Listr } from 'listr2';
|
|
3
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { join } from 'node:path';
|
|
3
|
+
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { extname, join } from 'node:path';
|
|
5
5
|
import slugify from 'slugify';
|
|
6
6
|
import { LoopressCommand } from '../../lib/base.js';
|
|
7
|
-
import {
|
|
8
|
-
import { getSnippetPlugin } from '../../utils/snippet-plugin.js';
|
|
7
|
+
import { normalizeSnippet, SNIPPETS_ENDPOINT } from '../../utils/snippet-format.js';
|
|
9
8
|
const EXTENSIONS = {
|
|
10
9
|
css: 'css',
|
|
11
10
|
html: 'html',
|
|
@@ -44,28 +43,32 @@ export default class Pull extends LoopressCommand {
|
|
|
44
43
|
path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
|
|
45
44
|
};
|
|
46
45
|
static description = 'Pull snippets from WordPress';
|
|
47
|
-
static examples = ['$ lps snippet pull', '$ lps snippet pull --path ./snippets'
|
|
46
|
+
static examples = ['$ lps snippet pull', '$ lps snippet pull --path ./snippets'];
|
|
48
47
|
static flags = {
|
|
49
48
|
...LoopressCommand.dryRunFlag,
|
|
50
|
-
...snippetPluginFlag,
|
|
51
49
|
};
|
|
52
50
|
async run() {
|
|
53
|
-
const { args
|
|
51
|
+
const { args } = await this.parse(Pull);
|
|
54
52
|
const { url } = this.siteConfig;
|
|
55
53
|
const path = this.resolveSnippetsPath(args.path);
|
|
56
|
-
|
|
57
|
-
this.log(`Pulling snippets from ${url} via ${resolvedPlugin}`);
|
|
54
|
+
this.log(`Pulling snippets from ${url}`);
|
|
58
55
|
this.log(`Snippets path: ${path}`);
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const
|
|
56
|
+
const remoteList = await this.wp.get(SNIPPETS_ENDPOINT);
|
|
57
|
+
const snippets = remoteList.map((r) => normalizeSnippet(r));
|
|
58
|
+
const pullable = snippets.filter((snippet) => snippet.name.trim());
|
|
59
|
+
const skipped = snippets.length - pullable.length;
|
|
60
|
+
// Files following the `<id>-<slug>` convention whose id is no longer in the current
|
|
61
|
+
// remote list belong to a snippet that was deleted on WordPress. Left on disk, they'd
|
|
62
|
+
// silently come back to life the next time `snippet push` runs.
|
|
63
|
+
const orphans = await this.findOrphanedFiles(path, new Set(pullable.map((snippet) => snippet.id)));
|
|
62
64
|
if (this.dryRun) {
|
|
63
65
|
this.log(`[dry-run] Would pull ${snippets.length} snippet${snippets.length === 1 ? '' : 's'} to ${path}`);
|
|
66
|
+
if (orphans.length > 0) {
|
|
67
|
+
this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} whose snippet no longer exists on WordPress: ${orphans.join(', ')}`);
|
|
68
|
+
}
|
|
64
69
|
return;
|
|
65
70
|
}
|
|
66
71
|
await mkdir(path, { recursive: true });
|
|
67
|
-
const pullable = snippets.filter((snippet) => snippet.name.trim());
|
|
68
|
-
const skipped = snippets.length - pullable.length;
|
|
69
72
|
await new Listr(pullable.map((snippet) => ({
|
|
70
73
|
async task(_ctx, task) {
|
|
71
74
|
const ext = EXTENSIONS[snippet.type];
|
|
@@ -77,9 +80,36 @@ export default class Pull extends LoopressCommand {
|
|
|
77
80
|
},
|
|
78
81
|
title: `Pull ${snippet.name}`,
|
|
79
82
|
}))).run();
|
|
83
|
+
for (const file of orphans)
|
|
84
|
+
await rm(join(path, file), { force: true });
|
|
85
|
+
if (orphans.length > 0) {
|
|
86
|
+
this.warn(`Removed ${orphans.length} local file${orphans.length === 1 ? '' : 's'} whose snippet no longer exists on WordPress: ${orphans.join(', ')}`);
|
|
87
|
+
}
|
|
80
88
|
this.log(`Pulled ${pullable.length} snippet${pullable.length === 1 ? '' : 's'} to ${path}`);
|
|
81
89
|
if (skipped > 0) {
|
|
82
90
|
this.warn(`${skipped} snippet${skipped === 1 ? '' : 's'} skipped because they have no name`);
|
|
83
91
|
}
|
|
84
92
|
}
|
|
93
|
+
// Only ever matches files already following the `<id>-<slug>` convention that `snippet
|
|
94
|
+
// pull`/`push` themselves produce, so a hand-created file without a numeric prefix is
|
|
95
|
+
// never at risk of being picked up here.
|
|
96
|
+
async findOrphanedFiles(path, keepIds) {
|
|
97
|
+
let files;
|
|
98
|
+
try {
|
|
99
|
+
files = await readdir(path);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error.code === 'ENOENT')
|
|
103
|
+
return [];
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
const knownExtensions = new Set(['json', ...Object.values(EXTENSIONS)]);
|
|
107
|
+
return files.filter((file) => {
|
|
108
|
+
const ext = extname(file).slice(1);
|
|
109
|
+
if (!knownExtensions.has(ext))
|
|
110
|
+
return false;
|
|
111
|
+
const match = /^(\d+)-/.exec(file);
|
|
112
|
+
return match !== null && !keepIds.has(Number(match[1]));
|
|
113
|
+
});
|
|
114
|
+
}
|
|
85
115
|
}
|
|
@@ -6,7 +6,6 @@ export default class Push extends PushCommand {
|
|
|
6
6
|
static description: string;
|
|
7
7
|
static examples: string[];
|
|
8
8
|
static flags: {
|
|
9
|
-
plugin: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
9
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
10
|
};
|
|
12
11
|
private failedCount;
|
|
@@ -14,4 +13,5 @@ export default class Push extends PushCommand {
|
|
|
14
13
|
private ensureCanonicalFilename;
|
|
15
14
|
private loadSnippets;
|
|
16
15
|
private pushSnippet;
|
|
16
|
+
private toPayload;
|
|
17
17
|
}
|
|
@@ -4,8 +4,8 @@ import { readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
|
4
4
|
import { basename, dirname, extname, join } from 'node:path';
|
|
5
5
|
import slugify from 'slugify';
|
|
6
6
|
import { PushCommand } from '../../lib/push-command.js';
|
|
7
|
-
import {
|
|
8
|
-
import { defaultLocationForType,
|
|
7
|
+
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
8
|
+
import { defaultLocationForType, normalizeSnippet, parseInsertMethod, parseLocation, parseType, SNIPPETS_ENDPOINT, stripPhpOpeningTag, } from '../../utils/snippet-format.js';
|
|
9
9
|
const TYPE_BY_EXTENSION = {
|
|
10
10
|
'.css': 'css',
|
|
11
11
|
'.html': 'html',
|
|
@@ -18,24 +18,21 @@ export default class Push extends PushCommand {
|
|
|
18
18
|
path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
|
|
19
19
|
};
|
|
20
20
|
static description = 'Push snippets to WordPress. Local snippet files created or updated remotely are renamed on disk to the `<id>-<slug>` convention.';
|
|
21
|
-
static examples = ['$ lps snippet push', '$ lps snippet push --path ./snippets'
|
|
21
|
+
static examples = ['$ lps snippet push', '$ lps snippet push --path ./snippets'];
|
|
22
22
|
static flags = {
|
|
23
23
|
...PushCommand.dryRunFlag,
|
|
24
|
-
...snippetPluginFlag,
|
|
25
24
|
};
|
|
26
25
|
failedCount = 0;
|
|
27
26
|
async run() {
|
|
28
|
-
const { args
|
|
27
|
+
const { args } = await this.parse(Push);
|
|
29
28
|
const { url } = this.siteConfig;
|
|
30
29
|
const path = this.resolveSnippetsPath(args.path);
|
|
31
|
-
|
|
32
|
-
this.log(`Pushing snippets to ${url} via ${resolvedPlugin}`);
|
|
30
|
+
this.log(`Pushing snippets to ${url}`);
|
|
33
31
|
this.log(`Snippets path: ${path}`);
|
|
34
32
|
const snippets = await this.loadSnippets(path);
|
|
35
33
|
this.log(`Found ${snippets.length} snippet${snippets.length === 1 ? '' : 's'} to push`);
|
|
36
|
-
const adapter = getSnippetPlugin(resolvedPlugin);
|
|
37
34
|
await new Listr(snippets.map((snippet) => ({
|
|
38
|
-
task: async (_ctx, task) => this.pushSnippet(snippet,
|
|
35
|
+
task: async (_ctx, task) => this.pushSnippet(snippet, task),
|
|
39
36
|
title: `Push ${snippet.name}`,
|
|
40
37
|
})), { concurrent: false, exitOnError: false }).run();
|
|
41
38
|
if (this.failedCount > 0) {
|
|
@@ -82,14 +79,23 @@ export default class Push extends PushCommand {
|
|
|
82
79
|
}
|
|
83
80
|
async loadSnippets(path) {
|
|
84
81
|
const snippets = [];
|
|
82
|
+
let files;
|
|
85
83
|
try {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
84
|
+
files = await readdir(path);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
this.error(`Error loading snippets: ${error.message}`);
|
|
88
|
+
}
|
|
89
|
+
for (const file of files) {
|
|
90
|
+
const ext = extname(file);
|
|
91
|
+
if (!(ext in TYPE_BY_EXTENSION))
|
|
92
|
+
continue;
|
|
93
|
+
const filePath = join(path, file);
|
|
94
|
+
const metaPath = join(path, `${basename(file, ext)}.json`);
|
|
95
|
+
// One snippet's files are read in isolation: a corrupted or hand-broken sidecar
|
|
96
|
+
// (bad JSON, unreadable file, ...) must only skip that snippet, not abort the entire
|
|
97
|
+
// push for every other snippet in the directory.
|
|
98
|
+
try {
|
|
93
99
|
const content = await readFile(filePath, 'utf8');
|
|
94
100
|
let id;
|
|
95
101
|
let name;
|
|
@@ -132,31 +138,41 @@ export default class Push extends PushCommand {
|
|
|
132
138
|
type: resolvedType,
|
|
133
139
|
});
|
|
134
140
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
141
|
+
catch (error) {
|
|
142
|
+
this.warn(`Skipping "${metaPath}": ${error.message}`);
|
|
143
|
+
}
|
|
138
144
|
}
|
|
139
145
|
return snippets;
|
|
140
146
|
}
|
|
141
147
|
// Throwing on failure (rather than returning a boolean) is what lets Listr mark the task as
|
|
142
148
|
// failed (red cross) instead of completed; `exitOnError: false` on the task list still lets
|
|
143
149
|
// sibling snippets push regardless.
|
|
144
|
-
async pushSnippet(snippet,
|
|
150
|
+
async pushSnippet(snippet, task) {
|
|
145
151
|
if (this.dryRun) {
|
|
146
152
|
if (task)
|
|
147
153
|
task.output = `[dry-run] Would push: ${snippet.name}`;
|
|
148
154
|
return;
|
|
149
155
|
}
|
|
150
|
-
const endpointPath = adapter.endpointPath();
|
|
151
156
|
try {
|
|
152
|
-
const payload =
|
|
157
|
+
const payload = this.toPayload(snippet);
|
|
153
158
|
if (snippet.id) {
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
try {
|
|
160
|
+
await this.wp.put(`${SNIPPETS_ENDPOINT}/${snippet.id}`, payload);
|
|
161
|
+
await this.ensureCanonicalFilename(snippet, snippet.id, snippet.name);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
// The id recorded locally doesn't exist on this site (e.g. a fresh install): create it
|
|
165
|
+
// instead of failing, and adopt whatever id the site assigns.
|
|
166
|
+
if (!isNotFoundError(error))
|
|
167
|
+
throw error;
|
|
168
|
+
const response = await this.wp.post(SNIPPETS_ENDPOINT, payload);
|
|
169
|
+
const created = normalizeSnippet(response);
|
|
170
|
+
await this.ensureCanonicalFilename(snippet, created.id, created.name);
|
|
171
|
+
}
|
|
156
172
|
}
|
|
157
173
|
else {
|
|
158
|
-
const response = await this.wp.post(
|
|
159
|
-
const created =
|
|
174
|
+
const response = await this.wp.post(SNIPPETS_ENDPOINT, payload);
|
|
175
|
+
const created = normalizeSnippet(response);
|
|
160
176
|
await this.ensureCanonicalFilename(snippet, created.id, created.name);
|
|
161
177
|
}
|
|
162
178
|
if (task)
|
|
@@ -172,4 +188,18 @@ export default class Push extends PushCommand {
|
|
|
172
188
|
throw error;
|
|
173
189
|
}
|
|
174
190
|
}
|
|
191
|
+
toPayload(snippet) {
|
|
192
|
+
return {
|
|
193
|
+
active: snippet.active,
|
|
194
|
+
code: stripPhpOpeningTag(snippet.code),
|
|
195
|
+
description: `Imported from ${snippet.path}`,
|
|
196
|
+
insertMethod: snippet.insertMethod,
|
|
197
|
+
location: snippet.location,
|
|
198
|
+
name: snippet.name,
|
|
199
|
+
priority: snippet.priority,
|
|
200
|
+
shortcodeAttributes: snippet.shortcodeAttributes,
|
|
201
|
+
tags: snippet.tags,
|
|
202
|
+
type: snippet.type,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
175
205
|
}
|
|
@@ -2,8 +2,11 @@ import { EnvironmentConfig, LoopressConfig, ProjectConfig } from '../types/confi
|
|
|
2
2
|
export declare class ProjectConfigManager {
|
|
3
3
|
private readonly homeDir;
|
|
4
4
|
constructor(homeDir?: string);
|
|
5
|
-
createProjectId(): string;
|
|
5
|
+
createProjectId(name: string): string;
|
|
6
6
|
ensureConfigDir(): void;
|
|
7
|
+
findProjectByApiId(apiProjectId: string): null | (ProjectConfig & {
|
|
8
|
+
id: string;
|
|
9
|
+
});
|
|
7
10
|
getConfigFilePath(): string;
|
|
8
11
|
getCurrentEnv(): EnvironmentConfig | null;
|
|
9
12
|
getCurrentProject(): null | (ProjectConfig & {
|
|
@@ -23,7 +26,9 @@ export declare class ProjectConfigManager {
|
|
|
23
26
|
removeProject(id: string): void;
|
|
24
27
|
setCurrent(projectId: string, envName: string): void;
|
|
25
28
|
setEnvironment(projectId: string, envName: string, env: EnvironmentConfig): void;
|
|
29
|
+
setEnvironmentApiId(projectId: string, envName: string, apiEnvironmentId: string): void;
|
|
26
30
|
setProject(id: string, project: ProjectConfig): void;
|
|
31
|
+
setProjectApiId(id: string, apiProjectId: string): void;
|
|
27
32
|
writeConfig(config: LoopressConfig): void;
|
|
28
33
|
private isProjectConfig;
|
|
29
34
|
private sanitizeConfig;
|
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
1
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
3
2
|
import { homedir } from 'node:os';
|
|
4
3
|
import { join } from 'node:path';
|
|
4
|
+
import slugify from 'slugify';
|
|
5
5
|
import { readJsonFile, writeJsonFileAtomic } from './json-file.js';
|
|
6
6
|
export class ProjectConfigManager {
|
|
7
7
|
homeDir;
|
|
8
8
|
constructor(homeDir = homedir()) {
|
|
9
9
|
this.homeDir = homeDir;
|
|
10
10
|
}
|
|
11
|
-
createProjectId() {
|
|
12
|
-
|
|
11
|
+
createProjectId(name) {
|
|
12
|
+
const config = this.readConfig();
|
|
13
|
+
const base = slugify(name, { lower: true, strict: true }) || 'project';
|
|
14
|
+
let id = base;
|
|
15
|
+
let suffix = 2;
|
|
16
|
+
while (config.projects[id]) {
|
|
17
|
+
id = `${base}-${suffix}`;
|
|
18
|
+
suffix++;
|
|
19
|
+
}
|
|
20
|
+
return id;
|
|
13
21
|
}
|
|
14
22
|
ensureConfigDir() {
|
|
15
23
|
const dir = join(this.homeDir, '.loopress');
|
|
@@ -17,6 +25,18 @@ export class ProjectConfigManager {
|
|
|
17
25
|
mkdirSync(dir, { recursive: true });
|
|
18
26
|
}
|
|
19
27
|
}
|
|
28
|
+
// Looks up a local project already linked to a given API project, regardless of whether
|
|
29
|
+
// it was "claimed" in the current sync run. Used by `project sync` to avoid minting a new
|
|
30
|
+
// local project every time it pulls an API project whose local link was lost (e.g. after
|
|
31
|
+
// a reset or partial config corruption), which otherwise accumulates duplicate entries.
|
|
32
|
+
findProjectByApiId(apiProjectId) {
|
|
33
|
+
const config = this.readConfig();
|
|
34
|
+
for (const [id, project] of Object.entries(config.projects)) {
|
|
35
|
+
if (project.apiProjectId === apiProjectId)
|
|
36
|
+
return { ...project, id };
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
20
40
|
getConfigFilePath() {
|
|
21
41
|
return join(this.homeDir, '.loopress', 'config.json');
|
|
22
42
|
}
|
|
@@ -113,6 +133,14 @@ export class ProjectConfigManager {
|
|
|
113
133
|
config.currentProject = { env: envName, id: projectId };
|
|
114
134
|
this.writeConfig(config);
|
|
115
135
|
}
|
|
136
|
+
setEnvironmentApiId(projectId, envName, apiEnvironmentId) {
|
|
137
|
+
const config = this.readConfig();
|
|
138
|
+
const env = config.projects[projectId]?.environments[envName];
|
|
139
|
+
if (!env)
|
|
140
|
+
return;
|
|
141
|
+
env.apiEnvironmentId = apiEnvironmentId;
|
|
142
|
+
this.writeConfig(config);
|
|
143
|
+
}
|
|
116
144
|
setProject(id, project) {
|
|
117
145
|
const config = this.readConfig();
|
|
118
146
|
config.projects[id] = project;
|
|
@@ -123,6 +151,14 @@ export class ProjectConfigManager {
|
|
|
123
151
|
}
|
|
124
152
|
this.writeConfig(config);
|
|
125
153
|
}
|
|
154
|
+
setProjectApiId(id, apiProjectId) {
|
|
155
|
+
const config = this.readConfig();
|
|
156
|
+
const project = config.projects[id];
|
|
157
|
+
if (!project)
|
|
158
|
+
return;
|
|
159
|
+
project.apiProjectId = apiProjectId;
|
|
160
|
+
this.writeConfig(config);
|
|
161
|
+
}
|
|
126
162
|
writeConfig(config) {
|
|
127
163
|
writeJsonFileAtomic(this.getConfigFilePath(), config);
|
|
128
164
|
}
|
package/dist/help.js
CHANGED
|
@@ -7,7 +7,7 @@ const BANNER = `
|
|
|
7
7
|
@@@@ ++++ ▗▖ ▗▄▖ ▗▄▖ ▗▄▄▖ ▗▄▄▖ ▗▄▄▄▖ ▗▄▄▖ ▗▄▄▖
|
|
8
8
|
@@@@ ++++ ▐▌ ▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌ ▐▌
|
|
9
9
|
@@@@ ++++ ▐▌ ▐▌ ▐▌▐▌ ▐▌▐▛▀▘ ▐▛▀▚▖▐▛▀▀▘ ▝▀▚▖ ▝▀▚▖
|
|
10
|
-
@@@@ ++++ ▐▙▄▄▖▝▚▄▞▘▝▚▄▞▘▐▌ ▐▌ ▐▌▐▙▄▄▖▗▄▄▞▘▗▄▄▞▘
|
|
10
|
+
@@@@ ++++ ▐▙▄▄▖▝▚▄▞▘▝▚▄▞▘▐▌ ▐▌ ▐▌▐▙▄▄▖▗▄▄▞▘▗▄▄▞▘ (BETA)
|
|
11
11
|
@@@@ ++++
|
|
12
12
|
@@@@ ++++
|
|
13
13
|
@@@@@@@@@@@*++
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const API_URL: string;
|
|
2
|
+
/**
|
|
3
|
+
* HTTP client for the Loopress cloud API (projects, environments, credentials).
|
|
4
|
+
* Authenticated with the console token obtained via `lps login`.
|
|
5
|
+
*/
|
|
6
|
+
export declare class ApiClient {
|
|
7
|
+
private readonly baseUrl;
|
|
8
|
+
private readonly client;
|
|
9
|
+
constructor(token: string, baseUrl?: string);
|
|
10
|
+
get<T = unknown>(path: string): Promise<T>;
|
|
11
|
+
post<T = unknown>(path: string, json?: Record<string, unknown>): Promise<T>;
|
|
12
|
+
put<T = unknown>(path: string, json?: Record<string, unknown>): Promise<T>;
|
|
13
|
+
private request;
|
|
14
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import got from 'got';
|
|
2
|
+
export const API_URL = process.env.LPS_API_URL ?? 'https://api.loopress.dev';
|
|
3
|
+
/**
|
|
4
|
+
* HTTP client for the Loopress cloud API (projects, environments, credentials).
|
|
5
|
+
* Authenticated with the console token obtained via `lps login`.
|
|
6
|
+
*/
|
|
7
|
+
export class ApiClient {
|
|
8
|
+
baseUrl;
|
|
9
|
+
client;
|
|
10
|
+
constructor(token, baseUrl = API_URL) {
|
|
11
|
+
this.baseUrl = baseUrl;
|
|
12
|
+
this.client = got.extend({
|
|
13
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
14
|
+
prefixUrl: baseUrl,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
async get(path) {
|
|
18
|
+
return this.request('get', path);
|
|
19
|
+
}
|
|
20
|
+
async post(path, json) {
|
|
21
|
+
return this.request('post', path, json);
|
|
22
|
+
}
|
|
23
|
+
async put(path, json) {
|
|
24
|
+
return this.request('put', path, json);
|
|
25
|
+
}
|
|
26
|
+
async request(method, path, json) {
|
|
27
|
+
try {
|
|
28
|
+
const response = await this.client(path, { json, method });
|
|
29
|
+
return (response.body ? JSON.parse(response.body) : undefined);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
throw new Error(formatApiError(error, `${this.baseUrl}/${path}`), { cause: error });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function formatApiError(error, url) {
|
|
37
|
+
const err = error;
|
|
38
|
+
const status = err.response?.statusCode;
|
|
39
|
+
if (status === 401) {
|
|
40
|
+
return `Not logged in or session expired on ${url}. Run \`lps login\` again.`;
|
|
41
|
+
}
|
|
42
|
+
if (status === 403) {
|
|
43
|
+
return `Request rejected (${status}) on ${url}: ${err.response?.body ?? err.message}`;
|
|
44
|
+
}
|
|
45
|
+
return err.message ?? String(error);
|
|
46
|
+
}
|
package/dist/lib/base.d.ts
CHANGED
|
@@ -13,7 +13,6 @@ export declare abstract class LoopressCommand extends Command {
|
|
|
13
13
|
protected get rootDir(): string;
|
|
14
14
|
protected get wp(): WpClient;
|
|
15
15
|
init(): Promise<void>;
|
|
16
|
-
protected resolveSnippetPlugin(flag?: string): 'code-snippets' | 'wpcode';
|
|
17
16
|
protected resolveSnippetsPath(override?: string): string;
|
|
18
17
|
private resolveEnvironment;
|
|
19
18
|
}
|
package/dist/lib/base.js
CHANGED
|
@@ -35,11 +35,6 @@ export class LoopressCommand extends Command {
|
|
|
35
35
|
this.localConfig = await readLocalConfig();
|
|
36
36
|
this.siteConfig = this.resolveEnvironment();
|
|
37
37
|
}
|
|
38
|
-
resolveSnippetPlugin(flag) {
|
|
39
|
-
if (flag)
|
|
40
|
-
return flag;
|
|
41
|
-
return this.localConfig.snippetPlugin ?? 'wpcode';
|
|
42
|
-
}
|
|
43
38
|
resolveSnippetsPath(override) {
|
|
44
39
|
if (override)
|
|
45
40
|
return override;
|
package/dist/lib/push-command.js
CHANGED
|
@@ -10,7 +10,7 @@ export class PushCommand extends LoopressCommand {
|
|
|
10
10
|
return super.catch(err);
|
|
11
11
|
}
|
|
12
12
|
async recordDeployment(status) {
|
|
13
|
-
const token = process.env.
|
|
13
|
+
const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token ?? null;
|
|
14
14
|
if (!token)
|
|
15
15
|
return;
|
|
16
16
|
try {
|
package/dist/lib/wp-client.d.ts
CHANGED
|
@@ -12,4 +12,5 @@ export declare class WpClient {
|
|
|
12
12
|
put<T = unknown>(path: string, json?: Record<string, unknown>): Promise<T>;
|
|
13
13
|
private request;
|
|
14
14
|
}
|
|
15
|
+
export declare function isNotFoundError(error: unknown): boolean;
|
|
15
16
|
export declare function formatWpError(error: unknown, url: string): string;
|
package/dist/lib/wp-client.js
CHANGED
|
@@ -34,6 +34,10 @@ export class WpClient {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
export function isNotFoundError(error) {
|
|
38
|
+
const cause = error?.cause;
|
|
39
|
+
return cause?.response?.statusCode === 404;
|
|
40
|
+
}
|
|
37
41
|
export function formatWpError(error, url) {
|
|
38
42
|
const err = error;
|
|
39
43
|
const status = err.response?.statusCode;
|
|
@@ -44,10 +48,27 @@ export function formatWpError(error, url) {
|
|
|
44
48
|
return `Endpoint not found (404) on ${url}. Is the required plugin installed and up to date on the site?`;
|
|
45
49
|
}
|
|
46
50
|
if (status !== undefined) {
|
|
47
|
-
|
|
51
|
+
const reason = extractServerErrorMessage(err.response?.body);
|
|
52
|
+
return reason ? `Request failed (${status}) on ${url}: ${reason}` : `Request failed (${status}) on ${url}.`;
|
|
48
53
|
}
|
|
49
54
|
if (err.name === 'TimeoutError') {
|
|
50
55
|
return `Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s on ${url}. Is the site reachable?`;
|
|
51
56
|
}
|
|
52
57
|
return `Request to ${url} failed: ${err.message ?? String(error)}`;
|
|
53
58
|
}
|
|
59
|
+
// The Loopress plugin's own controllers reply with `{"error": "..."}`; a WP_Error-based
|
|
60
|
+
// core response (e.g. an uncaught fatal formatted by WordPress itself) uses `{"message": "..."}`.
|
|
61
|
+
// Surfacing this is what makes a deliberately clear server-side error (e.g. "Multiple snippet
|
|
62
|
+
// plugins are active...") actually reach the user instead of a bare, unhelpful status code.
|
|
63
|
+
function extractServerErrorMessage(body) {
|
|
64
|
+
if (!body)
|
|
65
|
+
return undefined;
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(body);
|
|
68
|
+
const reason = parsed.error ?? parsed.message;
|
|
69
|
+
return typeof reason === 'string' && reason.trim() ? reason : undefined;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -28,6 +28,10 @@ export interface ProjectConfig {
|
|
|
28
28
|
* ISO timestamp of when the project was added.
|
|
29
29
|
*/
|
|
30
30
|
addedAt: string;
|
|
31
|
+
/**
|
|
32
|
+
* Id of the matching project on the Loopress API, once synced via `lps project sync`.
|
|
33
|
+
*/
|
|
34
|
+
apiProjectId?: string;
|
|
31
35
|
/**
|
|
32
36
|
* Environments for this project, keyed by environment name.
|
|
33
37
|
*/
|
|
@@ -44,6 +48,10 @@ export interface EnvironmentConfig {
|
|
|
44
48
|
* ISO timestamp of when the environment was added.
|
|
45
49
|
*/
|
|
46
50
|
addedAt: string;
|
|
51
|
+
/**
|
|
52
|
+
* Id of the matching environment on the Loopress API, once synced via `lps project sync`.
|
|
53
|
+
*/
|
|
54
|
+
apiEnvironmentId?: string;
|
|
47
55
|
/**
|
|
48
56
|
* Environment name (e.g. "production", "staging").
|
|
49
57
|
*/
|
|
@@ -15,10 +15,6 @@ export interface LoopressProjectConfiguration {
|
|
|
15
15
|
* Project identifier from the global Loopress config. When set, takes precedence over the currently active project in the global config.
|
|
16
16
|
*/
|
|
17
17
|
projectId?: string;
|
|
18
|
-
/**
|
|
19
|
-
* WordPress snippet plugin to use for pull and push commands.
|
|
20
|
-
*/
|
|
21
|
-
snippetPlugin?: 'wpcode' | 'code-snippets';
|
|
22
18
|
/**
|
|
23
19
|
* Pinned plugin versions. Keys are WordPress.org plugin slugs, values are version constraints.
|
|
24
20
|
*/
|
package/dist/types/snippet.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export
|
|
1
|
+
export declare const SNIPPETS_ENDPOINT = "loopress/v1/snippets";
|
|
2
2
|
export type SnippetType = 'css' | 'html' | 'js' | 'php' | 'text';
|
|
3
3
|
export type SnippetInsertMethod = 'auto' | 'shortcode';
|
|
4
4
|
export type SnippetLocation = 'admin' | 'body' | 'everywhere' | 'footer' | 'frontend' | 'header' | 'once';
|
|
@@ -19,21 +19,5 @@ export declare function parseType(raw: unknown): null | SnippetType;
|
|
|
19
19
|
export declare function parseLocation(raw: unknown): null | SnippetLocation;
|
|
20
20
|
export declare function parseInsertMethod(raw: unknown): null | SnippetInsertMethod;
|
|
21
21
|
export declare function defaultLocationForType(type: SnippetType): SnippetLocation;
|
|
22
|
-
export
|
|
23
|
-
|
|
24
|
-
code: string;
|
|
25
|
-
insertMethod: SnippetInsertMethod;
|
|
26
|
-
location: SnippetLocation;
|
|
27
|
-
name: string;
|
|
28
|
-
path: string;
|
|
29
|
-
priority: number;
|
|
30
|
-
shortcodeAttributes: string[];
|
|
31
|
-
tags: string[];
|
|
32
|
-
type: SnippetType;
|
|
33
|
-
}
|
|
34
|
-
export interface SnippetPlugin {
|
|
35
|
-
endpointPath(): string;
|
|
36
|
-
fromRemote(data: Record<string, unknown>): NormalizedSnippet;
|
|
37
|
-
toPayload(snippet: SnippetPayloadInput): Record<string, unknown>;
|
|
38
|
-
}
|
|
39
|
-
export declare function getSnippetPlugin(name: PluginName): SnippetPlugin;
|
|
22
|
+
export declare function stripPhpOpeningTag(code: string): string;
|
|
23
|
+
export declare function normalizeSnippet(data: Record<string, unknown>): NormalizedSnippet;
|