@loopress/cli 0.18.0 → 0.19.0

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