@loopress/cli 0.30.0 → 0.31.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.
Files changed (37) hide show
  1. package/README.md +196 -143
  2. package/dist/commands/acf/push.js +2 -1
  3. package/dist/commands/composer/push.d.ts +1 -0
  4. package/dist/commands/composer/push.js +13 -0
  5. package/dist/commands/content/copy.d.ts +28 -0
  6. package/dist/commands/content/copy.js +97 -0
  7. package/dist/commands/doctor.d.ts +1 -0
  8. package/dist/commands/doctor.js +28 -1
  9. package/dist/commands/form/push.js +4 -1
  10. package/dist/commands/option/push.js +4 -2
  11. package/dist/commands/page/push.js +3 -2
  12. package/dist/commands/plugin/audit.js +41 -24
  13. package/dist/commands/promote.d.ts +1 -0
  14. package/dist/commands/promote.js +7 -3
  15. package/dist/commands/pull.d.ts +2 -1
  16. package/dist/commands/pull.js +24 -4
  17. package/dist/commands/push.d.ts +4 -2
  18. package/dist/commands/push.js +42 -17
  19. package/dist/commands/snippet/push.d.ts +1 -0
  20. package/dist/commands/snippet/push.js +24 -3
  21. package/dist/commands/theme/version/pull.js +6 -1
  22. package/dist/lib/base.d.ts +1 -0
  23. package/dist/lib/base.js +3 -0
  24. package/dist/lib/diff-command.d.ts +2 -0
  25. package/dist/lib/diff-command.js +24 -3
  26. package/dist/lib/load-snippets.js +4 -0
  27. package/dist/lib/php-files-command.d.ts +8 -0
  28. package/dist/lib/php-files-command.js +5 -0
  29. package/dist/lib/push-command.d.ts +4 -2
  30. package/dist/lib/push-command.js +65 -5
  31. package/dist/lib/resource-state.js +5 -5
  32. package/dist/lib/rollback-command.js +9 -2
  33. package/dist/lib/validate-local.js +9 -3
  34. package/dist/lib/wp-client.js +14 -1
  35. package/dist/types/snippet.d.ts +1 -0
  36. package/oclif.manifest.json +895 -693
  37. package/package.json +5 -2
@@ -26,7 +26,8 @@ export default class Push extends PushCommand {
26
26
  this.log(`Pushing ACF configuration to ${url}`);
27
27
  this.log(`ACF path: ${path}`);
28
28
  const provider = getResourceStateProvider('acf');
29
- const beforeState = await this.captureBeforePushState(provider, path);
29
+ // `--type` leaves the other types untouched: only the pushed ones count as overwritten.
30
+ const beforeState = await this.captureBeforePushState(provider, path, (id) => types.some((type) => id.startsWith(`${type}/`)));
30
31
  for (const type of types) {
31
32
  await this.pushType(type, path);
32
33
  }
@@ -17,5 +17,6 @@ export default class ComposerPush extends PushCommand {
17
17
  };
18
18
  run(): Promise<PushResult>;
19
19
  private reportLockDrift;
20
+ private wpackagistToRemove;
20
21
  }
21
22
  export {};
@@ -2,6 +2,7 @@ import { Flags } from '@oclif/core';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
+ import { confirmUninstall } from '../../lib/interactive.js';
5
6
  import { PushCommand } from '../../lib/push-command.js';
6
7
  import { isLocalEnvironment, isTimeoutError } from '../../lib/wp-client.js';
7
8
  import { requireToInstall, toIntent } from '../../utils/composer.js';
