@loopress/cli 0.18.0 → 0.19.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 +138 -74
- package/dist/commands/acf/pull.d.ts +1 -1
- package/dist/commands/acf/pull.js +12 -25
- package/dist/commands/acf/push.d.ts +1 -1
- package/dist/commands/acf/push.js +18 -47
- package/dist/commands/api/pull.d.ts +1 -1
- package/dist/commands/api/pull.js +11 -26
- package/dist/commands/api/push.d.ts +1 -1
- package/dist/commands/api/push.js +21 -27
- package/dist/commands/composer/init.js +11 -1
- package/dist/commands/composer/push.d.ts +1 -0
- package/dist/commands/composer/push.js +17 -4
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.js +79 -0
- package/dist/commands/form/pull.d.ts +1 -2
- package/dist/commands/form/pull.js +13 -32
- package/dist/commands/form/push.d.ts +1 -1
- package/dist/commands/form/push.js +6 -14
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +64 -33
- package/dist/commands/plugin/push.d.ts +1 -1
- package/dist/commands/plugin/push.js +2 -14
- package/dist/commands/project/config.js +4 -0
- package/dist/commands/project/push.d.ts +5 -0
- package/dist/commands/project/push.js +21 -11
- package/dist/commands/seo/pull.d.ts +1 -3
- package/dist/commands/seo/pull.js +15 -43
- package/dist/commands/seo/push.d.ts +1 -1
- package/dist/commands/seo/push.js +4 -17
- package/dist/commands/snippet/publish.js +2 -2
- package/dist/commands/snippet/pull.d.ts +1 -4
- package/dist/commands/snippet/pull.js +12 -60
- package/dist/commands/snippet/push.d.ts +1 -1
- package/dist/commands/snippet/push.js +4 -13
- package/dist/commands/status.d.ts +4 -0
- package/dist/commands/status.js +25 -4
- package/dist/config/project-config.manager.js +2 -2
- package/dist/lib/api-client.d.ts +2 -1
- package/dist/lib/api-client.js +10 -3
- package/dist/lib/base.d.ts +9 -0
- package/dist/lib/base.js +61 -2
- package/dist/lib/find-orphaned-files.d.ts +7 -0
- package/dist/lib/find-orphaned-files.js +29 -0
- package/dist/lib/interactive.d.ts +1 -0
- package/dist/lib/interactive.js +7 -0
- package/dist/lib/load-files.d.ts +5 -0
- package/dist/lib/load-files.js +31 -0
- package/dist/lib/push-command.d.ts +7 -0
- package/dist/lib/push-command.js +40 -2
- package/dist/lib/wp-client.d.ts +9 -4
- package/dist/lib/wp-client.js +26 -12
- package/dist/utils/composer.d.ts +3 -0
- package/dist/utils/seo-format.d.ts +1 -0
- package/dist/utils/seo-format.js +5 -0
- package/dist/utils/snippet-format.d.ts +2 -0
- package/dist/utils/snippet-format.js +27 -1
- package/dist/utils/to-slug.d.ts +1 -0
- package/dist/utils/to-slug.js +7 -0
- package/oclif.manifest.json +469 -61
- package/package.json +10 -4
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
2
|
import { Listr } from 'listr2';
|
|
3
|
-
import { mkdir,
|
|
4
|
-
import {
|
|
5
|
-
import slugify from 'slugify';
|
|
3
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
6
5
|
import { LoopressCommand } from '../../lib/base.js';
|
|
7
|
-
import {
|
|
6
|
+
import { findOrphanedFiles, numericPrefixKey } from '../../lib/find-orphaned-files.js';
|
|
7
|
+
import { buildMetaFile, buildSnippetFile, normalizeSnippet, SNIPPETS_ENDPOINT } from '../../utils/snippet-format.js';
|
|
8
|
+
import { toSlug } from '../../utils/to-slug.js';
|
|
8
9
|
const EXTENSIONS = {
|
|
9
10
|
css: 'css',
|
|
10
11
|
html: 'html',
|
|
@@ -12,32 +13,6 @@ const EXTENSIONS = {
|
|
|
12
13
|
php: 'php',
|
|
13
14
|
text: 'txt',
|
|
14
15
|
};
|
|
15
|
-
export function buildSnippetFile(snippet) {
|
|
16
|
-
if (snippet.type === 'php' && !snippet.code.trimStart().startsWith('<?')) {
|
|
17
|
-
return `<?php\n\n${snippet.code}`;
|
|
18
|
-
}
|
|
19
|
-
return snippet.code;
|
|
20
|
-
}
|
|
21
|
-
export function buildMetaFile(snippet) {
|
|
22
|
-
const meta = {
|
|
23
|
-
id: snippet.id,
|
|
24
|
-
name: snippet.name,
|
|
25
|
-
type: snippet.type,
|
|
26
|
-
active: snippet.active,
|
|
27
|
-
location: snippet.location,
|
|
28
|
-
};
|
|
29
|
-
if (snippet.description)
|
|
30
|
-
meta.description = snippet.description;
|
|
31
|
-
if (snippet.tags.length > 0)
|
|
32
|
-
meta.tags = snippet.tags;
|
|
33
|
-
if (snippet.insertMethod === 'shortcode')
|
|
34
|
-
meta.insertMethod = snippet.insertMethod;
|
|
35
|
-
if (snippet.priority !== 10)
|
|
36
|
-
meta.priority = snippet.priority;
|
|
37
|
-
if (snippet.shortcodeAttributes.length > 0)
|
|
38
|
-
meta.shortcodeAttributes = snippet.shortcodeAttributes;
|
|
39
|
-
return JSON.stringify(meta, null, 2) + '\n';
|
|
40
|
-
}
|
|
41
16
|
export default class Pull extends LoopressCommand {
|
|
42
17
|
static args = {
|
|
43
18
|
path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
|
|
@@ -46,6 +21,7 @@ export default class Pull extends LoopressCommand {
|
|
|
46
21
|
static examples = ['$ lps snippet pull', '$ lps snippet pull --path ./snippets'];
|
|
47
22
|
static flags = {
|
|
48
23
|
...LoopressCommand.dryRunFlag,
|
|
24
|
+
...LoopressCommand.yesFlag,
|
|
49
25
|
};
|
|
50
26
|
async run() {
|
|
51
27
|
const { args } = await this.parse(Pull);
|
|
@@ -60,7 +36,10 @@ export default class Pull extends LoopressCommand {
|
|
|
60
36
|
// Files following the `<id>-<slug>` convention whose id is no longer in the current
|
|
61
37
|
// remote list belong to a snippet that was deleted on WordPress. Left on disk, they'd
|
|
62
38
|
// silently come back to life the next time `snippet push` runs.
|
|
63
|
-
const orphans = await
|
|
39
|
+
const orphans = await findOrphanedFiles(path, new Set(pullable.map((snippet) => String(snippet.id))), {
|
|
40
|
+
extensions: ['.json', ...Object.values(EXTENSIONS).map((ext) => `.${ext}`)],
|
|
41
|
+
key: numericPrefixKey,
|
|
42
|
+
});
|
|
64
43
|
if (this.dryRun) {
|
|
65
44
|
this.log(`[dry-run] Would pull ${snippets.length} snippet${snippets.length === 1 ? '' : 's'} to ${path}`);
|
|
66
45
|
if (orphans.length > 0) {
|
|
@@ -72,44 +51,17 @@ export default class Pull extends LoopressCommand {
|
|
|
72
51
|
await new Listr(pullable.map((snippet) => ({
|
|
73
52
|
async task(_ctx, task) {
|
|
74
53
|
const ext = EXTENSIONS[snippet.type];
|
|
75
|
-
const
|
|
76
|
-
const base = `${snippet.id}-${slug}`;
|
|
54
|
+
const base = `${snippet.id}-${toSlug(snippet.name)}`;
|
|
77
55
|
await writeFile(join(path, `${base}.${ext}`), buildSnippetFile(snippet));
|
|
78
56
|
await writeFile(join(path, `${base}.json`), buildMetaFile(snippet));
|
|
79
57
|
task.output = `Pulled: ${snippet.name}`;
|
|
80
58
|
},
|
|
81
59
|
title: `Pull ${snippet.name}`,
|
|
82
60
|
}))).run();
|
|
83
|
-
|
|
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
|
-
}
|
|
61
|
+
await this.removeOrphanedFiles(path, orphans, 'whose snippet no longer exists on WordPress');
|
|
88
62
|
this.log(`Pulled ${pullable.length} snippet${pullable.length === 1 ? '' : 's'} to ${path}`);
|
|
89
63
|
if (skipped > 0) {
|
|
90
64
|
this.warn(`${skipped} snippet${skipped === 1 ? '' : 's'} skipped because they have no name`);
|
|
91
65
|
}
|
|
92
66
|
}
|
|
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
|
-
}
|
|
115
67
|
}
|
|
@@ -6,9 +6,9 @@ export default class Push extends PushCommand {
|
|
|
6
6
|
static description: string;
|
|
7
7
|
static examples: string[];
|
|
8
8
|
static flags: {
|
|
9
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
10
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
11
|
};
|
|
11
|
-
private failedCount;
|
|
12
12
|
run(): Promise<void>;
|
|
13
13
|
private ensureCanonicalFilename;
|
|
14
14
|
private loadSnippets;
|
|
@@ -2,11 +2,11 @@ import { Args } from '@oclif/core';
|
|
|
2
2
|
import { Listr } from 'listr2';
|
|
3
3
|
import { readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, extname, join } from 'node:path';
|
|
5
|
-
import slugify from 'slugify';
|
|
6
5
|
import { loadSnippets as loadSnippetsFromDisk } from '../../lib/load-snippets.js';
|
|
7
6
|
import { PushCommand } from '../../lib/push-command.js';
|
|
8
7
|
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
9
8
|
import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../../utils/snippet-format.js';
|
|
9
|
+
import { toSlug } from '../../utils/to-slug.js';
|
|
10
10
|
export default class Push extends PushCommand {
|
|
11
11
|
static args = {
|
|
12
12
|
path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
|
|
@@ -15,8 +15,8 @@ export default class Push extends PushCommand {
|
|
|
15
15
|
static examples = ['$ lps snippet push', '$ lps snippet push --path ./snippets'];
|
|
16
16
|
static flags = {
|
|
17
17
|
...PushCommand.dryRunFlag,
|
|
18
|
+
...PushCommand.yesFlag,
|
|
18
19
|
};
|
|
19
|
-
failedCount = 0;
|
|
20
20
|
async run() {
|
|
21
21
|
const { args } = await this.parse(Push);
|
|
22
22
|
const { url } = this.siteConfig;
|
|
@@ -44,7 +44,7 @@ export default class Push extends PushCommand {
|
|
|
44
44
|
const dir = dirname(snippet.path);
|
|
45
45
|
const ext = extname(snippet.path);
|
|
46
46
|
const currentBase = basename(snippet.path, ext);
|
|
47
|
-
const canonicalBase = `${id}-${
|
|
47
|
+
const canonicalBase = `${id}-${toSlug(name)}`;
|
|
48
48
|
const oldMetaPath = join(dir, `${currentBase}.json`);
|
|
49
49
|
let meta = {};
|
|
50
50
|
try {
|
|
@@ -79,9 +79,6 @@ export default class Push extends PushCommand {
|
|
|
79
79
|
this.error(error.message);
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
-
// Throwing on failure (rather than returning a boolean) is what lets Listr mark the task as
|
|
83
|
-
// failed (red cross) instead of completed; `exitOnError: false` on the task list still lets
|
|
84
|
-
// sibling snippets push regardless.
|
|
85
82
|
async pushSnippet(snippet, task) {
|
|
86
83
|
if (this.dryRun) {
|
|
87
84
|
if (task)
|
|
@@ -114,13 +111,7 @@ export default class Push extends PushCommand {
|
|
|
114
111
|
task.output = `Pushed: ${snippet.name}`;
|
|
115
112
|
}
|
|
116
113
|
catch (error) {
|
|
117
|
-
|
|
118
|
-
if (task)
|
|
119
|
-
task.output = message;
|
|
120
|
-
else
|
|
121
|
-
this.warn(` ${message}`);
|
|
122
|
-
this.failedCount++;
|
|
123
|
-
throw error;
|
|
114
|
+
this.reportTaskFailure(`Failed to push ${snippet.name}: ${error.message}`, error, task);
|
|
124
115
|
}
|
|
125
116
|
}
|
|
126
117
|
toPayload(snippet) {
|
|
@@ -2,7 +2,11 @@ import { Command } from '@oclif/core';
|
|
|
2
2
|
export default class Status extends Command {
|
|
3
3
|
static description: string;
|
|
4
4
|
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
};
|
|
5
8
|
run(): Promise<void>;
|
|
6
9
|
private reportActiveProject;
|
|
10
|
+
private reportEnvOverride;
|
|
7
11
|
private reportPinnedProject;
|
|
8
12
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import { Command, ux } from '@oclif/core';
|
|
1
|
+
import { Command, Flags, ux } from '@oclif/core';
|
|
2
2
|
import { configManager } from '../config/project-config.manager.js';
|
|
3
3
|
import { readLocalConfig } from '../utils/loopress-config.js';
|
|
4
4
|
const c = ux.colorize;
|
|
5
5
|
export default class Status extends Command {
|
|
6
6
|
static description = 'Show which WordPress project and environment commands will target';
|
|
7
|
-
static examples = ['$ lps status'];
|
|
7
|
+
static examples = ['$ lps status', '$ lps status --env staging'];
|
|
8
|
+
static flags = {
|
|
9
|
+
env: Flags.string({ description: 'Show what would be targeted with this environment, as other commands do with --env' }),
|
|
10
|
+
};
|
|
8
11
|
async run() {
|
|
9
|
-
await this.parse(Status);
|
|
12
|
+
const { flags } = await this.parse(Status);
|
|
10
13
|
const localConfig = await readLocalConfig();
|
|
11
|
-
if (
|
|
14
|
+
if (flags.env) {
|
|
15
|
+
this.reportEnvOverride(localConfig.projectId, flags.env);
|
|
16
|
+
}
|
|
17
|
+
else if (localConfig.projectId) {
|
|
12
18
|
this.reportPinnedProject(localConfig.projectId);
|
|
13
19
|
}
|
|
14
20
|
else {
|
|
@@ -32,6 +38,21 @@ export default class Status extends Command {
|
|
|
32
38
|
this.log(`Project: ${project.name} (${env.name})`);
|
|
33
39
|
this.log(`URL: ${env.url}`);
|
|
34
40
|
}
|
|
41
|
+
// Mirrors base.ts:resolveEnvironment with --env: the targeted project comes from
|
|
42
|
+
// loopress.json when pinned, from the globally active project otherwise.
|
|
43
|
+
reportEnvOverride(pinnedProjectId, envName) {
|
|
44
|
+
const project = pinnedProjectId ? configManager.getProject(pinnedProjectId) : configManager.getCurrentProject();
|
|
45
|
+
if (!project) {
|
|
46
|
+
this.log('No project configured. Run `lps project config` first.');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const env = project.environments[envName];
|
|
50
|
+
if (!env) {
|
|
51
|
+
this.error(`Environment "${envName}" not found in project "${project.name}". Available: ${Object.keys(project.environments).join(', ')}`);
|
|
52
|
+
}
|
|
53
|
+
this.log(`Project: ${project.name} (${env.name}, via --env)`);
|
|
54
|
+
this.log(`URL: ${env.url}`);
|
|
55
|
+
}
|
|
35
56
|
reportPinnedProject(projectId) {
|
|
36
57
|
const project = configManager.getProject(projectId);
|
|
37
58
|
if (!project) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import
|
|
3
|
+
import { toSlug } from '../utils/to-slug.js';
|
|
4
4
|
import { readJsonFile, writeJsonFileAtomic } from './json-file.js';
|
|
5
5
|
export class ProjectConfigManager {
|
|
6
6
|
configDir;
|
|
@@ -9,7 +9,7 @@ export class ProjectConfigManager {
|
|
|
9
9
|
}
|
|
10
10
|
createProjectId(name) {
|
|
11
11
|
const config = this.readConfig();
|
|
12
|
-
const base =
|
|
12
|
+
const base = toSlug(name, 'project');
|
|
13
13
|
let id = base;
|
|
14
14
|
let suffix = 2;
|
|
15
15
|
while (config.projects[id]) {
|
package/dist/lib/api-client.d.ts
CHANGED
|
@@ -6,7 +6,8 @@ export declare const API_URL: string;
|
|
|
6
6
|
export declare class ApiClient {
|
|
7
7
|
private readonly baseUrl;
|
|
8
8
|
private readonly client;
|
|
9
|
-
|
|
9
|
+
private readonly timeoutMs;
|
|
10
|
+
constructor(token: string, baseUrl?: string, timeoutMs?: number);
|
|
10
11
|
get<T = unknown>(path: string): Promise<T>;
|
|
11
12
|
post<T = unknown>(path: string, json?: Record<string, unknown>): Promise<T>;
|
|
12
13
|
put<T = unknown>(path: string, json?: Record<string, unknown>): Promise<T>;
|
package/dist/lib/api-client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import got from 'got';
|
|
2
|
+
import { REQUEST_TIMEOUT_MS } from './wp-client.js';
|
|
2
3
|
export const API_URL = process.env.LPS_API_URL ?? 'https://api.loopress.dev';
|
|
3
4
|
/**
|
|
4
5
|
* HTTP client for the Loopress cloud API (projects, environments, credentials).
|
|
@@ -7,11 +8,14 @@ export const API_URL = process.env.LPS_API_URL ?? 'https://api.loopress.dev';
|
|
|
7
8
|
export class ApiClient {
|
|
8
9
|
baseUrl;
|
|
9
10
|
client;
|
|
10
|
-
|
|
11
|
+
timeoutMs;
|
|
12
|
+
constructor(token, baseUrl = API_URL, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
11
13
|
this.baseUrl = baseUrl;
|
|
14
|
+
this.timeoutMs = timeoutMs;
|
|
12
15
|
this.client = got.extend({
|
|
13
16
|
headers: { Authorization: `Bearer ${token}` },
|
|
14
17
|
prefixUrl: baseUrl,
|
|
18
|
+
timeout: { request: timeoutMs },
|
|
15
19
|
});
|
|
16
20
|
}
|
|
17
21
|
async get(path) {
|
|
@@ -29,11 +33,11 @@ export class ApiClient {
|
|
|
29
33
|
return (response.body ? JSON.parse(response.body) : undefined);
|
|
30
34
|
}
|
|
31
35
|
catch (error) {
|
|
32
|
-
throw new Error(formatApiError(error, `${this.baseUrl}/${path}
|
|
36
|
+
throw new Error(formatApiError(error, `${this.baseUrl}/${path}`, this.timeoutMs), { cause: error });
|
|
33
37
|
}
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
|
-
function formatApiError(error, url) {
|
|
40
|
+
function formatApiError(error, url, timeoutMs) {
|
|
37
41
|
const err = error;
|
|
38
42
|
const status = err.response?.statusCode;
|
|
39
43
|
if (status === 401) {
|
|
@@ -42,5 +46,8 @@ function formatApiError(error, url) {
|
|
|
42
46
|
if (status === 403) {
|
|
43
47
|
return `Request rejected (${status}) on ${url}: ${err.response?.body ?? err.message}`;
|
|
44
48
|
}
|
|
49
|
+
if (err.name === 'TimeoutError') {
|
|
50
|
+
return `Request timed out after ${timeoutMs / 1000}s on ${url}. Check your network connection, or the Loopress API may be temporarily unreachable.`;
|
|
51
|
+
}
|
|
45
52
|
return err.message ?? String(error);
|
|
46
53
|
}
|
package/dist/lib/base.d.ts
CHANGED
|
@@ -3,20 +3,29 @@ import { EnvironmentConfig } from '../types/config.js';
|
|
|
3
3
|
import { LoopressLocalConfig } from '../utils/loopress-config.js';
|
|
4
4
|
import { WpClient } from './wp-client.js';
|
|
5
5
|
export declare abstract class LoopressCommand extends Command {
|
|
6
|
+
static baseFlags: {
|
|
7
|
+
env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
};
|
|
6
9
|
static dryRunFlag: {
|
|
7
10
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
11
|
};
|
|
12
|
+
static yesFlag: {
|
|
13
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
|
+
};
|
|
9
15
|
protected dryRun: boolean;
|
|
10
16
|
protected localConfig: LoopressLocalConfig;
|
|
11
17
|
protected siteConfig: EnvironmentConfig;
|
|
18
|
+
protected yes: boolean;
|
|
12
19
|
private wpClient?;
|
|
13
20
|
protected get rootDir(): string;
|
|
14
21
|
protected get wp(): WpClient;
|
|
15
22
|
init(): Promise<void>;
|
|
23
|
+
protected removeOrphanedFiles(dir: string, orphans: string[], reason: string): Promise<void>;
|
|
16
24
|
protected resolveAcfPath(override?: string): string;
|
|
17
25
|
protected resolveApiPath(override?: string): string;
|
|
18
26
|
protected resolveFormPath(override?: string): string;
|
|
19
27
|
protected resolveSeoPath(override?: string): string;
|
|
20
28
|
protected resolveSnippetsPath(override?: string): string;
|
|
29
|
+
private pickEnvironment;
|
|
21
30
|
private resolveEnvironment;
|
|
22
31
|
}
|
package/dist/lib/base.js
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
1
2
|
import { Command, Flags } from '@oclif/core';
|
|
3
|
+
import { rm } from 'node:fs/promises';
|
|
2
4
|
import { join } from 'node:path';
|
|
3
5
|
import { configManager } from '../config/project-config.manager.js';
|
|
4
6
|
import { readLocalConfig } from '../utils/loopress-config.js';
|
|
7
|
+
import { isInteractive } from './interactive.js';
|
|
5
8
|
import { WpClient } from './wp-client.js';
|
|
6
9
|
export class LoopressCommand extends Command {
|
|
10
|
+
// On every subclass without opt-in: targeting an environment explicitly beats depending on
|
|
11
|
+
// the machine-wide mutable state of `lps project switch`, which is shared across terminals.
|
|
12
|
+
static baseFlags = {
|
|
13
|
+
env: Flags.string({
|
|
14
|
+
description: 'Target environment by name, overriding the globally active one (lps project switch)',
|
|
15
|
+
}),
|
|
16
|
+
};
|
|
7
17
|
static dryRunFlag = {
|
|
8
18
|
'dry-run': Flags.boolean({ char: 'd', description: 'Show what would change without making changes' }),
|
|
9
19
|
};
|
|
20
|
+
static yesFlag = {
|
|
21
|
+
yes: Flags.boolean({ char: 'y', description: 'Answer yes to confirmation prompts' }),
|
|
22
|
+
};
|
|
10
23
|
dryRun = false;
|
|
11
24
|
localConfig = {};
|
|
12
25
|
siteConfig;
|
|
26
|
+
yes = false;
|
|
13
27
|
wpClient;
|
|
14
28
|
get rootDir() {
|
|
15
29
|
return this.localConfig.rootDir ?? '.';
|
|
@@ -28,12 +42,41 @@ export class LoopressCommand extends Command {
|
|
|
28
42
|
await super.init();
|
|
29
43
|
const { flags } = (await this.parse({
|
|
30
44
|
args: this.ctor.args,
|
|
45
|
+
baseFlags: this.ctor.baseFlags,
|
|
31
46
|
flags: this.ctor.flags,
|
|
32
47
|
strict: this.ctor.strict,
|
|
33
48
|
}));
|
|
34
49
|
this.dryRun = Boolean(flags['dry-run']);
|
|
50
|
+
this.yes = Boolean(flags.yes);
|
|
35
51
|
this.localConfig = await readLocalConfig();
|
|
36
|
-
this.siteConfig = this.resolveEnvironment();
|
|
52
|
+
this.siteConfig = this.resolveEnvironment(flags.env);
|
|
53
|
+
}
|
|
54
|
+
// Orphan cleanup shared by every pull command (see Push Deletion Rules in the product docs):
|
|
55
|
+
// in a TTY the list is shown and confirmed first (Enter accepts), --yes skips the prompt, and
|
|
56
|
+
// outside a TTY the files are removed with a warning, the behavior scripts relied on before
|
|
57
|
+
// the confirmation existed.
|
|
58
|
+
async removeOrphanedFiles(dir, orphans, reason) {
|
|
59
|
+
if (orphans.length === 0)
|
|
60
|
+
return;
|
|
61
|
+
const description = `${orphans.length} local file${orphans.length === 1 ? '' : 's'} ${reason}: ${orphans.join(', ')}`;
|
|
62
|
+
if (!this.yes && isInteractive()) {
|
|
63
|
+
const proceed = await confirm({ default: true, message: `Remove ${description}?` });
|
|
64
|
+
if (!proceed) {
|
|
65
|
+
this.log(`Kept ${description}`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const file of orphans)
|
|
70
|
+
await rm(join(dir, file), { force: true });
|
|
71
|
+
// Non-interactive removals (no TTY, CI) keep the original warn so existing
|
|
72
|
+
// scripts that parse stderr or check exit status are not broken. Confirmed
|
|
73
|
+
// interactive removals are intentional, so log() is appropriate.
|
|
74
|
+
if (this.yes || isInteractive()) {
|
|
75
|
+
this.log(`Removed ${description}`);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
this.warn(`Removed ${description}`);
|
|
79
|
+
}
|
|
37
80
|
}
|
|
38
81
|
resolveAcfPath(override) {
|
|
39
82
|
if (override)
|
|
@@ -60,7 +103,14 @@ export class LoopressCommand extends Command {
|
|
|
60
103
|
return override;
|
|
61
104
|
return join(this.rootDir, this.localConfig.snippetsDir ?? 'snippets');
|
|
62
105
|
}
|
|
63
|
-
|
|
106
|
+
pickEnvironment(project, envName) {
|
|
107
|
+
const env = project.environments[envName];
|
|
108
|
+
if (!env) {
|
|
109
|
+
this.error(`Environment "${envName}" not found in project "${project.name}". Available: ${Object.keys(project.environments).join(', ')}`);
|
|
110
|
+
}
|
|
111
|
+
return env;
|
|
112
|
+
}
|
|
113
|
+
resolveEnvironment(envName) {
|
|
64
114
|
if (this.localConfig.projectId) {
|
|
65
115
|
const project = configManager.getProject(this.localConfig.projectId);
|
|
66
116
|
if (!project) {
|
|
@@ -70,6 +120,8 @@ export class LoopressCommand extends Command {
|
|
|
70
120
|
if (envNames.length === 0) {
|
|
71
121
|
this.error(`Project "${project.name}" has no environments configured. Run \`lps project config\` to add one.`);
|
|
72
122
|
}
|
|
123
|
+
if (envName)
|
|
124
|
+
return this.pickEnvironment(project, envName);
|
|
73
125
|
if (envNames.length === 1) {
|
|
74
126
|
return project.environments[envNames[0]];
|
|
75
127
|
}
|
|
@@ -80,6 +132,13 @@ export class LoopressCommand extends Command {
|
|
|
80
132
|
}
|
|
81
133
|
return currentEnv;
|
|
82
134
|
}
|
|
135
|
+
if (envName) {
|
|
136
|
+
const current = configManager.getCurrentProject();
|
|
137
|
+
if (!current) {
|
|
138
|
+
this.error('No project configured. Run `lps project config` first.');
|
|
139
|
+
}
|
|
140
|
+
return this.pickEnvironment(current, envName);
|
|
141
|
+
}
|
|
83
142
|
const env = configManager.getCurrentEnv();
|
|
84
143
|
if (env)
|
|
85
144
|
return env;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface OrphanMatcher {
|
|
2
|
+
extensions: string[];
|
|
3
|
+
key(base: string): null | string;
|
|
4
|
+
}
|
|
5
|
+
export declare function findOrphanedFiles(dir: string, keep: Set<string>, matcher: OrphanMatcher): Promise<string[]>;
|
|
6
|
+
export declare function numericPrefixKey(base: string): null | string;
|
|
7
|
+
export declare function basenameKey(base: string): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
export async function findOrphanedFiles(dir, keep, matcher) {
|
|
4
|
+
let files;
|
|
5
|
+
try {
|
|
6
|
+
files = await readdir(dir);
|
|
7
|
+
}
|
|
8
|
+
catch (error) {
|
|
9
|
+
if (error.code === 'ENOENT')
|
|
10
|
+
return [];
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
return files.filter((file) => {
|
|
14
|
+
const ext = extname(file);
|
|
15
|
+
if (!matcher.extensions.includes(ext))
|
|
16
|
+
return false;
|
|
17
|
+
const key = matcher.key(file.slice(0, -ext.length));
|
|
18
|
+
return key !== null && !keep.has(key);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
// The `<id>-<slug>.*` filename convention: the numeric prefix is the identity.
|
|
22
|
+
export function numericPrefixKey(base) {
|
|
23
|
+
const match = /^(\d+)-/.exec(base);
|
|
24
|
+
return match?.[1] ?? null;
|
|
25
|
+
}
|
|
26
|
+
// The `<key>.<ext>` filename convention: the whole basename is the identity.
|
|
27
|
+
export function basenameKey(base) {
|
|
28
|
+
return base;
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isInteractive(): boolean;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Whether prompts can be shown at all: a real terminal on both ends, and not a CI runner.
|
|
2
|
+
// CI=1 with a pseudo-TTY still counts as non-interactive, which is what CI users expect.
|
|
3
|
+
// Every command that prompts routes its decision through this so the CLI never hangs on a
|
|
4
|
+
// question nobody can answer.
|
|
5
|
+
export function isInteractive() {
|
|
6
|
+
return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY) && !process.env.CI;
|
|
7
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { extname, join } from 'node:path';
|
|
3
|
+
// Loads every `<extension>` file of a flat directory through `parse`. One file is read in
|
|
4
|
+
// isolation: a corrupted or hand-broken file (unreadable, bad JSON, failed validation) must
|
|
5
|
+
// only skip that file, never abort loading the rest of the directory, so `parse` throwing is
|
|
6
|
+
// reported through `onSkip` and the loop moves on. ENOENT on the directory itself means
|
|
7
|
+
// "nothing to load", not an error.
|
|
8
|
+
export async function loadFiles(dir, options) {
|
|
9
|
+
let entries;
|
|
10
|
+
try {
|
|
11
|
+
entries = await readdir(dir);
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error.code === 'ENOENT')
|
|
15
|
+
return [];
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
const items = [];
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
if (extname(entry) !== options.extension)
|
|
21
|
+
continue;
|
|
22
|
+
const filePath = join(dir, entry);
|
|
23
|
+
try {
|
|
24
|
+
items.push(options.parse(await readFile(filePath, 'utf8'), filePath));
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
options.onSkip(`Skipping "${filePath}": ${error.message}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return items;
|
|
31
|
+
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { LoopressCommand } from './base.js';
|
|
2
2
|
export declare abstract class PushCommand extends LoopressCommand {
|
|
3
|
+
protected failedCount: number;
|
|
4
|
+
private refusedByGuard;
|
|
3
5
|
catch(err: Error): Promise<void>;
|
|
6
|
+
protected guardProductionPush(): Promise<void>;
|
|
7
|
+
init(): Promise<void>;
|
|
4
8
|
protected recordDeployment(status: 'failure' | 'success'): Promise<void>;
|
|
5
9
|
protected recordSuccess(): Promise<void>;
|
|
10
|
+
protected reportTaskFailure(message: string, error: unknown, task?: {
|
|
11
|
+
output: string;
|
|
12
|
+
}): never;
|
|
6
13
|
}
|
package/dist/lib/push-command.js
CHANGED
|
@@ -1,14 +1,39 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
1
2
|
import got from 'got';
|
|
2
3
|
import { authManager } from '../config/auth.manager.js';
|
|
4
|
+
import { API_URL } from './api-client.js';
|
|
3
5
|
import { LoopressCommand } from './base.js';
|
|
4
|
-
|
|
6
|
+
import { isInteractive } from './interactive.js';
|
|
5
7
|
export class PushCommand extends LoopressCommand {
|
|
8
|
+
failedCount = 0;
|
|
9
|
+
// Refusals of the production guard never reach the site, so they must not pollute the
|
|
10
|
+
// deployment history with failure records.
|
|
11
|
+
refusedByGuard = false;
|
|
6
12
|
async catch(err) {
|
|
7
|
-
if (!this.dryRun && this.siteConfig) {
|
|
13
|
+
if (!this.dryRun && !this.refusedByGuard && this.siteConfig) {
|
|
8
14
|
await this.recordDeployment('failure');
|
|
9
15
|
}
|
|
10
16
|
return super.catch(err);
|
|
11
17
|
}
|
|
18
|
+
// Pushing to an environment named "production" needs explicit intent: a TTY confirmation
|
|
19
|
+
// (Enter accepts), or --yes in scripts and CI. Dry runs are exempt, they change nothing.
|
|
20
|
+
async guardProductionPush() {
|
|
21
|
+
if (this.siteConfig.name.toLowerCase() !== 'production' || this.dryRun || this.yes)
|
|
22
|
+
return;
|
|
23
|
+
if (!isInteractive()) {
|
|
24
|
+
this.refusedByGuard = true;
|
|
25
|
+
this.error('Target environment is "production". Pass --yes to confirm the push in a non-interactive run.');
|
|
26
|
+
}
|
|
27
|
+
const proceed = await confirm({ default: true, message: `Push to production (${this.siteConfig.url})?` });
|
|
28
|
+
if (!proceed) {
|
|
29
|
+
this.refusedByGuard = true;
|
|
30
|
+
this.error('Aborted.');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async init() {
|
|
34
|
+
await super.init();
|
|
35
|
+
await this.guardProductionPush();
|
|
36
|
+
}
|
|
12
37
|
async recordDeployment(status) {
|
|
13
38
|
const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token ?? null;
|
|
14
39
|
if (!token)
|
|
@@ -28,4 +53,17 @@ export class PushCommand extends LoopressCommand {
|
|
|
28
53
|
if (!this.dryRun)
|
|
29
54
|
await this.recordDeployment('success');
|
|
30
55
|
}
|
|
56
|
+
// Reports one failed Listr task: status lines go through `task.output` when running inside a
|
|
57
|
+
// Listr renderer (this.warn would race with the repaint), and fall back to `this.warn` when
|
|
58
|
+
// called without a task (e.g. directly in tests). Rethrowing is what lets Listr mark the task
|
|
59
|
+
// failed (red cross) instead of completed; `exitOnError: false` on the list still lets sibling
|
|
60
|
+
// tasks run, and the accumulated `failedCount` drives the command's final "N failed" error.
|
|
61
|
+
reportTaskFailure(message, error, task) {
|
|
62
|
+
if (task)
|
|
63
|
+
task.output = message;
|
|
64
|
+
else
|
|
65
|
+
this.warn(` ${message}`);
|
|
66
|
+
this.failedCount++;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
31
69
|
}
|
package/dist/lib/wp-client.d.ts
CHANGED
|
@@ -7,10 +7,15 @@ export declare class WpClient {
|
|
|
7
7
|
private readonly siteUrl;
|
|
8
8
|
private readonly client;
|
|
9
9
|
constructor(siteUrl: string, token: string);
|
|
10
|
-
get<T>(path: string): Promise<T>;
|
|
11
|
-
post<T = unknown>(path: string, json?: Record<string, unknown
|
|
12
|
-
put<T = unknown>(path: string, json?: Record<string, unknown
|
|
10
|
+
get<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
11
|
+
post<T = unknown>(path: string, json?: Record<string, unknown>, options?: RequestOptions): Promise<T>;
|
|
12
|
+
put<T = unknown>(path: string, json?: Record<string, unknown>, options?: RequestOptions): Promise<T>;
|
|
13
13
|
private request;
|
|
14
14
|
}
|
|
15
|
+
interface RequestOptions {
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
}
|
|
15
18
|
export declare function isNotFoundError(error: unknown): boolean;
|
|
16
|
-
export declare function
|
|
19
|
+
export declare function isTimeoutError(error: unknown): boolean;
|
|
20
|
+
export declare function formatWpError(error: unknown, url: string, timeoutMs?: number): string;
|
|
21
|
+
export {};
|