@loopress/cli 0.20.1 → 0.22.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 +254 -75
- package/dist/commands/acf/list.d.ts +2 -2
- package/dist/commands/acf/list.js +4 -6
- package/dist/commands/acf/pull.js +9 -22
- package/dist/commands/acf/push.js +4 -7
- package/dist/commands/api/list.d.ts +7 -4
- package/dist/commands/api/list.js +5 -11
- package/dist/commands/api/publish.js +7 -23
- package/dist/commands/api/pull.d.ts +8 -1
- package/dist/commands/api/pull.js +22 -18
- package/dist/commands/api/push.d.ts +7 -1
- package/dist/commands/api/push.js +50 -23
- package/dist/commands/composer/init.js +6 -6
- package/dist/commands/composer/pull.d.ts +7 -1
- package/dist/commands/composer/pull.js +4 -2
- package/dist/commands/composer/push.d.ts +8 -1
- package/dist/commands/composer/push.js +3 -1
- package/dist/commands/dev.d.ts +13 -0
- package/dist/commands/dev.js +97 -0
- package/dist/commands/doctor.js +6 -5
- package/dist/commands/form/list.d.ts +2 -4
- package/dist/commands/form/list.js +3 -10
- package/dist/commands/form/pull.js +15 -24
- package/dist/commands/form/push.js +17 -42
- package/dist/commands/init.js +7 -7
- package/dist/commands/login.js +9 -5
- package/dist/commands/logout.js +2 -1
- package/dist/commands/page/list.d.ts +2 -4
- package/dist/commands/page/list.js +3 -10
- package/dist/commands/page/pull.d.ts +13 -1
- package/dist/commands/page/pull.js +21 -29
- package/dist/commands/page/push.d.ts +11 -1
- package/dist/commands/page/push.js +31 -49
- package/dist/commands/plugin/add.js +3 -3
- package/dist/commands/plugin/pull.d.ts +8 -1
- package/dist/commands/plugin/pull.js +11 -11
- package/dist/commands/plugin/push.d.ts +12 -1
- package/dist/commands/plugin/push.js +41 -27
- package/dist/commands/project/config.js +12 -9
- package/dist/commands/project/list.d.ts +16 -1
- package/dist/commands/project/list.js +22 -16
- package/dist/commands/project/pull.js +3 -2
- package/dist/commands/project/push.d.ts +5 -0
- package/dist/commands/project/push.js +84 -63
- package/dist/commands/project/switch.js +2 -2
- package/dist/commands/promote.d.ts +18 -0
- package/dist/commands/promote.js +90 -0
- package/dist/commands/pull.d.ts +12 -0
- package/dist/commands/pull.js +51 -0
- package/dist/commands/push.d.ts +13 -0
- package/dist/commands/push.js +57 -0
- package/dist/commands/seo/list.d.ts +9 -2
- package/dist/commands/seo/list.js +9 -11
- package/dist/commands/seo/pull.js +14 -37
- package/dist/commands/seo/push.js +24 -48
- package/dist/commands/snippet/list.d.ts +3 -4
- package/dist/commands/snippet/list.js +5 -11
- package/dist/commands/snippet/publish.js +5 -21
- package/dist/commands/snippet/pull.d.ts +13 -1
- package/dist/commands/snippet/pull.js +18 -20
- package/dist/commands/snippet/push.d.ts +11 -1
- package/dist/commands/snippet/push.js +29 -28
- package/dist/commands/status.d.ts +13 -1
- package/dist/commands/status.js +25 -18
- package/dist/commands/validate.d.ts +8 -0
- package/dist/commands/validate.js +24 -0
- package/dist/config/auth.manager.d.ts +2 -2
- package/dist/config/project-config.manager.d.ts +1 -1
- package/dist/config/project-config.manager.js +5 -6
- package/dist/lib/base.d.ts +11 -2
- package/dist/lib/base.js +54 -24
- package/dist/lib/bootstrap-full-install.d.ts +1 -1
- package/dist/lib/dev-watch.d.ts +16 -0
- package/dist/lib/dev-watch.js +71 -0
- package/dist/lib/find-orphaned-files.d.ts +3 -2
- package/dist/lib/find-orphaned-files.js +40 -4
- package/dist/lib/github-release.js +1 -1
- package/dist/lib/guard-production-push.d.ts +12 -0
- package/dist/lib/guard-production-push.js +20 -0
- package/dist/lib/interactive.js +1 -1
- package/dist/lib/load-files.d.ts +1 -0
- package/dist/lib/load-files.js +40 -11
- package/dist/lib/load-snippets.d.ts +1 -1
- package/dist/lib/load-snippets.js +66 -59
- package/dist/lib/local-callback-server.js +15 -8
- package/dist/lib/plugin-detection.d.ts +1 -1
- package/dist/lib/push-command.d.ts +3 -0
- package/dist/lib/push-command.js +22 -15
- package/dist/lib/put-or-create.d.ts +13 -0
- package/dist/lib/put-or-create.js +19 -0
- package/dist/lib/readdir-tolerant.d.ts +5 -0
- package/dist/lib/readdir-tolerant.js +11 -0
- package/dist/lib/resolve-linked-project.d.ts +8 -0
- package/dist/lib/resolve-linked-project.js +27 -0
- package/dist/lib/rotate-app-password.d.ts +1 -1
- package/dist/lib/rotate-app-password.js +1 -1
- package/dist/lib/temp-admin.d.ts +3 -3
- package/dist/lib/temp-admin.js +2 -2
- package/dist/lib/validate-local.d.ts +10 -0
- package/dist/lib/validate-local.js +183 -0
- package/dist/lib/wp-authorize-flow.js +5 -1
- package/dist/lib/wp-client.d.ts +2 -2
- package/dist/lib/wp-client.js +7 -0
- package/dist/types/plugin.d.ts +4 -4
- package/dist/types/snippet.d.ts +3 -3
- package/dist/utils/composer.d.ts +2 -2
- package/dist/utils/form-format.js +1 -1
- package/dist/utils/loopress-config.d.ts +1 -1
- package/dist/utils/page-format.js +2 -2
- package/dist/utils/plugins.d.ts +5 -5
- package/dist/utils/plugins.js +3 -3
- package/dist/utils/pluralize.d.ts +1 -0
- package/dist/utils/pluralize.js +5 -0
- package/dist/utils/seo-format.d.ts +6 -6
- package/dist/utils/snippet-format.d.ts +2 -2
- package/dist/utils/snippet-format.js +10 -5
- package/oclif.manifest.json +394 -57
- package/package.json +18 -16
|
@@ -12,7 +12,7 @@ export class ProjectConfigManager {
|
|
|
12
12
|
const base = toSlug(name, 'project');
|
|
13
13
|
let id = base;
|
|
14
14
|
let suffix = 2;
|
|
15
|
-
while (config.projects
|
|
15
|
+
while (Object.hasOwn(config.projects, id)) {
|
|
16
16
|
id = `${base}-${suffix}`;
|
|
17
17
|
suffix++;
|
|
18
18
|
}
|
|
@@ -100,7 +100,7 @@ export class ProjectConfigManager {
|
|
|
100
100
|
const project = config.projects[projectId];
|
|
101
101
|
if (!project)
|
|
102
102
|
return;
|
|
103
|
-
|
|
103
|
+
Reflect.deleteProperty(project.environments, envName);
|
|
104
104
|
if (config.currentProject?.id === projectId && config.currentProject.env === envName) {
|
|
105
105
|
const remaining = Object.keys(project.environments);
|
|
106
106
|
config.currentProject = remaining.length > 0 ? { env: remaining[0], id: projectId } : null;
|
|
@@ -109,7 +109,7 @@ export class ProjectConfigManager {
|
|
|
109
109
|
}
|
|
110
110
|
removeProject(id) {
|
|
111
111
|
const config = this.readConfig();
|
|
112
|
-
|
|
112
|
+
Reflect.deleteProperty(config.projects, id);
|
|
113
113
|
if (config.currentProject?.id === id) {
|
|
114
114
|
const [nextId] = Object.keys(config.projects);
|
|
115
115
|
const nextProject = nextId ? config.projects[nextId] : undefined;
|
|
@@ -126,7 +126,7 @@ export class ProjectConfigManager {
|
|
|
126
126
|
}
|
|
127
127
|
setCurrent(projectId, envName) {
|
|
128
128
|
const config = this.readConfig();
|
|
129
|
-
if (!config.projects
|
|
129
|
+
if (!Object.hasOwn(config.projects, projectId))
|
|
130
130
|
return;
|
|
131
131
|
config.currentProject = { env: envName, id: projectId };
|
|
132
132
|
this.writeConfig(config);
|
|
@@ -137,8 +137,7 @@ export class ProjectConfigManager {
|
|
|
137
137
|
if (!project)
|
|
138
138
|
return;
|
|
139
139
|
project.environments[envName] = env;
|
|
140
|
-
|
|
141
|
-
config.currentProject = { env: envName, id: projectId };
|
|
140
|
+
config.currentProject ??= { env: envName, id: projectId };
|
|
142
141
|
this.writeConfig(config);
|
|
143
142
|
}
|
|
144
143
|
setEnvironmentApiId(projectId, envName, apiEnvironmentId) {
|
package/dist/lib/base.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from '@oclif/core';
|
|
2
|
-
import { EnvironmentConfig } from '../types/config.js';
|
|
3
|
-
import { LoopressLocalConfig } from '../utils/loopress-config.js';
|
|
2
|
+
import { type EnvironmentConfig } from '../types/config.js';
|
|
3
|
+
import { type LoopressLocalConfig } from '../utils/loopress-config.js';
|
|
4
4
|
import { WpClient } from './wp-client.js';
|
|
5
5
|
export declare abstract class LoopressCommand extends Command {
|
|
6
6
|
static baseFlags: {
|
|
@@ -22,6 +22,14 @@ export declare abstract class LoopressCommand extends Command {
|
|
|
22
22
|
protected get wp(): WpClient;
|
|
23
23
|
init(): Promise<void>;
|
|
24
24
|
protected maybeAutoRotate(): Promise<void>;
|
|
25
|
+
protected pullDirectory<T>(dir: string, items: T[], orphans: string[], options: {
|
|
26
|
+
alwaysCreateDir?: boolean;
|
|
27
|
+
dryRunMessage: string;
|
|
28
|
+
orphanReason: string;
|
|
29
|
+
pulledMessage: string;
|
|
30
|
+
title: (item: T) => string;
|
|
31
|
+
write: (item: T, dir: string) => Promise<void>;
|
|
32
|
+
}): Promise<void>;
|
|
25
33
|
protected removeOrphanedFiles(dir: string, orphans: string[], reason: string): Promise<void>;
|
|
26
34
|
protected resolveAcfPath(override?: string): string;
|
|
27
35
|
protected resolveApiPath(override?: string): string;
|
|
@@ -31,4 +39,5 @@ export declare abstract class LoopressCommand extends Command {
|
|
|
31
39
|
protected resolveSnippetsPath(override?: string): string;
|
|
32
40
|
private pickEnvironment;
|
|
33
41
|
private resolveEnvironment;
|
|
42
|
+
private resolveEnvironmentForConfiguredProject;
|
|
34
43
|
}
|
package/dist/lib/base.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { confirm } from '@inquirer/prompts';
|
|
2
2
|
import { Command, Flags } from '@oclif/core';
|
|
3
|
-
import {
|
|
3
|
+
import { Listr } from 'listr2';
|
|
4
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { configManager } from '../config/project-config.manager.js';
|
|
6
7
|
import { readLocalConfig } from '../utils/loopress-config.js';
|
|
8
|
+
import { pluralize } from '../utils/pluralize.js';
|
|
7
9
|
import { isInteractive } from './interactive.js';
|
|
8
10
|
import { isAppPasswordStale, rotateAppPassword } from './rotate-app-password.js';
|
|
9
11
|
import { WpClient } from './wp-client.js';
|
|
@@ -45,6 +47,7 @@ export class LoopressCommand extends Command {
|
|
|
45
47
|
const { flags } = (await this.parse({
|
|
46
48
|
args: this.ctor.args,
|
|
47
49
|
baseFlags: this.ctor.baseFlags,
|
|
50
|
+
enableJsonFlag: this.ctor.enableJsonFlag,
|
|
48
51
|
flags: this.ctor.flags,
|
|
49
52
|
strict: this.ctor.strict,
|
|
50
53
|
}));
|
|
@@ -76,6 +79,31 @@ export class LoopressCommand extends Command {
|
|
|
76
79
|
// swallowed: see comment above
|
|
77
80
|
}
|
|
78
81
|
}
|
|
82
|
+
// Shared shape of every resource's directory pull: write each item, then reconcile local
|
|
83
|
+
// files against the remote list (see removeOrphanedFiles above). `dryRunMessage` and
|
|
84
|
+
// `pulledMessage` are fully-formed by the caller (label pluralization varies per resource,
|
|
85
|
+
// e.g. ACF's `type` is a fixed category name, never pluralized), the "[dry-run] Would "
|
|
86
|
+
// prefix and the orphans notice are the only wording this method owns.
|
|
87
|
+
async pullDirectory(dir, items, orphans, options) {
|
|
88
|
+
if (this.dryRun) {
|
|
89
|
+
this.log(`[dry-run] ${options.dryRunMessage}`);
|
|
90
|
+
if (orphans.length > 0) {
|
|
91
|
+
this.log(`[dry-run] Would remove ${pluralize(orphans.length, 'local file')} ${options.orphanReason}: ${orphans.join(', ')}`);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (options.alwaysCreateDir === true || items.length > 0)
|
|
96
|
+
await mkdir(dir, { recursive: true });
|
|
97
|
+
await new Listr(items.map((item) => ({
|
|
98
|
+
async task(_ctx, task) {
|
|
99
|
+
await options.write(item, dir);
|
|
100
|
+
task.output = `Pulled: ${options.title(item)}`;
|
|
101
|
+
},
|
|
102
|
+
title: `Pull ${options.title(item)}`,
|
|
103
|
+
})), { renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
|
|
104
|
+
await this.removeOrphanedFiles(dir, orphans, options.orphanReason);
|
|
105
|
+
this.log(options.pulledMessage);
|
|
106
|
+
}
|
|
79
107
|
// Orphan cleanup shared by every pull command (see Push Deletion Rules in the product docs):
|
|
80
108
|
// in a TTY the list is shown and confirmed first (Enter accepts), --yes skips the prompt, and
|
|
81
109
|
// outside a TTY the files are removed with a warning, the behavior scripts relied on before
|
|
@@ -83,10 +111,10 @@ export class LoopressCommand extends Command {
|
|
|
83
111
|
async removeOrphanedFiles(dir, orphans, reason) {
|
|
84
112
|
if (orphans.length === 0)
|
|
85
113
|
return;
|
|
86
|
-
const description = `${orphans.length
|
|
114
|
+
const description = `${pluralize(orphans.length, 'local file')} ${reason}: ${orphans.join(', ')}`;
|
|
87
115
|
if (!this.yes && isInteractive()) {
|
|
88
|
-
const
|
|
89
|
-
if (!
|
|
116
|
+
const isProceed = await confirm({ default: true, message: `Remove ${description}?` });
|
|
117
|
+
if (!isProceed) {
|
|
90
118
|
this.log(`Kept ${description}`);
|
|
91
119
|
return;
|
|
92
120
|
}
|
|
@@ -142,26 +170,7 @@ export class LoopressCommand extends Command {
|
|
|
142
170
|
}
|
|
143
171
|
resolveEnvironment(envName) {
|
|
144
172
|
if (this.localConfig.projectId) {
|
|
145
|
-
|
|
146
|
-
const project = configManager.getProject(projectId);
|
|
147
|
-
if (!project) {
|
|
148
|
-
this.error(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
|
|
149
|
-
}
|
|
150
|
-
const envNames = Object.keys(project.environments);
|
|
151
|
-
if (envNames.length === 0) {
|
|
152
|
-
this.error(`Project "${project.name}" has no environments configured. Run \`lps project config\` to add one.`);
|
|
153
|
-
}
|
|
154
|
-
if (envName)
|
|
155
|
-
return { env: this.pickEnvironment(project, envName), projectId };
|
|
156
|
-
if (envNames.length === 1) {
|
|
157
|
-
return { env: project.environments[envNames[0]], projectId };
|
|
158
|
-
}
|
|
159
|
-
const current = configManager.getCurrentProject();
|
|
160
|
-
const currentEnv = current?.id === projectId ? configManager.getCurrentEnv() : null;
|
|
161
|
-
if (!currentEnv) {
|
|
162
|
-
this.error(`Project "${project.name}" has multiple environments. Run \`lps project switch\` to pick one.`);
|
|
163
|
-
}
|
|
164
|
-
return { env: currentEnv, projectId };
|
|
173
|
+
return this.resolveEnvironmentForConfiguredProject(this.localConfig.projectId, envName);
|
|
165
174
|
}
|
|
166
175
|
if (envName) {
|
|
167
176
|
const current = configManager.getCurrentProject();
|
|
@@ -176,4 +185,25 @@ export class LoopressCommand extends Command {
|
|
|
176
185
|
return { env, projectId: current.id };
|
|
177
186
|
this.error('No environment configured. Run `lps project config` first.');
|
|
178
187
|
}
|
|
188
|
+
resolveEnvironmentForConfiguredProject(projectId, envName) {
|
|
189
|
+
const project = configManager.getProject(projectId);
|
|
190
|
+
if (!project) {
|
|
191
|
+
this.error(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
|
|
192
|
+
}
|
|
193
|
+
const envNames = Object.keys(project.environments);
|
|
194
|
+
if (envNames.length === 0) {
|
|
195
|
+
this.error(`Project "${project.name}" has no environments configured. Run \`lps project config\` to add one.`);
|
|
196
|
+
}
|
|
197
|
+
if (envName)
|
|
198
|
+
return { env: this.pickEnvironment(project, envName), projectId };
|
|
199
|
+
if (envNames.length === 1) {
|
|
200
|
+
return { env: project.environments[envNames[0]], projectId };
|
|
201
|
+
}
|
|
202
|
+
const current = configManager.getCurrentProject();
|
|
203
|
+
const currentEnv = current?.id === projectId ? configManager.getCurrentEnv() : null;
|
|
204
|
+
if (!currentEnv) {
|
|
205
|
+
this.error(`Project "${project.name}" has multiple environments. Run \`lps project switch\` to pick one.`);
|
|
206
|
+
}
|
|
207
|
+
return { env: currentEnv, projectId };
|
|
208
|
+
}
|
|
179
209
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { WpClient } from './wp-client.js';
|
|
1
|
+
import { type WpClient } from './wp-client.js';
|
|
2
2
|
/**
|
|
3
3
|
* Installs and activates Loopress Full on a site that doesn't have it yet: downloads the
|
|
4
4
|
* latest release zip, creates a temporary admin (the only way to get `install_plugins` from
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type LoopressLocalConfig } from '../utils/loopress-config.js';
|
|
2
|
+
export type ResourceType = 'api' | 'pages' | 'plugins' | 'snippets';
|
|
3
|
+
export declare const RESOURCE_TYPES: ResourceType[];
|
|
4
|
+
export type WatchTarget = {
|
|
5
|
+
commandId: string;
|
|
6
|
+
path: string;
|
|
7
|
+
type: ResourceType;
|
|
8
|
+
};
|
|
9
|
+
export declare function resolveResourceTypes(only?: ResourceType[], skip?: ResourceType[]): ResourceType[];
|
|
10
|
+
export declare function buildWatchTargets(types: ResourceType[], localConfig: LoopressLocalConfig, cwd: string): WatchTarget[];
|
|
11
|
+
export declare function resourceTypeForPath(filePath: string, targets: WatchTarget[]): ResourceType | undefined;
|
|
12
|
+
export type DebouncedBatcher = {
|
|
13
|
+
cancel: () => void;
|
|
14
|
+
queue: (path: string) => void;
|
|
15
|
+
};
|
|
16
|
+
export declare function createDebouncedBatcher(flush: (paths: string[]) => Promise<void>, debounceMs: number): DebouncedBatcher;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join, sep } from 'node:path';
|
|
3
|
+
export const RESOURCE_TYPES = ['snippets', 'pages', 'api', 'plugins'];
|
|
4
|
+
// `only` narrows the default set, `skip` then removes from whatever that set is, same
|
|
5
|
+
// precedence a combined `--only=a,b --skip=b` reads as: watch a, b, then drop b.
|
|
6
|
+
export function resolveResourceTypes(only, skip = []) {
|
|
7
|
+
const base = only && only.length > 0 ? only : RESOURCE_TYPES;
|
|
8
|
+
return base.filter((type) => !skip.includes(type));
|
|
9
|
+
}
|
|
10
|
+
// One target per requested type that actually has something to watch: a missing directory (no
|
|
11
|
+
// `api/` folder yet) or an empty plugin manifest is left out silently, matching `push`'s own
|
|
12
|
+
// directories which are all optional. `plugins` watches the whole `loopress.json` lockfile since
|
|
13
|
+
// the plugin manifest is a field in it, not its own directory.
|
|
14
|
+
export function buildWatchTargets(types, localConfig, cwd) {
|
|
15
|
+
const rootDir = join(cwd, localConfig.rootDir ?? '.');
|
|
16
|
+
const candidates = {
|
|
17
|
+
api: { commandId: 'api:push', path: join(rootDir, localConfig.apiDir ?? 'api'), type: 'api' },
|
|
18
|
+
pages: { commandId: 'page:push', path: join(rootDir, localConfig.pageDir ?? 'pages'), type: 'pages' },
|
|
19
|
+
plugins: { commandId: 'plugin:push', path: join(cwd, 'loopress.json'), type: 'plugins' },
|
|
20
|
+
snippets: { commandId: 'snippet:push', path: join(rootDir, localConfig.snippetsDir ?? 'snippets'), type: 'snippets' },
|
|
21
|
+
};
|
|
22
|
+
return types
|
|
23
|
+
.map((type) => candidates[type])
|
|
24
|
+
.filter((target) => {
|
|
25
|
+
if (target.type === 'plugins')
|
|
26
|
+
return Object.keys(localConfig.plugins ?? {}).length > 0;
|
|
27
|
+
return existsSync(target.path);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
// A changed file belongs to whichever target's path is a prefix of it (directory targets) or
|
|
31
|
+
// equals it (the `loopress.json` file target for `plugins`).
|
|
32
|
+
export function resourceTypeForPath(filePath, targets) {
|
|
33
|
+
return targets.find((target) => filePath === target.path || filePath.startsWith(target.path + sep))?.type;
|
|
34
|
+
}
|
|
35
|
+
// Groups file events that land within `debounceMs` of each other into one `flush` call. If a
|
|
36
|
+
// flush is still running when the timer fires again (a slow push outlasting the next debounce
|
|
37
|
+
// window), the new paths just accumulate in `pending` instead of starting a second, overlapping
|
|
38
|
+
// flush; once the in-flight one finishes it immediately flushes whatever queued up meanwhile.
|
|
39
|
+
export function createDebouncedBatcher(flush, debounceMs) {
|
|
40
|
+
const pending = new Set();
|
|
41
|
+
let timer;
|
|
42
|
+
let isBusy = false;
|
|
43
|
+
const schedule = () => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
timer = setTimeout(() => {
|
|
46
|
+
runFlush().catch(() => { });
|
|
47
|
+
}, debounceMs);
|
|
48
|
+
};
|
|
49
|
+
const runFlush = async () => {
|
|
50
|
+
if (isBusy || pending.size === 0)
|
|
51
|
+
return;
|
|
52
|
+
isBusy = true;
|
|
53
|
+
const paths = [...pending];
|
|
54
|
+
pending.clear();
|
|
55
|
+
try {
|
|
56
|
+
await flush(paths);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
isBusy = false;
|
|
60
|
+
if (pending.size > 0)
|
|
61
|
+
schedule();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
cancel() { clearTimeout(timer); },
|
|
66
|
+
queue(path) {
|
|
67
|
+
pending.add(path);
|
|
68
|
+
schedule();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
export
|
|
1
|
+
export type OrphanMatcher = {
|
|
2
2
|
extensions: string[];
|
|
3
3
|
key(base: string): null | string;
|
|
4
|
-
|
|
4
|
+
recursive?: boolean;
|
|
5
|
+
};
|
|
5
6
|
export declare function findOrphanedFiles(dir: string, keep: Set<string>, matcher: OrphanMatcher): Promise<string[]>;
|
|
6
7
|
export declare function numericPrefixKey(base: string): null | string;
|
|
7
8
|
export declare function basenameKey(base: string): string;
|
|
@@ -1,15 +1,51 @@
|
|
|
1
1
|
import { readdir } from 'node:fs/promises';
|
|
2
|
-
import { extname } from 'node:path';
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { extname, join } from 'node:path';
|
|
3
|
+
// Same shape as load-files.ts's own walker, kept separate rather than shared: this one
|
|
4
|
+
// filters against a list of extensions (page/snippet pull need more than one), that one
|
|
5
|
+
// against a single extension, and reconciling the two would cost more than the ~15 duplicated
|
|
6
|
+
// lines it would save.
|
|
7
|
+
async function walk(dir, extensions) {
|
|
8
|
+
let entries;
|
|
5
9
|
try {
|
|
6
|
-
|
|
10
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
7
11
|
}
|
|
8
12
|
catch (error) {
|
|
9
13
|
if (error.code === 'ENOENT')
|
|
10
14
|
return [];
|
|
11
15
|
throw error;
|
|
12
16
|
}
|
|
17
|
+
const relativePaths = [];
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
if (entry.isDirectory()) {
|
|
20
|
+
// join() builds the real OS path to actually walk, but the returned identity is always
|
|
21
|
+
// '/'-joined regardless of platform: it's matched against `keep`, filenames the server
|
|
22
|
+
// sends with '/', never the OS separator. join() here (backslash on Windows) would make
|
|
23
|
+
// every nested file's identity never match, findOrphanedFiles() would then report a
|
|
24
|
+
// live, kept file as orphaned and pull.ts would delete it.
|
|
25
|
+
const nested = await walk(join(dir, entry.name), extensions);
|
|
26
|
+
relativePaths.push(...nested.map((relativePath) => `${entry.name}/${relativePath}`));
|
|
27
|
+
}
|
|
28
|
+
else if (extensions.includes(extname(entry.name))) {
|
|
29
|
+
relativePaths.push(entry.name);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return relativePaths;
|
|
33
|
+
}
|
|
34
|
+
export async function findOrphanedFiles(dir, keep, matcher) {
|
|
35
|
+
let files;
|
|
36
|
+
if (matcher.recursive) {
|
|
37
|
+
files = await walk(dir, matcher.extensions);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
try {
|
|
41
|
+
files = await readdir(dir);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code === 'ENOENT')
|
|
45
|
+
return [];
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
13
49
|
return files.filter((file) => {
|
|
14
50
|
const ext = extname(file);
|
|
15
51
|
if (!matcher.extensions.includes(ext))
|
|
@@ -22,7 +22,7 @@ export async function downloadLatestFullZip() {
|
|
|
22
22
|
headers: { accept: 'application/vnd.github+json', 'user-agent': 'loopress-cli' },
|
|
23
23
|
searchParams: { 'per_page': 30 },
|
|
24
24
|
}).json();
|
|
25
|
-
const pluginRelease = releases.find((release) =>
|
|
25
|
+
const pluginRelease = releases.find((release) => release.tag_name.startsWith("wordpress-plugin@"));
|
|
26
26
|
if (!pluginRelease) {
|
|
27
27
|
throw new Error(`No "wordpress-plugin@*" release found in ${REPO}.`);
|
|
28
28
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type GuardProductionPushOptions = {
|
|
2
|
+
dryRun: boolean;
|
|
3
|
+
error: (message: string) => never;
|
|
4
|
+
onRefuse?: () => void;
|
|
5
|
+
siteConfig: {
|
|
6
|
+
name: string;
|
|
7
|
+
url: string;
|
|
8
|
+
};
|
|
9
|
+
yes: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function guardProductionPush(options: GuardProductionPushOptions): Promise<void>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { isInteractive } from './interactive.js';
|
|
3
|
+
// Pushing to an environment named "production" needs explicit intent: a TTY confirmation
|
|
4
|
+
// (Enter accepts), or --yes in scripts and CI. Dry runs are exempt, they change nothing.
|
|
5
|
+
// Shared by PushCommand (lib/push-command.ts, guards a single resource push) and the
|
|
6
|
+
// top-level `lps push` (commands/push.ts, guards once before delegating to 8 sub-commands).
|
|
7
|
+
export async function guardProductionPush(options) {
|
|
8
|
+
const { dryRun, error, onRefuse, siteConfig, yes } = options;
|
|
9
|
+
if (dryRun || yes || siteConfig.name.toLowerCase() !== 'production')
|
|
10
|
+
return;
|
|
11
|
+
if (!isInteractive()) {
|
|
12
|
+
onRefuse?.();
|
|
13
|
+
error('Target environment is "production". Pass --yes to confirm the push in a non-interactive run.');
|
|
14
|
+
}
|
|
15
|
+
const isProceed = await confirm({ default: true, message: `Push to production (${siteConfig.url})?` });
|
|
16
|
+
if (!isProceed) {
|
|
17
|
+
onRefuse?.();
|
|
18
|
+
error('Aborted.');
|
|
19
|
+
}
|
|
20
|
+
}
|
package/dist/lib/interactive.js
CHANGED
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
// Every command that prompts routes its decision through this so the CLI never hangs on a
|
|
4
4
|
// question nobody can answer.
|
|
5
5
|
export function isInteractive() {
|
|
6
|
-
return
|
|
6
|
+
return process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
|
|
7
7
|
}
|
package/dist/lib/load-files.d.ts
CHANGED
package/dist/lib/load-files.js
CHANGED
|
@@ -1,25 +1,54 @@
|
|
|
1
1
|
import { readdir, readFile } from 'node:fs/promises';
|
|
2
2
|
import { extname, join } from 'node:path';
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
// "nothing to load", not an error.
|
|
8
|
-
export async function loadFiles(dir, options) {
|
|
3
|
+
// Recursion for the api resource type only (path-param route files can live in
|
|
4
|
+
// subdirectories, e.g. api/invoice-pdf/[order_id].php); every other resource type stays
|
|
5
|
+
// flat. withFileTypes avoids a second stat() per entry to tell files from directories.
|
|
6
|
+
async function walk(dir, extension) {
|
|
9
7
|
let entries;
|
|
10
8
|
try {
|
|
11
|
-
entries = await readdir(dir);
|
|
9
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
12
10
|
}
|
|
13
11
|
catch (error) {
|
|
14
12
|
if (error.code === 'ENOENT')
|
|
15
13
|
return [];
|
|
16
14
|
throw error;
|
|
17
15
|
}
|
|
18
|
-
const
|
|
16
|
+
const filePaths = [];
|
|
19
17
|
for (const entry of entries) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
const entryPath = join(dir, entry.name);
|
|
19
|
+
if (entry.isDirectory()) {
|
|
20
|
+
filePaths.push(...(await walk(entryPath, extension)));
|
|
21
|
+
}
|
|
22
|
+
else if (extname(entry.name) === extension) {
|
|
23
|
+
filePaths.push(entryPath);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return filePaths;
|
|
27
|
+
}
|
|
28
|
+
// Loads every `<extension>` file of a directory through `parse`. One file is read in
|
|
29
|
+
// isolation: a corrupted or hand-broken file (unreadable, bad JSON, failed validation) must
|
|
30
|
+
// only skip that file, never abort loading the rest of the directory, so `parse` throwing is
|
|
31
|
+
// reported through `onSkip` and the loop moves on. ENOENT on the directory itself means
|
|
32
|
+
// "nothing to load", not an error.
|
|
33
|
+
export async function loadFiles(dir, options) {
|
|
34
|
+
let filePaths;
|
|
35
|
+
if (options.recursive) {
|
|
36
|
+
filePaths = await walk(dir, options.extension);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = await readdir(dir);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code === 'ENOENT')
|
|
45
|
+
return [];
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
filePaths = entries.filter((entry) => extname(entry) === options.extension).map((entry) => join(dir, entry));
|
|
49
|
+
}
|
|
50
|
+
const items = [];
|
|
51
|
+
for (const filePath of filePaths) {
|
|
23
52
|
try {
|
|
24
53
|
items.push(options.parse(await readFile(filePath, 'utf8'), filePath));
|
|
25
54
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { Snippet } from '../types/snippet.js';
|
|
1
|
+
import { type Snippet } from '../types/snippet.js';
|
|
2
2
|
export declare function loadSnippets(path: string, onSkip?: (message: string) => void): Promise<Snippet[]>;
|
|
@@ -8,77 +8,84 @@ const TYPE_BY_EXTENSION = {
|
|
|
8
8
|
'.php': 'php',
|
|
9
9
|
'.txt': 'text',
|
|
10
10
|
};
|
|
11
|
+
// One snippet's files are read in isolation: a corrupted or hand-broken sidecar (bad JSON,
|
|
12
|
+
// unreadable file, ...) must only skip that snippet, not abort loading every other one.
|
|
13
|
+
async function loadSnippet(path, file, onSkip) {
|
|
14
|
+
const ext = extname(file);
|
|
15
|
+
if (!Object.hasOwn(TYPE_BY_EXTENSION, ext))
|
|
16
|
+
return undefined;
|
|
17
|
+
const filePath = join(path, file);
|
|
18
|
+
const metaPath = join(path, `${basename(file, ext)}.json`);
|
|
19
|
+
let content;
|
|
20
|
+
try {
|
|
21
|
+
content = await readFile(filePath, 'utf8');
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
onSkip?.(`Skipping "${filePath}": ${error.message}`);
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
let id;
|
|
28
|
+
let name;
|
|
29
|
+
let type;
|
|
30
|
+
let isActive = false;
|
|
31
|
+
let tags = [];
|
|
32
|
+
let location = null;
|
|
33
|
+
let insertMethod = null;
|
|
34
|
+
let priority = 10;
|
|
35
|
+
let shortcodeAttributes = [];
|
|
36
|
+
try {
|
|
37
|
+
const metaContent = await readFile(metaPath, 'utf8');
|
|
38
|
+
const meta = JSON.parse(metaContent);
|
|
39
|
+
id = meta.id;
|
|
40
|
+
// `||`, not `??`: an empty-string name in the sidecar JSON counts as absent too.
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
42
|
+
name = meta.name || undefined;
|
|
43
|
+
type = parseType(meta.type) ?? undefined;
|
|
44
|
+
isActive = Boolean(meta.active);
|
|
45
|
+
tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
|
|
46
|
+
location = parseLocation(meta.location);
|
|
47
|
+
insertMethod = parseInsertMethod(meta.insertMethod);
|
|
48
|
+
priority = meta.priority ?? 10;
|
|
49
|
+
shortcodeAttributes = Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [];
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code !== 'ENOENT') {
|
|
53
|
+
onSkip?.(`Skipping "${metaPath}": ${error.message}`);
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const resolvedType = type ?? (Object.hasOwn(TYPE_BY_EXTENSION, ext) ? TYPE_BY_EXTENSION[ext] : 'php');
|
|
58
|
+
return {
|
|
59
|
+
active: isActive,
|
|
60
|
+
code: content,
|
|
61
|
+
id,
|
|
62
|
+
insertMethod: insertMethod ?? 'auto',
|
|
63
|
+
location: location ?? defaultLocationForType(resolvedType),
|
|
64
|
+
name: name ?? basename(file, ext),
|
|
65
|
+
path: filePath,
|
|
66
|
+
priority,
|
|
67
|
+
shortcodeAttributes,
|
|
68
|
+
tags,
|
|
69
|
+
type: resolvedType,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
11
72
|
// Shared by `snippet push` (pushes to the project's own WordPress site) and `snippet publish`
|
|
12
73
|
// (publishes to the Loopress api as a shared source) — both read the same local `snippets/`
|
|
13
74
|
// directory the same way. `onSkip` is optional so callers that don't care about per-file
|
|
14
75
|
// warnings (e.g. `snippet publish`) can ignore them; skipped files are simply left out either way.
|
|
15
76
|
export async function loadSnippets(path, onSkip) {
|
|
16
|
-
const snippets = [];
|
|
17
77
|
let files;
|
|
18
78
|
try {
|
|
19
79
|
files = await readdir(path);
|
|
20
80
|
}
|
|
21
81
|
catch (error) {
|
|
22
|
-
throw new Error(`Error loading snippets: ${error.message}
|
|
82
|
+
throw new Error(`Error loading snippets: ${error.message}`, { cause: error });
|
|
23
83
|
}
|
|
84
|
+
const snippets = [];
|
|
24
85
|
for (const file of files) {
|
|
25
|
-
const
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
const filePath = join(path, file);
|
|
29
|
-
const metaPath = join(path, `${basename(file, ext)}.json`);
|
|
30
|
-
// One snippet's files are read in isolation: a corrupted or hand-broken sidecar (bad JSON,
|
|
31
|
-
// unreadable file, ...) must only skip that snippet, not abort loading every other one.
|
|
32
|
-
let content;
|
|
33
|
-
try {
|
|
34
|
-
content = await readFile(filePath, 'utf8');
|
|
35
|
-
}
|
|
36
|
-
catch (error) {
|
|
37
|
-
onSkip?.(`Skipping "${filePath}": ${error.message}`);
|
|
38
|
-
continue;
|
|
39
|
-
}
|
|
40
|
-
let id;
|
|
41
|
-
let name;
|
|
42
|
-
let type;
|
|
43
|
-
let active = false;
|
|
44
|
-
let tags = [];
|
|
45
|
-
let location = null;
|
|
46
|
-
let insertMethod = null;
|
|
47
|
-
let priority = 10;
|
|
48
|
-
let shortcodeAttributes = [];
|
|
49
|
-
try {
|
|
50
|
-
const metaContent = await readFile(metaPath, 'utf8');
|
|
51
|
-
const meta = JSON.parse(metaContent);
|
|
52
|
-
id = meta.id === undefined ? undefined : Number(meta.id);
|
|
53
|
-
name = meta.name ? String(meta.name) : undefined;
|
|
54
|
-
type = parseType(meta.type) ?? undefined;
|
|
55
|
-
active = Boolean(meta.active);
|
|
56
|
-
tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
|
|
57
|
-
location = parseLocation(meta.location);
|
|
58
|
-
insertMethod = parseInsertMethod(meta.insertMethod);
|
|
59
|
-
priority = meta.priority === undefined ? 10 : Number(meta.priority);
|
|
60
|
-
shortcodeAttributes = Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [];
|
|
61
|
-
}
|
|
62
|
-
catch (error) {
|
|
63
|
-
if (error.code !== 'ENOENT') {
|
|
64
|
-
onSkip?.(`Skipping "${metaPath}": ${error.message}`);
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
const resolvedType = type ?? (ext in TYPE_BY_EXTENSION ? TYPE_BY_EXTENSION[ext] : 'php');
|
|
69
|
-
snippets.push({
|
|
70
|
-
active,
|
|
71
|
-
code: content,
|
|
72
|
-
id,
|
|
73
|
-
insertMethod: insertMethod ?? 'auto',
|
|
74
|
-
location: location ?? defaultLocationForType(resolvedType),
|
|
75
|
-
name: name ?? basename(file, ext),
|
|
76
|
-
path: filePath,
|
|
77
|
-
priority,
|
|
78
|
-
shortcodeAttributes,
|
|
79
|
-
tags,
|
|
80
|
-
type: resolvedType,
|
|
81
|
-
});
|
|
86
|
+
const snippet = await loadSnippet(path, file, onSkip);
|
|
87
|
+
if (snippet)
|
|
88
|
+
snippets.push(snippet);
|
|
82
89
|
}
|
|
83
90
|
return snippets;
|
|
84
91
|
}
|