@@ -37,8 +38,15 @@ export default class ComposerPush extends PushCommand {
37
38
  this.log(`Pushing composer.json (${packageCount} ${packageCount === 1 ? 'package' : 'packages'}) to ${url}`);
38
39
  if (lock)
39
40
  this.log(' + composer.lock sent for drift comparison (the server resolves versions from composer.json)');
41
+ // The server renders composer.json from this intent alone, so a WordPress.org plugin or theme
42
+ // the site has and this file lacks gets uninstalled, active or not: say so before it happens.
43
+ const toRemove = await this.wpackagistToRemove(require);
44
+ if (toRemove.length > 0)
45
+ this.log(`Would uninstall from the site, absent from composer.json: ${toRemove.join(', ')}`);
40
46
  if (this.dryRun)
41
47
  return { hasLock, lockDrift: [], packageCount, status: 'dry-run' };
48
+ if (!(await confirmUninstall(toRemove, this.yes)))
49
+ this.error('Aborted.');
42
50
  this.log('Running Composer on the server, this can take a few minutes...');
43
51
  let response;
44
52
  try {
@@ -74,4 +82,9 @@ export default class ComposerPush extends PushCommand {
74
82
  }
75
83
  this.log('Run `lps composer pull` to update your local composer.json and composer.lock.');
76
84
  }
85
+ async wpackagistToRemove(require) {
86
+ const { composerJson } = await this.wp.get('loopress/v1/composer/json');
87
+ const current = JSON.parse(composerJson).require ?? {};
88
+ return Object.keys(current).filter((name) => name.startsWith('wpackagist-') && !Object.hasOwn(require, name));
89
+ }
77
90
  }
