@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
@@ -1,12 +1,13 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
3
+ import { join, resolve } from 'node:path';
4
4
  import { LoopressCommand } from '../lib/base.js';
5
5
  import { guardProductionPush } from '../lib/guard-production-push.js';
6
6
  import { stdoutToStderr } from '../lib/json-delegation.js';
7
+ import { PushCommand } from '../lib/push-command.js';
7
8
  import { isLocalEnvironment } from '../lib/wp-client.js';
8
9
  import { wpackagistRequire } from '../utils/composer.js';
9
- import { pluralize } from '../utils/pluralize.js';
10
+ import { resolveResourceDir } from '../utils/resource-dirs.js';
10
11
  // Dependency-ish order: code first (plugins, composer), then the ACF definitions content
11
12
  // relies on, then content itself.
12
13
  const PUSH_TARGETS = [
@@ -37,18 +38,19 @@ export default class Push extends LoopressCommand {
37
38
  static flags = {
38
39
  ...LoopressCommand.dryRunFlag,
39
40
  ...LoopressCommand.yesFlag,
41
+ overwrite: PushCommand.baseFlags.overwrite,
40
42
  };
41
- failedCount = 0;
42
43
  async run() {
43
44
  await this.guardProductionPush();
44
45
  this.warnRemovedSeoModule();
45
46
  const argv = this.buildArgv();
46
47
  const results = [];
47
- const composerPushedByPlugins = await this.composerDeclaresPlugins();
48
+ const skips = await this.skippedTargets();
48
49
  for (const target of PUSH_TARGETS) {
49
- if (composerPushedByPlugins && target.commandId === 'composer:push') {
50
- this.log('\n→ composer: already pushed whole by plugins');
51
- results.push({ label: target.label, status: 'pushed' });
50
+ const skip = skips.get(target.commandId);
51
+ if (skip) {
52
+ this.log(`\n→ ${target.label}: ${skip.note}`);
53
+ results.push({ label: target.label, status: skip.status });
52
54
  continue;
53
55
  }
54
56
  this.log(`\n→ Pushing ${target.label}...`);
@@ -58,20 +60,16 @@ export default class Push extends LoopressCommand {
58
60
  results.push({ label: target.label, status: 'pushed' });
59
61
  }
60
62
  catch (error) {
61
- this.failedCount++;
62
63
  const { message } = error;
63
64
  this.log(`✗ ${target.label} failed: ${message}`);
64
65
  results.push({ error: message, label: target.label, status: 'failed' });
66
+ // Stop here: later targets depend on earlier ones (hooks use what composer installs,
67
+ // pages need their templates), so pushing them anyway can ship a silently broken site.
68
+ const skipped = PUSH_TARGETS.slice(PUSH_TARGETS.indexOf(target) + 1).map((next) => next.label);
69
+ const skippedNote = skipped.length > 0 ? ` Not attempted: ${skipped.join(', ')}.` : '';
70
+ this.error(`${target.label} failed to push: ${message}.${skippedNote}`);
65
71
  }
66
72
  }
67
- if (this.failedCount > 0) {
68
- // Each failure's own reason, not just the count: under --json the per-resource lines above
69
- // never reach stdout, and this message is all an MCP caller or a script gets to see.
70
- const failures = results
71
- .filter((result) => result.status === 'failed')
72
- .map((result) => `${result.label}: ${result.error}`);
73
- this.error(`${pluralize(this.failedCount, 'resource')} failed to push. ${failures.join('; ')}`);
74
- }
75
73
  this.log(this.dryRun ? '\nEvery resource would push.' : '\nAll resources pushed.');
76
74
  return { results };
77
75
  }
@@ -82,12 +80,15 @@ export default class Push extends LoopressCommand {
82
80
  const argv = ['--env', this.siteConfig.name, '--yes'];
83
81
  if (this.dryRun)
84
82
  argv.push('--dry-run');
83
+ // Not implied by --yes: overwriting changes made outside Loopress is its own decision.
84
+ if (this.overwrite)
85
+ argv.push('--overwrite');
85
86
  return argv;
86
87
  }
87
88
  // A composer.json that declares plugins is pushed whole by `plugin:push` (with its activation
88
89
  // safety), so `composer:push` would only run Composer on the server a second time.
89
90
  async composerDeclaresPlugins() {
90
- const path = join(process.cwd(), this.rootDir, 'composer.json');
91
+ const path = this.composerJsonPath();
91
92
  if (!existsSync(path))
92
93
  return false;
93
94
  try {
@@ -99,6 +100,9 @@ export default class Push extends LoopressCommand {
99
100
  return false;
100
101
  }
101
102
  }
103
+ composerJsonPath() {
104
+ return join(process.cwd(), this.rootDir, 'composer.json');
105
+ }
102
106
  // Guards once here (shared with PushCommand, lib/push-command.ts) rather than once per
103
107
  // delegated command.
104
108
  async guardProductionPush() {
@@ -109,4 +113,25 @@ export default class Push extends LoopressCommand {
109
113
  yes: this.yes,
110
114
  });
111
115
  }
116
+ // Unconfigured is not a failure: a project that doesn't manage plugins or composer through
117
+ // Loopress must not stop the run before anything else gets pushed.
118
+ async skippedTargets() {
119
+ const skips = new Map();
120
+ const notConfigured = { note: 'not configured, skipped', status: 'skipped' };
121
+ const hasComposerJson = existsSync(this.composerJsonPath());
122
+ const composerPushedByPlugins = await this.composerDeclaresPlugins();
123
+ if (composerPushedByPlugins)
124
+ skips.set('composer:push', { note: 'already pushed whole by plugins', status: 'pushed' });
125
+ else if (!hasComposerJson)
126
+ skips.set('composer:push', notConfigured);
127
+ // With a composer.json, plugins come from it only; otherwise from loopress.json.
128
+ const pluginsConfigured = hasComposerJson ? composerPushedByPlugins : Object.keys(this.localConfig.plugins ?? {}).length > 0;
129
+ if (!pluginsConfigured)
130
+ skips.set('plugin:push', notConfigured);
131
+ // The only push that fails on a missing directory (the others find 0 files), so a project
132
+ // that never used snippets would otherwise end every `lps push` on an ENOENT.
133
+ if (!existsSync(resolve(resolveResourceDir('snippets', this.localConfig))))
134
+ skips.set('snippet:push', notConfigured);
135
+ return skips;
136
+ }
112
137
  }
@@ -24,5 +24,6 @@ export default class Push extends PushCommand {
24
24
  private loadSnippets;
25
25
  private pushSnippet;
26
26
  private toPayload;
27
+ private withoutDuplicateIds;
27
28
  }
28
29
  export {};
@@ -28,7 +28,7 @@ export default class Push extends PushCommand {
28
28
  this.log(`Snippets path: ${path}`);
29
29
  const provider = getResourceStateProvider('snippet');
30
30
  const beforeState = await this.captureBeforePushState(provider, path);
31
- const snippets = await this.loadSnippets(path);
31
+ const snippets = this.withoutDuplicateIds(await this.loadSnippets(path));
32
32
  this.log(`Found ${pluralize(snippets.length, 'snippet')} to push`);
33
33
  const pushed = [];
34
34
  await this.runPushTasks(snippets, (snippet) => snippet.name, async (snippet, task) => {
@@ -148,8 +148,11 @@ export default class Push extends PushCommand {
148
148
  toPayload(snippet) {
149
149
  return {
150
150
  active: snippet.active,
151
- code: stripPhpOpeningTag(snippet.code),
152
- description: `Imported from ${snippet.path}`,
151
+ // Only PHP files carry an editor-only `<?php` tag: an HTML snippet starting with an inline
152
+ // `<?php ... ?>` block is its real content and must reach WordPress untouched.
153
+ code: snippet.type === 'php' ? stripPhpOpeningTag(snippet.code) : snippet.code,
154
+ // The sidecar's description wins, so a pull then push never erases one written in wp-admin.
155
+ description: snippet.description ?? `Imported from ${snippet.path}`,
153
156
  insertMethod: snippet.insertMethod,
154
157
  location: snippet.location,
155
158
  name: snippet.name,
@@ -159,4 +162,22 @@ export default class Push extends PushCommand {
159
162
  type: snippet.type,
160
163
  };
161
164
  }
165
+ // Two files claiming the same id would each PUT over that id, the last one silently replacing
166
+ // the other on the site (seen after switching snippet provider, whose ids restart). Neither is
167
+ // pushed, and the push fails, until the files are fixed by hand.
168
+ withoutDuplicateIds(snippets) {
169
+ const pathsById = new Map();
170
+ for (const { id, path } of snippets) {
171
+ if (id !== undefined)
172
+ pathsById.set(id, [...(pathsById.get(id) ?? []), path]);
173
+ }
174
+ return snippets.filter((snippet) => {
175
+ const paths = snippet.id === undefined ? [] : pathsById.get(snippet.id);
176
+ if (paths.length < 2)
177
+ return true;
178
+ this.failedCount++;
179
+ this.warn(`Skipping "${snippet.path}": id ${snippet.id} is also used by ${paths.filter((p) => p !== snippet.path).join(', ')}.`);
180
+ return false;
181
+ });
182
+ }
162
183
  }
@@ -16,8 +16,13 @@ export default class Pull extends LoopressCommand {
16
16
  const { url } = this.siteConfig;
17
17
  this.log(`Pulling themes from ${url}`);
18
18
  const raw = await this.wp.get('wp/v2/themes');
19
+ // A theme with no Version header can't be pinned, and `theme version push` would then fail on
20
+ // it: the Loopress child theme (`<parent>-loopress`, written by `theme template push`) is one.
19
21
  const installed = parseInstalledThemes(raw);
20
- const incoming = Object.fromEntries(installed.map((t) => [t.slug, t.version]));
22
+ const unversioned = installed.filter((t) => t.version === '').map((t) => t.slug);
23
+ const incoming = Object.fromEntries(installed.filter((t) => t.version !== '').map((t) => [t.slug, t.version]));
24
+ if (unversioned.length > 0)
25
+ this.log(` - Skipped: ${unversioned.join(', ')} (no version to pin)`);
21
26
  // A composer.json is authoritative over loopress.json: pin the live versions there instead.
22
27
  const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
23
28
  if (existsSync(composerJsonPath)) {
@@ -14,6 +14,7 @@ export declare abstract class LoopressCommand extends Command {
14
14
  };
15
15
  protected dryRun: boolean;
16
16
  protected localConfig: LoopressLocalConfig;
17
+ protected overwrite: boolean;
17
18
  protected productionConfirmed: boolean;
18
19
  protected projectId: string;
19
20
  protected siteConfig: EnvironmentConfig;
package/dist/lib/base.js CHANGED
@@ -28,6 +28,8 @@ export class LoopressCommand extends Command {
28
28
  };
29
29
  dryRun = false;
30
30
  localConfig = {};
31
+ // Set by PushCommand's --overwrite, see PushCommand.guardOutsideEdits.
32
+ overwrite = false;
31
33
  // Set by PushCommand's hidden --production-confirmed, see there.
32
34
  productionConfirmed = false;
33
35
  projectId;
@@ -58,6 +60,7 @@ export class LoopressCommand extends Command {
58
60
  }));
59
61
  this.dryRun = Boolean(flags['dry-run']);
60
62
  this.yes = Boolean(flags.yes);
63
+ this.overwrite = Boolean(flags.overwrite);
61
64
  this.productionConfirmed = Boolean(flags['production-confirmed']);
62
65
  this.localConfig = await readLocalConfig();
63
66
  const resolved = this.resolveEnvironment(flags.env);
@@ -23,6 +23,7 @@ export type DiffTarget = {
23
23
  type DiffSides = {
24
24
  againstWp: undefined | WpClient;
25
25
  leftLabel: string;
26
+ rightHeading: string;
26
27
  rightLabel: string;
27
28
  warn: (message: string) => void;
28
29
  };
@@ -37,6 +38,7 @@ export declare abstract class DiffCommand extends LoopressCommand {
37
38
  abstract run(): Promise<DiffJson>;
38
39
  private computeResource;
39
40
  private out;
41
+ private renderFailure;
40
42
  private renderSection;
41
43
  private wpForEnvironment;
42
44
  }
@@ -47,8 +47,16 @@ export class DiffCommand extends LoopressCommand {
47
47
  // a tally, and the verdict, then returns the structured result. Exit code: 1 on drift, 2
48
48
  // when a resource could not be compared (inconclusive), so the command doubles as a CI gate.
49
49
  async report(targets, sides) {
50
- this.out(`Comparing ${c('bold', sides.leftLabel)} ${c('dim', '→')} ${c('bold', sides.rightLabel)}\n`);
50
+ this.out(`Comparing ${c('bold', sides.leftLabel)} ${c('dim', '→')} ${c('bold', sides.rightHeading)}\n`);
51
51
  const results = await Promise.all(targets.map(async (target) => this.computeResource(target, sides)));
52
+ // Every resource failing the same way (site down, bad credentials) is one problem: say it
53
+ // once instead of once per resource.
54
+ const sharedError = sharedFailure(results.map((result) => result.json.error));
55
+ if (sharedError !== undefined) {
56
+ // Named, since some resources may have compared fine (one that needs no request).
57
+ const failedTitles = results.filter((result) => result.json.error !== undefined).map((result) => result.title);
58
+ this.out(c('red', `error (${failedTitles.join(', ')}): ${sharedError}`));
59
+ }
52
60
  const resources = {};
53
61
  let drift = false;
54
62
  let failed = false;
@@ -60,8 +68,7 @@ export class DiffCommand extends LoopressCommand {
60
68
  resources[result.resource] = result.json;
61
69
  if (result.json.error !== undefined) {
62
70
  failed = true;
63
- const errorLabel = c('red', `error: ${result.json.error}`);
64
- this.out(`${c('bold', result.title)} ${errorLabel}`);
71
+ this.renderFailure(result.title, result.json.error, sharedError);
65
72
  continue;
66
73
  }
67
74
  compared += 1;
@@ -91,6 +98,7 @@ export class DiffCommand extends LoopressCommand {
91
98
  return {
92
99
  againstWp: against ? this.wpForEnvironment(against) : undefined,
93
100
  leftLabel: this.siteConfig.name,
101
+ rightHeading: against ?? 'local files',
94
102
  rightLabel: against ?? 'local',
95
103
  warn: (message) => {
96
104
  this.warn(message);
@@ -120,6 +128,13 @@ export class DiffCommand extends LoopressCommand {
120
128
  if (!this.jsonEnabled())
121
129
  this.log(message);
122
130
  }
131
+ renderFailure(title, error, sharedError) {
132
+ // Already printed once by `report` when every failure is the same.
133
+ if (sharedError !== undefined)
134
+ return;
135
+ const errorLabel = c('red', `error: ${error}`);
136
+ this.out(`${c('bold', title)} ${errorLabel}`);
137
+ }
123
138
  renderSection(title, stateDiff) {
124
139
  if (isEmptyDiff(stateDiff)) {
125
140
  this.out(`${c('bold', title)} ${c('dim', 'in sync')}`);
@@ -180,3 +195,9 @@ function indentPatch(patch) {
180
195
  .join('\n')
181
196
  .trimEnd();
182
197
  }
198
+ // The one error every failed resource shares, when more than one failed (see `report`).
199
+ function sharedFailure(errors) {
200
+ const failures = errors.filter((error) => error !== undefined);
201
+ const distinct = new Set(failures);
202
+ return failures.length > 1 && distinct.size === 1 ? failures[0] : undefined;
203
+ }
@@ -26,6 +26,7 @@ async function loadSnippet(path, file, onSkip) {
26
26
  }
27
27
  let id;
28
28
  let name;
29
+ let description;
29
30
  let type;
30
31
  let isActive = false;
31
32
  let tags = [];
@@ -40,6 +41,8 @@ async function loadSnippet(path, file, onSkip) {
40
41
  // `||`, not `??`: an empty-string name in the sidecar JSON counts as absent too.
41
42
  // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
42
43
  name = meta.name || undefined;
44
+ // Kept even when empty: a description cleared on purpose, not one to replace with "Imported from <path>".
45
+ description = meta.description;
43
46
  type = parseType(meta.type) ?? undefined;
44
47
  isActive = Boolean(meta.active);
45
48
  tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
@@ -58,6 +61,7 @@ async function loadSnippet(path, file, onSkip) {
58
61
  return {
59
62
  active: isActive,
60
63
  code: content,
64
+ description,
61
65
  id,
62
66
  insertMethod: insertMethod ?? 'auto',
63
67
  location: location ?? defaultLocationForType(resolvedType),
@@ -30,7 +30,15 @@ type RemotePhpFile = {
30
30
  filename: string;
31
31
  public?: boolean;
32
32
  revision?: string;
33
+ runtimeError?: RuntimeError;
33
34
  };
35
+ export type RuntimeError = {
36
+ at: string;
37
+ message: string;
38
+ method: string;
39
+ type: 'action' | 'cron' | 'filter';
40
+ };
41
+ export declare function formatRuntimeError(error: RuntimeError): string;
34
42
  type PushResult = {
35
43
  pruned: string[];
36
44
  pushed: string[];
@@ -14,6 +14,9 @@ import { loadFiles as loadDirectoryFiles } from './load-files.js';
14
14
  import { PushCommand } from './push-command.js';
15
15
  import { getResourceStateProvider } from './resource-state.js';
16
16
  import { isApplicative404 } from './wp-client.js';
17
+ export function formatRuntimeError(error) {
18
+ return `${error.method}() threw while running as ${error.type === 'action' ? 'an' : 'a'} ${error.type}, last at ${error.at}: ${error.message}`;
19
+ }
17
20
  // Mirrors wordpress-plugin FileWriter::DECLARE_PATTERN / withGuard(): the server rejects both
18
21
  // an absent declare(strict_types=1); and one that appears more than once (it needs a single
19
22
  // unambiguous insertion point for the ABSPATH guard). Matching "exactly once" here, not just
@@ -262,6 +265,8 @@ export function resourceListCommand(spec) {
262
265
  for (const file of files) {
263
266
  const badges = [file.public ? '[PUBLIC]' : '', file.error ? `(${file.error})` : ''].filter(Boolean).join(' ');
264
267
  this.log(badges ? ` ${file.filename} ${badges}` : ` ${file.filename}`);
268
+ if (file.runtimeError)
269
+ this.log(` ${formatRuntimeError(file.runtimeError)}`);
265
270
  }
266
271
  if (files.some((file) => file.public)) {
267
272
  this.log('');
@@ -3,16 +3,17 @@ import { type ResourceState } from './diff-state.js';
3
3
  import { type ResourceStateProvider } from './resource-state.js';
4
4
  export declare abstract class PushCommand extends LoopressCommand {
5
5
  static baseFlags: {
6
+ overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
6
7
  'production-confirmed': import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
8
  env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
9
  };
9
10
  protected failedCount: number;
10
11
  private refusedByGuard;
11
- protected captureBeforePushState(provider: ResourceStateProvider, dir: string): Promise<ResourceState | undefined>;
12
+ protected captureBeforePushState(provider: ResourceStateProvider, dir: string, writes?: (id: string) => boolean): Promise<ResourceState | undefined>;
12
13
  catch(err: Error): Promise<void>;
13
14
  protected guardProductionPush(): Promise<void>;
14
15
  init(): Promise<void>;
15
- protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<void>;
16
+ protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<ResourceState>;
16
17
  protected recordDeployment(status: 'failure' | 'success'): Promise<void>;
17
18
  protected recordSuccess(): Promise<void>;
18
19
  protected reportPushToSite(): Promise<void>;
@@ -23,4 +24,5 @@ export declare abstract class PushCommand extends LoopressCommand {
23
24
  output: string;
24
25
  }) => Promise<void>): Promise<void>;
25
26
  protected writeAfterPushSnapshot(provider: ResourceStateProvider, dir: string, beforeState: ResourceState | undefined): Promise<void>;
27
+ private guardOutsideEdits;
26
28
  }
@@ -1,18 +1,22 @@
1
+ import { confirm } from '@inquirer/prompts';
1
2
  import { Flags } from '@oclif/core';
2
3
  import got from 'got';
3
4
  import { Listr } from 'listr2';
4
5
  import { resolve } from 'node:path';
6
+ import { isDeepStrictEqual } from 'node:util';
5
7
  import { authManager } from '../config/auth.manager.js';
6
8
  import { API_URL } from './api-client.js';
7
9
  import { LoopressCommand } from './base.js';
8
10
  import { guardProductionPush } from './guard-production-push.js';
9
- import { writeSnapshot } from './snapshot-store.js';
11
+ import { isInteractive } from './interactive.js';
12
+ import { readSnapshot, recordToState, writeSnapshot } from './snapshot-store.js';
10
13
  import { withoutSpinner, withSpinner } from './spinner.js';
11
14
  export class PushCommand extends LoopressCommand {
12
15
  // Passed by an aggregate command (`lps theme push`) that already confirmed production once,
13
16
  // so its steps skip that prompt while still asking their own confirmations (uninstalls).
14
17
  static baseFlags = {
15
18
  ...LoopressCommand.baseFlags,
19
+ overwrite: Flags.boolean({ description: 'Push even over changes made on the site outside Loopress since your last push' }),
16
20
  'production-confirmed': Flags.boolean({ hidden: true }),
17
21
  };
18
22
  failedCount = 0;
@@ -26,13 +30,17 @@ export class PushCommand extends LoopressCommand {
26
30
  // `writeAfterPushSnapshot` there is no restore point to complete.
27
31
  //
28
32
  // On a dry run, the same read is the preview's check against the site (see previewRemote).
29
- async captureBeforePushState(provider, dir) {
33
+ //
34
+ // `writes` narrows the outside-edit check to the ids this push actually writes, for a push that
35
+ // skips some local items (`lps page push <slug>`, readonly options, `lps acf push --type`).
36
+ async captureBeforePushState(provider, dir, writes) {
30
37
  if (this.dryRun) {
31
- await this.previewRemote(provider, dir);
38
+ await this.guardOutsideEdits(provider, dir, await this.previewRemote(provider, dir), writes);
32
39
  return undefined;
33
40
  }
41
+ let remote;
34
42
  try {
35
- return await provider.remote(this.wp, (message) => {
43
+ remote = await provider.remote(this.wp, (message) => {
36
44
  this.warn(message);
37
45
  }, dir);
38
46
  }
@@ -40,6 +48,8 @@ export class PushCommand extends LoopressCommand {
40
48
  this.warn(`Could not read remote state for a rollback snapshot: ${error.message}`);
41
49
  return undefined;
42
50
  }
51
+ await this.guardOutsideEdits(provider, dir, remote, writes);
52
+ return remote;
43
53
  }
44
54
  async catch(err) {
45
55
  if (!this.dryRun && !this.refusedByGuard && this.siteConfig) {
@@ -68,7 +78,7 @@ export class PushCommand extends LoopressCommand {
68
78
  // pre-apply check built on it, see what the real push will hit.
69
79
  async previewRemote(provider, dir) {
70
80
  try {
71
- await provider.remote(this.wp, () => { }, dir);
81
+ return await provider.remote(this.wp, () => { }, dir);
72
82
  }
73
83
  catch (error) {
74
84
  this.error(`The site would refuse this push: ${error.message}`);
@@ -165,4 +175,54 @@ export class PushCommand extends LoopressCommand {
165
175
  this.warn(`Could not save a rollback snapshot after this push: ${error.message}`);
166
176
  }
167
177
  }
178
+ // Refuses to silently overwrite a change made on the site outside Loopress (wp-admin, another
179
+ // tool) since this machine's last push. Flags an id only when both hold:
180
+ // - the site no longer matches what that push left (the latest snapshot's `afterState`, the
181
+ // same reference rollback's drift check uses), and
182
+ // - this push would write a different value over it (local differs from the site), so a
183
+ // `pull` that already brought the change into the local files is no reason to stop.
184
+ // Only ids present locally count: a push is additive, it never touches a site-only id. And
185
+ // only ids the site still has with readable content: pushing over a deletion (`lps <resource>
186
+ // rm`, or wp-admin) loses nothing, and a file the site lists without its content (too large
187
+ // to send) can't be compared, so it would otherwise block every push.
188
+ // Ceiling: snapshots live in git-ignored `.loopress/`, so only the machine that pushed last
189
+ // is protected; a record kept on the site would cover teammates and CI.
190
+ async guardOutsideEdits(provider, dir, remote, writes) {
191
+ if (this.overwrite)
192
+ return;
193
+ let expected;
194
+ let local;
195
+ let pushedAt;
196
+ try {
197
+ const snapshot = await readSnapshot(resolve(process.cwd(), this.rootDir), provider.resource, this.siteConfig.name);
198
+ expected = recordToState(snapshot.afterState);
199
+ pushedAt = snapshot.createdAt;
200
+ local = await provider.local(dir, () => { });
201
+ }
202
+ catch {
203
+ // No earlier push from this machine, or unreadable local files the push reports itself.
204
+ return;
205
+ }
206
+ const overwritten = [...local.keys()].filter((id) => (writes?.(id) ?? true) &&
207
+ remote.get(id) !== undefined &&
208
+ !sameEntry(remote, expected, id) &&
209
+ !sameEntry(local, remote, id));
210
+ if (overwritten.length === 0)
211
+ return;
212
+ const message = `Changed on ${this.siteConfig.url} outside Loopress since your last push (${pushedAt}), this push would overwrite: ${overwritten.join(', ')}. ` +
213
+ `Review with \`lps diff --only ${provider.resource}\`, bring the site's version into your files, or pass --overwrite.`;
214
+ // A dry run only previews: say what the real push would stop on.
215
+ if (this.dryRun) {
216
+ this.warn(message);
217
+ return;
218
+ }
219
+ if (!isInteractive())
220
+ this.error(message);
221
+ this.warn(message);
222
+ if (!(await confirm({ default: false, message: 'Overwrite those changes?' })))
223
+ this.error('Aborted.');
224
+ }
225
+ }
226
+ function sameEntry(left, right, id) {
227
+ return left.has(id) === right.has(id) && isDeepStrictEqual(left.get(id), right.get(id));
168
228
  }
@@ -31,14 +31,14 @@ const ACF_VOLATILE_KEYS = ['modified'];
31
31
  // A WPForms form is a WP post; `modified`/`modified_gmt` move on every edit.
32
32
  const FORM_VOLATILE_KEYS = ['modified', 'modified_gmt'];
33
33
  // ---- snippets -------------------------------------------------------------------------------
34
- // `description` is dropped: `snippet push` overwrites it with "Imported from <path>" on every
35
- // push and `loadSnippets` never reads it back, so it is not part of the synced state. `id`
36
- // becomes the map key. `code` has its leading `<?php` stripped on both sides: `snippet pull`
37
- // re-adds it to PHP files for editor friendliness, WordPress stores only the body.
34
+ // `description` is dropped: a sidecar without one gets "Imported from <path>" on push, so
35
+ // comparing it would flag every such snippet as drifted. `id` becomes the map key. A PHP
36
+ // snippet's `code` has its leading `<?php` stripped on both sides: `snippet pull` re-adds it
37
+ // for editor friendliness, WordPress stores only the body. Other types keep it, it's content.
38
38
  function canonicalSnippet(snippet) {
39
39
  return {
40
40
  active: snippet.active,
41
- code: stripPhpOpeningTag(snippet.code),
41
+ code: snippet.type === 'php' ? stripPhpOpeningTag(snippet.code) : snippet.code,
42
42
  insertMethod: snippet.insertMethod,
43
43
  location: snippet.location,
44
44
  name: snippet.name,
@@ -126,8 +126,15 @@ export function resourceRollbackCommand(resource, options) {
126
126
  // commands/snippet/push.ts's `static args`), not a --path flag. --env/--yes: the
127
127
  // production guard and the drift confirmation above already covered what the delegated
128
128
  // push's own guard would ask again, same reasoning as the top-level `lps push`
129
- // delegating to each resource's push command in commands/push.ts.
130
- await this.config.runCommand(`${command.replaceAll(' ', ':')}:push`, [tmpDir, '--env', this.siteConfig.name, '--yes']);
129
+ // delegating to each resource's push command in commands/push.ts. --overwrite: the drift
130
+ // check above is the same question as the push's outside-edit check, already answered.
131
+ await this.config.runCommand(`${command.replaceAll(' ', ':')}:push`, [
132
+ tmpDir,
133
+ '--env',
134
+ this.siteConfig.name,
135
+ '--yes',
136
+ '--overwrite',
137
+ ]);
131
138
  }
132
139
  finally {
133
140
  // A cleanup failure here must never hide a real error from the push above: warn about
@@ -178,12 +178,18 @@ function checkLoopressJson(localConfig, problems) {
178
178
  if (plugins === undefined)
179
179
  return;
180
180
  if (typeof plugins !== 'object' || plugins === null || Array.isArray(plugins)) {
181
- problems.push({ file: 'loopress.json', message: '"plugins" must be an object of slug -> version string' });
181
+ problems.push({ file: 'loopress.json', message: '"plugins" must be an object of slug -> version string or {version, active}' });
182
182
  return;
183
183
  }
184
184
  for (const [slug, version] of Object.entries(plugins)) {
185
- if (typeof version !== 'string')
186
- problems.push({ file: 'loopress.json', message: `plugin "${slug}" must map to a string` });
185
+ // The object form is what `plugin pull` writes for a plugin installed but inactive (see PluginPin).
186
+ const isPin = typeof version === 'string' ||
187
+ (typeof version === 'object' &&
188
+ version !== null &&
189
+ typeof version.version === 'string' &&
190
+ ['boolean', 'undefined'].includes(typeof version.active));
191
+ if (!isPin)
192
+ problems.push({ file: 'loopress.json', message: `plugin "${slug}" must map to a version string or {version, active}` });
187
193
  }
188
194
  }
189
195
  async function checkSnippets(dir, problems) {
@@ -159,8 +159,21 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
159
159
  if (err.name === 'TimeoutError') {
160
160
  return `Request timed out after ${timeoutMs / 1000}s on ${url}. Is the site reachable?`;
161
161
  }
162
- return `Request to ${url} failed: ${err.message ?? String(error)}`;
162
+ const reason = err.code ? NETWORK_ERROR_HINTS[err.code] : undefined;
163
+ // The site, not the endpoint: every resource fails the same way when the site is down, so
164
+ // `lps diff` can print it once (see DiffCommand.report).
165
+ if (reason)
166
+ return `Could not reach ${new URL(url).origin}: ${reason}. Run \`lps doctor\` to check the environment.`;
167
+ // Node leaves `message` empty on some network errors (an AggregateError over IPv4 + IPv6):
168
+ // fall back to the error code so the reason is never blank.
169
+ return `Request to ${url} failed: ${err.message?.length ? err.message : (err.code ?? String(error))}`;
163
170
  }
171
+ const NETWORK_ERROR_HINTS = {
172
+ ECONNREFUSED: 'connection refused, the site may be down',
173
+ ECONNRESET: 'connection reset by the server',
174
+ ENOTFOUND: 'host not found, check the URL',
175
+ ETIMEDOUT: 'connection timed out',
176
+ };
164
177
  function extractLoopressError(body) {
165
178
  if (!body)
166
179
  return undefined;
@@ -2,6 +2,7 @@ import { type SnippetInsertMethod, type SnippetLocation, type SnippetType } from
2
2
  export type Snippet = {
3
3
  active: boolean;
4
4
  code: string;
5
+ description?: string;
5
6
  id?: number;
6
7
  insertMethod: SnippetInsertMethod;
7
8
  location: SnippetLocation;