@@ -0,0 +1,28 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ import { WpClient } from '../../lib/wp-client.js';
3
+ type ContentCopyResult = {
4
+ from: string;
5
+ posts: number;
6
+ status: 'copied' | 'dry-run';
7
+ terms: number;
8
+ };
9
+ export default class Copy extends LoopressCommand {
10
+ static args: {
11
+ from: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
12
+ };
13
+ static baseFlags: {
14
+ env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ };
16
+ static description: string;
17
+ static enableJsonFlag: boolean;
18
+ static examples: string[];
19
+ static flags: {
20
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
21
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
22
+ };
23
+ run(): Promise<ContentCopyResult>;
24
+ protected sourceWp(from: string): WpClient;
25
+ private confirmReplace;
26
+ private copy;
27
+ }
28
+ export {};
@@ -0,0 +1,97 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Args, Flags } from '@oclif/core';
3
+ import { configManager } from '../../config/project-config.manager.js';
4
+ import { LoopressCommand } from '../../lib/base.js';
5
+ import { isInteractive } from '../../lib/interactive.js';
6
+ import { stdoutToStderr } from '../../lib/json-delegation.js';
7
+ import { isLocalEnvironment, LOCAL_ENVIRONMENT_NAME, WpClient } from '../../lib/wp-client.js';
8
+ import { pluralize } from '../../utils/pluralize.js';
9
+ const BATCH_SIZE = 100;
10
+ // Code goes up, content comes down: the content of <from> replaces the local site's, IDs
11
+ // unchanged, never the reverse and never into the repo. Not a `pull`: nothing is written to
12
+ // local files. The target is always "local", so there is no --env (this.wp is the local site),
13
+ // <from> names the source like `lps promote`. Terms go first, so the post batches can attach
14
+ // their term relationships. The plugin refuses the import on any site whose environment type is
15
+ // not "local", whatever this command is pointed at.
16
+ export default class Copy extends LoopressCommand {
17
+ static args = {
18
+ from: Args.string({ description: 'Environment to copy the content from', required: true }),
19
+ };
20
+ // Hidden, not removed: the type of LoopressCommand.baseFlags requires it. Any value other than
21
+ // "local" is refused in run().
22
+ static baseFlags = {
23
+ env: Flags.string({ hidden: true }),
24
+ };
25
+ static description = 'Replace the local site\'s content (posts, pages, public custom post types, terms) with the content of <from>, then push the project to the local site so tracked configuration wins. Media stay served by <from>, users, comments and orders are never copied.';
26
+ static enableJsonFlag = true;
27
+ static examples = ['$ lps content copy production', '$ lps content copy staging --dry-run'];
28
+ static flags = {
29
+ ...LoopressCommand.dryRunFlag,
30
+ ...LoopressCommand.yesFlag,
31
+ };
32
+ async run() {
33
+ const { args } = await this.parse(Copy);
34
+ const { from } = args;
35
+ if (!isLocalEnvironment(this.siteConfig.name)) {
36
+ this.error(`Content is only ever copied into the "${LOCAL_ENVIRONMENT_NAME}" environment. Run \`lps project config\` to add it if the project has none.`);
37
+ }
38
+ if (isLocalEnvironment(from)) {
39
+ this.error(`<from> must be another environment than "${LOCAL_ENVIRONMENT_NAME}", e.g. \`lps content copy production\`.`);
40
+ }
41
+ const source = this.sourceWp(from);
42
+ if (!this.dryRun)
43
+ await this.confirmReplace(from);
44
+ const info = await source.get('loopress/v1/content');
45
+ if (!this.dryRun) {
46
+ await this.wp.post('loopress/v1/content/reset', { home: info.home, uploadsUrl: info.uploadsUrl });
47
+ }
48
+ this.log(`\n→ Copying terms from ${from}...`);
49
+ const terms = await this.copy('terms', source);
50
+ this.log(`→ Copying posts from ${from}...`);
51
+ const posts = await this.copy('posts', source);
52
+ const summary = `${pluralize(posts, 'post')} and ${pluralize(terms, 'term')}`;
53
+ if (this.dryRun) {
54
+ this.log(`\n[dry-run] Would replace the local content with ${summary} from ${from}, then push the project to ${LOCAL_ENVIRONMENT_NAME}.`);
55
+ return { from, posts, status: 'dry-run', terms };
56
+ }
57
+ this.log(`\n→ Pushing the project to ${LOCAL_ENVIRONMENT_NAME}...`);
58
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', ['--env', LOCAL_ENVIRONMENT_NAME, '--yes']));
59
+ this.log(`\nCopied ${summary} from ${from}.`);
60
+ return { from, posts, status: 'copied', terms };
61
+ }
62
+ // Overridable seam for tests, like `wpClient` on LoopressCommand.
63
+ sourceWp(from) {
64
+ const project = configManager.getProject(this.projectId);
65
+ const env = project?.environments[from];
66
+ if (!env) {
67
+ this.error(`Environment "${from}" not found. Available: ${Object.keys(project?.environments ?? {}).join(', ')}`);
68
+ }
69
+ if (!env.token)
70
+ this.error(`No credentials configured for ${env.url}. Run \`lps project config\` to add them.`);
71
+ return new WpClient(env.url, env.token, env.name);
72
+ }
73
+ async confirmReplace(from) {
74
+ if (this.yes)
75
+ return;
76
+ const message = `This deletes the posts, pages and terms of the "${LOCAL_ENVIRONMENT_NAME}" site and replaces them with ${from}'s.`;
77
+ if (!isInteractive())
78
+ this.error(`${message} Pass --yes to confirm.`);
79
+ const isProceed = await confirm({ default: true, message: `${message} Continue?` });
80
+ if (!isProceed)
81
+ this.error('Aborted.');
82
+ }
83
+ // Pages through the source, each page posted to the local site as is.
84
+ async copy(kind, source) {
85
+ let count = 0;
86
+ let after = 0;
87
+ while (after !== null) {
88
+ const page = await source.get(`loopress/v1/content/${kind}?after=${after}&limit=${BATCH_SIZE}`);
89
+ const { next, ...rows } = page;
90
+ count += rows[kind].length;
91
+ if (!this.dryRun)
92
+ await this.wp.post(`loopress/v1/content/${kind}`, rows);
93
+ after = next;
94
+ }
95
+ return count;
96
+ }
97
+ }
@@ -19,6 +19,7 @@ export default class Doctor extends LoopressCommand {
19
19
  private readonly checks;
20
20
  run(): Promise<DoctorResult>;
21
21
  private check;
22
+ private checkHookErrors;
22
23
  private checkLeftoverTempAdmins;
23
24
  private out;
24
25
  private reportPluginVersion;
@@ -1,5 +1,6 @@
1
1
  import { configManager } from '../config/project-config.manager.js';
2
2
  import { LoopressCommand } from '../lib/base.js';
3
+ import { formatRuntimeError, HOOK_FILES_RESOURCE } from '../lib/php-files-command.js';
3
4
  import { findTempAdmins } from '../lib/temp-admin.js';
4
5
  import { isForbiddenError, isNotFoundError } from '../lib/wp-client.js';
5
6
  import { diagnoseWpSite } from '../lib/wp-site-diagnostic.js';
@@ -35,8 +36,10 @@ export default class Doctor extends LoopressCommand {
35
36
  // application password without depending on any Loopress feature.
36
37
  const isAuthenticated = await this.check('Credentials accepted (authenticated request)', async () => this.wp.get('wp/v2/users/me'));
37
38
  // Skipped when the credentials were just rejected: it would only repeat that same 401.
38
- if (isAuthenticated)
39
+ if (isAuthenticated) {
39
40
  await this.checkLeftoverTempAdmins();
41
+ await this.checkHookErrors();
42
+ }
40
43
  pluginVersion = await this.reportPluginVersion();
41
44
  }
42
45
  else {
@@ -76,6 +79,30 @@ export default class Doctor extends LoopressCommand {
76
79
  return false;
77
80
  }
78
81
  }
82
+ // A hook that fails to load or throws while running doesn't break the site, it just quietly
83
+ // does nothing, and its only trace is an error_log nobody reads on managed hosting.
84
+ async checkHookErrors() {
85
+ let lookup;
86
+ try {
87
+ lookup = { files: await this.wp.get(HOOK_FILES_RESOURCE.endpoint) };
88
+ }
89
+ catch (error) {
90
+ if (isNotFoundError(error))
91
+ return; // Loopress Light has no hooks
92
+ lookup = { error };
93
+ }
94
+ await this.check('Hooks load and run without errors', async () => {
95
+ if ('error' in lookup)
96
+ throw lookup.error;
97
+ const files = Array.isArray(lookup.files) ? lookup.files : [];
98
+ const problems = files.flatMap((file) => [
99
+ ...(file.error ? [`hooks/${file.filename}.php: ${file.error}`] : []),
100
+ ...(file.runtimeError ? [`hooks/${file.filename}.php: ${formatRuntimeError(file.runtimeError)}`] : []),
101
+ ]);
102
+ if (problems.length > 0)
103
+ throw new Error(problems.join('\n '));
104
+ });
105
+ }
79
106
  // Report only, never deletes: a doctor run shouldn't change the site. A leftover is a live
80
107
  // administrator with a password nobody holds, so it fails the run. Credentials without
81
108
  // `list_users` can't look, which is informational, not a failure: nothing was found wrong.
@@ -1,5 +1,5 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
- import { readFile, rename } from 'node:fs/promises';
2
+ import { readFile, rename, writeFile } from 'node:fs/promises';
3
3
  import { dirname, extname, join } from 'node:path';
4
4
  import { PushCommand } from '../../lib/push-command.js';
5
5
  import { putOrCreate } from '../../lib/put-or-create.js';
@@ -120,6 +120,9 @@ export default class Push extends PushCommand {
120
120
  putEndpoint: formEndpoint,
121
121
  });
122
122
  const canonicalId = created ? getFormId(body) : id;
123
+ // The id is read from the file, not its name: without it the next push creates the form again.
124
+ if (created && canonicalId !== null)
125
+ await writeFile(filePath, JSON.stringify({ ...data, id: canonicalId }, null, 2) + '\n');
123
126
  if (canonicalId !== null)
124
127
  await this.ensureCanonicalFilename(filePath, canonicalId, title);
125
128
  if (task)
@@ -23,8 +23,6 @@ export default class Push extends PushCommand {
23
23
  const { url } = this.siteConfig;
24
24
  this.log(`Pushing tracked options to ${url}`);
25
25
  this.log(`Options path: ${dir}`);
26
- const provider = getResourceStateProvider('option');
27
- const beforeState = await this.captureBeforePushState(provider, dir);
28
26
  const tracked = await loadFiles(dir, {
29
27
  extension: '.json',
30
28
  onSkip: (message) => {
@@ -33,6 +31,10 @@ export default class Push extends PushCommand {
33
31
  parse: parseLocalOption,
34
32
  });
35
33
  const { skipped, writable } = partitionByReadonly(tracked);
34
+ const provider = getResourceStateProvider('option');
35
+ // Readonly options are never written, so a site-side change to one is no reason to stop.
36
+ const writableNames = new Set(writable.map((option) => option.name));
37
+ const beforeState = await this.captureBeforePushState(provider, dir, (name) => writableNames.has(name));
36
38
  if (skipped.length > 0) {
37
39
  this.log(`Skipping ${pluralize(skipped.length, 'readonly option')}: ${skipped.map((option) => option.name).join(', ')}`);
38
40
  }
@@ -29,13 +29,14 @@ export default class Push extends PushCommand {
29
29
  this.error(`No page "${args.slug}" in ${path} (expected ${args.slug}.html).`);
30
30
  }
31
31
  this.log(`Found ${pluralize(selected.length, 'page')} to push`);
32
- if (this.dryRun)
33
- await this.previewRemote(getResourceStateProvider('page'), path);
32
+ const provider = getResourceStateProvider('page');
33
+ const beforeState = await this.captureBeforePushState(provider, path, args.slug === undefined ? undefined : (id) => id === args.slug);
34
34
  const pushed = [];
35
35
  await this.runPushTasks(selected, (page) => page.slug, async (page, task) => {
36
36
  await this.pushPage(page, task);
37
37
  pushed.push(page.slug);
38
38
  });
39
+ await this.writeAfterPushSnapshot(provider, path, beforeState);
39
40
  if (this.failedCount > 0) {
40
41
  this.error(`${pluralize(this.failedCount, 'page')} failed to push.`);
41
42
  }
@@ -28,7 +28,12 @@ export default class Audit extends LoopressCommand {
28
28
  }
29
29
  async auditOne(slug, pinned) {
30
30
  const [vulns, info] = await Promise.all([this.fetchVulnerabilities(slug), this.fetchHealth(slug)]);
31
- const advisories = vulns.filter((v) => pinned === 'latest' || affects(pinned, v.fixedIn)).map((v) => ({ ...v, slug }));
31
+ // "latest" is the newest WordPress.org release, the one a push installs: audit that version.
32
+ // Unknown (WordPress.org unreachable), every advisory is reported, safer than none.
33
+ const version = pinned === 'latest' && typeof info?.version === 'string' ? info.version : pinned;
34
+ const advisories = vulns
35
+ .filter((v) => version === 'latest' || affects(version, v.range))
36
+ .map(({ range: _range, ...v }) => ({ ...v, slug }));
32
37
  const health = info ? describeHealth(slug, pinned, info) : [];
33
38
  return { advisories, health };
34
39
  }
@@ -72,8 +77,13 @@ export default class Audit extends LoopressCommand {
72
77
  const noun = advisories.length === 1 ? 'advisory' : 'advisories';
73
78
  this.log(`\n${advisories.length} vulnerability ${noun}, ${health.length} health note(s). ` +
74
79
  'Vulnerability data: wpvulnerability.net.');
75
- if (advisories.length > 0)
76
- this.exit(1);
80
+ // exit() throws before oclif prints the --json result, which a CI reading it needs most here.
81
+ if (advisories.length > 0) {
82
+ if (this.jsonEnabled())
83
+ process.exitCode = 1;
84
+ else
85
+ this.exit(1);
86
+ }
77
87
  return { advisories, health, status: 'issues' };
78
88
  }
79
89
  }
@@ -86,34 +96,41 @@ function normalizeAdvisory(raw) {
86
96
  const cvss = (raw.cvss ?? {});
87
97
  const source = Array.isArray(raw.source) ? raw.source : [];
88
98
  const cve = typeof raw.cve === 'string' ? raw.cve : source.find((s) => s.id?.startsWith('CVE-'))?.id;
99
+ const range = extractRange(raw);
89
100
  return {
90
101
  cve,
91
- fixedIn: extractFixedIn(raw),
102
+ fixedIn: range.max?.op === 'lt' ? range.max.version : undefined,
103
+ range,
92
104
  score: cvss.score,
93
105
  severity: cvss.severity,
94
- title: typeof raw.name === 'string' ? raw.name : 'Unnamed advisory',
106
+ title: typeof raw.name === 'string' ? decodeEntities(raw.name) : 'Unnamed advisory',
95
107
  };
96
108
  }
97
- // wpvulnerability's `impact.software[].versions[]` carries `to_version` / `to_compare`; the
98
- // first "< X" bound is the version the fix landed in. Best-effort: the shape varies, so a
99
- // missing bound just means we can't tell and the advisory is reported anyway.
100
- function extractFixedIn(raw) {
101
- const impact = raw.impact;
102
- for (const sw of impact?.software ?? []) {
103
- for (const v of sw.versions ?? []) {
104
- if (v.to_compare === '<' && typeof v.to_version === 'string')
105
- return v.to_version;
106
- }
107
- }
108
- return undefined;
109
+ // wpvulnerability's `operator`: `max_version` with `max_operator` "lt" (fixed in that version),
110
+ // "le" (affected up to it, no fix yet) or "eq" (that version only), and an optional lower bound `min_version` with
111
+ // `min_operator` "gt" or "ge". No upper bound means every version from the lower one on.
112
+ function extractRange(raw) {
113
+ const op = (raw.operator ?? {});
114
+ const bound = (version, operator) => typeof version === 'string' && version !== '' && typeof operator === 'string' ? { op: operator, version } : undefined;
115
+ return { max: bound(op.max_version, op.max_operator), min: bound(op.min_version, op.min_operator) };
116
+ }
117
+ // A bound we can't evaluate (odd version strings, unknown operator) counts as affected: a false
118
+ // positive is safer than hiding an advisory.
119
+ function affects(version, range) {
120
+ const within = (bound, ok) => {
121
+ if (!bound)
122
+ return true;
123
+ const order = compareVersions(version, bound.version);
124
+ return order === null || !Object.hasOwn(ok, bound.op) || ok[bound.op](order);
125
+ };
126
+ return (within(range.min, { ge: (o) => o >= 0, gt: (o) => o > 0 }) && within(range.max, { eq: (o) => o === 0, le: (o) => o <= 0, lt: (o) => o < 0 }));
109
127
  }
110
- // If we know the fix version, a pin below it is affected. If we can't compare (odd version
111
- // strings), report it anyway: a false positive is safer than hiding an advisory.
112
- function affects(pinned, fixedIn) {
113
- if (!fixedIn)
114
- return true;
115
- const order = compareVersions(pinned, fixedIn);
116
- return order === null || order < 0;
128
+ // Advisory names come HTML-encoded ("WPForms &#8211; AI Form Builder").
129
+ function decodeEntities(text) {
130
+ return text
131
+ .replaceAll(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
132
+ .replaceAll('&quot;', '"')
133
+ .replaceAll('&amp;', '&');
117
134
  }
118
135
  function describeHealth(slug, pinned, info) {
119
136
  const notes = [];
@@ -13,6 +13,7 @@ export default class Promote extends Command {
13
13
  static enableJsonFlag: boolean;
14
14
  static examples: string[];
15
15
  static flags: {
16
+ overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
17
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
18
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
19
  };
@@ -8,6 +8,7 @@ import { configManager } from '../config/project-config.manager.js';
8
8
  import { LoopressCommand } from '../lib/base.js';
9
9
  import { isInteractive } from '../lib/interactive.js';
10
10
  import { stdoutToStderr } from '../lib/json-delegation.js';
11
+ import { PushCommand } from '../lib/push-command.js';
11
12
  import { readLocalConfig } from '../utils/loopress-config.js';
12
13
  import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
13
14
  export default class Promote extends Command {
@@ -21,10 +22,11 @@ export default class Promote extends Command {
21
22
  static flags = {
22
23
  ...LoopressCommand.dryRunFlag,
23
24
  ...LoopressCommand.yesFlag,
25
+ overwrite: PushCommand.baseFlags.overwrite,
24
26
  };
25
27
  async run() {
26
28
  const { args, flags } = await this.parse(Promote);
27
- const { yes } = flags;
29
+ const { overwrite, yes } = flags;
28
30
  const dryRun = flags['dry-run'];
29
31
  const { environments, projectName } = await this.resolveProject();
30
32
  const from = this.requireEnvironment(environments, projectName, args.from);
@@ -52,10 +54,12 @@ export default class Promote extends Command {
52
54
  }
53
55
  catch (error) {
54
56
  // A partial pull must never be pushed onward: stop before touching <to>.
55
- this.error(`Pull from ${from.name} failed, ${to.name} left untouched: ${error.message}`);
57
+ // "untouched" is only true of <to>: a real (non dry-run) pull has already rewritten the files.
58
+ const filesNote = dryRun ? '' : ` Your local files may already hold part of ${from.name}, review them before pushing.`;
59
+ this.error(`Pull from ${from.name} failed, nothing was pushed to ${to.name}.${filesNote} ${error.message}`);
56
60
  }
57
61
  this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
58
- await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', this.delegateArgv(to.name, dryRun)));
62
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', [...this.delegateArgv(to.name, dryRun), ...(overwrite ? ['--overwrite'] : [])]));
59
63
  }
60
64
  finally {
61
65
  if (scratch) {
@@ -2,7 +2,7 @@ import { LoopressCommand } from '../lib/base.js';
2
2
  type PullTargetResult = {
3
3
  error?: string;
4
4
  label: string;
5
- status: 'failed' | 'pulled';
5
+ status: 'failed' | 'pulled' | 'skipped';
6
6
  };
7
7
  type PullResult = {
8
8
  results: PullTargetResult[];
@@ -18,5 +18,6 @@ export default class Pull extends LoopressCommand {
18
18
  private failedCount;
19
19
  run(): Promise<PullResult>;
20
20
  private buildArgv;
21
+ private usesResource;
21
22
  }
22
23
  export {};
@@ -1,6 +1,12 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
1
3
  import { LoopressCommand } from '../lib/base.js';
2
4
  import { stdoutToStderr } from '../lib/json-delegation.js';
3
5
  import { pluralize } from '../utils/pluralize.js';
6
+ import { resolveResourceDir } from '../utils/resource-dirs.js';
7
+ // How the plugin reports its integration missing: "ACF is not active", "No supported form
8
+ // plugin is active", "No supported snippet plugin is active".
9
+ const PLUGIN_MISSING = /\bis not active\b|\bNo supported .+ plugin is active\b/;
4
10
  // composer before plugins: when a composer.json exists, `plugin:pull` merges the live plugins
5
11
  // into it, so it must run on the freshly pulled one or `composer:pull` would overwrite its work. The rest write to independent
6
12
  // directories and their order does not matter, it mirrors `lps push` for familiarity.
@@ -11,14 +17,14 @@ const PULL_TARGETS = [
11
17
  { commandId: 'taxonomy:pull', label: 'taxonomies', supportsYes: true },
12
18
  { commandId: 'meta:pull', label: 'post meta', supportsYes: true },
13
19
  { commandId: 'role:pull', label: 'roles', supportsYes: true },
14
- { commandId: 'acf:pull', label: 'ACF', supportsYes: true },
20
+ { commandId: 'acf:pull', label: 'ACF', needsPlugin: 'acf', supportsYes: true },
15
21
  { commandId: 'api:pull', label: 'API routes', supportsYes: true },
16
22
  { commandId: 'hook:pull', label: 'hooks', supportsYes: true },
17
- { commandId: 'form:pull', label: 'forms', supportsYes: true },
23
+ { commandId: 'form:pull', label: 'forms', needsPlugin: 'form', supportsYes: true },
18
24
  { commandId: 'pattern:pull', label: 'patterns', supportsYes: true },
19
25
  { commandId: 'menu:pull', label: 'menus', supportsYes: true },
20
26
  { commandId: 'option:pull', label: 'options', supportsYes: true },
21
- { commandId: 'snippet:pull', label: 'snippets', supportsYes: true },
27
+ { commandId: 'snippet:pull', label: 'snippets', needsPlugin: 'snippets', supportsYes: true },
22
28
  ];
23
29
  export default class Pull extends LoopressCommand {
24
30
  static description = 'Pull all content, plugins, composer dependencies, post types, taxonomies, post meta, roles, ACF, API routes, hooks, forms, patterns, menus, options, and snippets, from WordPress';
@@ -40,8 +46,19 @@ export default class Pull extends LoopressCommand {
40
46
  results.push({ label: target.label, status: 'pulled' });
41
47
  }
42
48
  catch (error) {
43
- this.failedCount++;
44
49
  const { message } = error;
50
+ // A site without ACF (or a form or snippet plugin) is the common case, not a failure,
51
+ // as long as the project doesn't use that resource: a vanilla site must pull, and a
52
+ // promote must not stop on it. A project that does use it still fails.
53
+ if (target.needsPlugin && PLUGIN_MISSING.test(message) && !this.usesResource(target.needsPlugin)) {
54
+ // oclif's catch already set exitCode to 1 for the sub-command; a real failure still
55
+ // exits non-zero through the this.error() below.
56
+ process.exitCode = undefined;
57
+ this.log(`→ ${target.label}: not configured, skipped`);
58
+ results.push({ label: target.label, status: 'skipped' });
59
+ continue;
60
+ }
61
+ this.failedCount++;
45
62
  this.log(`✗ ${target.label} failed: ${message}`);
46
63
  results.push({ error: message, label: target.label, status: 'failed' });
47
64
  }
@@ -65,4 +82,7 @@ export default class Pull extends LoopressCommand {
65
82
  argv.push('--yes');
66
83
  return argv;
67
84
  }
85
+ usesResource(kind) {
86
+ return existsSync(resolve(resolveResourceDir(kind, this.localConfig)));
87
+ }
68
88
  }
@@ -2,7 +2,7 @@ import { LoopressCommand } from '../lib/base.js';
2
2
  type PushTargetResult = {
3
3
  error?: string;
4
4
  label: string;
5
- status: 'failed' | 'pushed';
5
+ status: 'failed' | 'pushed' | 'skipped';
6
6
  };
7
7
  type PushResult = {
8
8
  results: PushTargetResult[];
@@ -12,13 +12,15 @@ export default class Push extends LoopressCommand {
12
12
  static enableJsonFlag: boolean;
13
13
  static examples: string[];
14
14
  static flags: {
15
+ overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
16
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
17
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
18
  };
18
- private failedCount;
19
19
  run(): Promise<PushResult>;
20
20
  private buildArgv;
21
21
  private composerDeclaresPlugins;
22
+ private composerJsonPath;
22
23
  private guardProductionPush;
24
+ private skippedTargets;
23
25
  }
24
26
  export {};