@loopress/cli 0.20.1 → 0.21.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 (82) hide show
  1. package/README.md +128 -62
  2. package/dist/commands/acf/list.js +2 -1
  3. package/dist/commands/acf/push.js +1 -1
  4. package/dist/commands/api/list.d.ts +7 -4
  5. package/dist/commands/api/list.js +3 -10
  6. package/dist/commands/api/publish.js +1 -1
  7. package/dist/commands/api/pull.d.ts +8 -1
  8. package/dist/commands/api/pull.js +13 -4
  9. package/dist/commands/api/push.d.ts +7 -1
  10. package/dist/commands/api/push.js +48 -18
  11. package/dist/commands/composer/init.js +6 -6
  12. package/dist/commands/composer/pull.d.ts +7 -1
  13. package/dist/commands/composer/pull.js +4 -2
  14. package/dist/commands/composer/push.d.ts +8 -1
  15. package/dist/commands/composer/push.js +3 -1
  16. package/dist/commands/dev.d.ts +13 -0
  17. package/dist/commands/dev.js +97 -0
  18. package/dist/commands/doctor.js +4 -4
  19. package/dist/commands/init.js +7 -7
  20. package/dist/commands/login.js +3 -3
  21. package/dist/commands/page/list.d.ts +2 -4
  22. package/dist/commands/page/list.js +3 -10
  23. package/dist/commands/page/pull.d.ts +13 -1
  24. package/dist/commands/page/pull.js +5 -2
  25. package/dist/commands/page/push.d.ts +11 -1
  26. package/dist/commands/page/push.js +21 -14
  27. package/dist/commands/plugin/add.js +3 -3
  28. package/dist/commands/plugin/pull.d.ts +7 -1
  29. package/dist/commands/plugin/pull.js +3 -1
  30. package/dist/commands/plugin/push.d.ts +9 -1
  31. package/dist/commands/plugin/push.js +13 -9
  32. package/dist/commands/project/config.js +8 -8
  33. package/dist/commands/project/list.js +2 -2
  34. package/dist/commands/project/push.js +5 -5
  35. package/dist/commands/project/switch.js +2 -2
  36. package/dist/commands/snippet/list.d.ts +3 -4
  37. package/dist/commands/snippet/list.js +3 -10
  38. package/dist/commands/snippet/pull.d.ts +13 -1
  39. package/dist/commands/snippet/pull.js +5 -2
  40. package/dist/commands/snippet/push.d.ts +11 -1
  41. package/dist/commands/snippet/push.js +15 -5
  42. package/dist/commands/status.d.ts +13 -1
  43. package/dist/commands/status.js +25 -18
  44. package/dist/config/auth.manager.d.ts +2 -2
  45. package/dist/config/project-config.manager.d.ts +1 -1
  46. package/dist/config/project-config.manager.js +5 -6
  47. package/dist/lib/base.d.ts +2 -2
  48. package/dist/lib/base.js +3 -2
  49. package/dist/lib/bootstrap-full-install.d.ts +1 -1
  50. package/dist/lib/dev-watch.d.ts +16 -0
  51. package/dist/lib/dev-watch.js +71 -0
  52. package/dist/lib/find-orphaned-files.d.ts +3 -2
  53. package/dist/lib/find-orphaned-files.js +40 -4
  54. package/dist/lib/github-release.js +1 -1
  55. package/dist/lib/interactive.js +1 -1
  56. package/dist/lib/load-files.d.ts +1 -0
  57. package/dist/lib/load-files.js +40 -11
  58. package/dist/lib/load-snippets.d.ts +1 -1
  59. package/dist/lib/load-snippets.js +11 -9
  60. package/dist/lib/local-callback-server.js +8 -6
  61. package/dist/lib/plugin-detection.d.ts +1 -1
  62. package/dist/lib/push-command.js +2 -2
  63. package/dist/lib/rotate-app-password.d.ts +1 -1
  64. package/dist/lib/rotate-app-password.js +1 -1
  65. package/dist/lib/temp-admin.d.ts +3 -3
  66. package/dist/lib/temp-admin.js +2 -2
  67. package/dist/lib/wp-authorize-flow.js +5 -1
  68. package/dist/lib/wp-client.d.ts +2 -2
  69. package/dist/lib/wp-client.js +7 -0
  70. package/dist/types/plugin.d.ts +4 -4
  71. package/dist/types/snippet.d.ts +3 -3
  72. package/dist/utils/composer.d.ts +2 -2
  73. package/dist/utils/form-format.js +1 -1
  74. package/dist/utils/loopress-config.d.ts +1 -1
  75. package/dist/utils/page-format.js +2 -2
  76. package/dist/utils/plugins.d.ts +5 -5
  77. package/dist/utils/plugins.js +3 -3
  78. package/dist/utils/seo-format.d.ts +6 -6
  79. package/dist/utils/snippet-format.d.ts +2 -2
  80. package/dist/utils/snippet-format.js +10 -5
  81. package/oclif.manifest.json +153 -31
  82. package/package.json +9 -8
@@ -12,6 +12,7 @@ export default class Push extends PushCommand {
12
12
  path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
13
13
  };
14
14
  static description = 'Push snippets to WordPress. Local snippet files created or updated remotely are renamed on disk to the `<id>-<slug>` convention.';
15
+ static enableJsonFlag = true;
15
16
  static examples = ['$ lps snippet push', '$ lps snippet push --path ./snippets'];
16
17
  static flags = {
17
18
  ...PushCommand.dryRunFlag,
@@ -25,17 +26,22 @@ export default class Push extends PushCommand {
25
26
  this.log(`Snippets path: ${path}`);
26
27
  const snippets = await this.loadSnippets(path);
27
28
  this.log(`Found ${snippets.length} snippet${snippets.length === 1 ? '' : 's'} to push`);
29
+ const pushed = [];
28
30
  await new Listr(snippets.map((snippet) => ({
29
- task: async (_ctx, task) => this.pushSnippet(snippet, task),
31
+ task: async (_ctx, task) => {
32
+ const id = await this.pushSnippet(snippet, task);
33
+ pushed.push({ id, name: snippet.name });
34
+ },
30
35
  title: `Push ${snippet.name}`,
31
- })), { concurrent: false, exitOnError: false }).run();
36
+ })), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
32
37
  if (this.failedCount > 0) {
33
38
  this.error(`${this.failedCount} snippet${this.failedCount === 1 ? '' : 's'} failed to push.`);
34
39
  }
35
40
  if (this.dryRun)
36
- return;
41
+ return { pushed, status: 'dry-run' };
37
42
  await this.recordSuccess();
38
43
  this.log('All snippets pushed.');
44
+ return { pushed, status: 'success' };
39
45
  }
40
46
  // Renames the local file pair to the `<id>-<slug>` convention used by `snippet pull` whenever
41
47
  // it doesn't already match (e.g. a hand-created `demo.php` with no id, or a stale slug after a rename).
@@ -73,7 +79,7 @@ export default class Push extends PushCommand {
73
79
  }
74
80
  async loadSnippets(path) {
75
81
  try {
76
- return await loadSnippetsFromDisk(path, (message) => this.warn(message));
82
+ return await loadSnippetsFromDisk(path, (message) => { this.warn(message); });
77
83
  }
78
84
  catch (error) {
79
85
  this.error(error.message);
@@ -83,10 +89,11 @@ export default class Push extends PushCommand {
83
89
  if (this.dryRun) {
84
90
  if (task)
85
91
  task.output = `[dry-run] Would push: ${snippet.name}`;
86
- return;
92
+ return snippet.id;
87
93
  }
88
94
  try {
89
95
  const payload = this.toPayload(snippet);
96
+ let { id } = snippet;
90
97
  if (snippet.id) {
91
98
  try {
92
99
  await this.wp.put(`${SNIPPETS_ENDPOINT}/${snippet.id}`, payload);
@@ -99,16 +106,19 @@ export default class Push extends PushCommand {
99
106
  throw error;
100
107
  const response = await this.wp.post(SNIPPETS_ENDPOINT, payload);
101
108
  const created = normalizeSnippet(response);
109
+ id = created.id;
102
110
  await this.ensureCanonicalFilename(snippet, created.id, created.name);
103
111
  }
104
112
  }
105
113
  else {
106
114
  const response = await this.wp.post(SNIPPETS_ENDPOINT, payload);
107
115
  const created = normalizeSnippet(response);
116
+ id = created.id;
108
117
  await this.ensureCanonicalFilename(snippet, created.id, created.name);
109
118
  }
110
119
  if (task)
111
120
  task.output = `Pushed: ${snippet.name}`;
121
+ return id;
112
122
  }
113
123
  catch (error) {
114
124
  this.reportTaskFailure(`Failed to push ${snippet.name}: ${error.message}`, error, task);
@@ -1,12 +1,24 @@
1
1
  import { Command } from '@oclif/core';
2
+ type StatusInfo = {
3
+ environments?: string[];
4
+ note?: string;
5
+ project?: string;
6
+ url?: string;
7
+ };
8
+ type StatusResult = StatusInfo & {
9
+ configDir: string;
10
+ dataDir: string;
11
+ };
2
12
  export default class Status extends Command {
3
13
  static description: string;
14
+ static enableJsonFlag: boolean;
4
15
  static examples: string[];
5
16
  static flags: {
6
17
  env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
18
  };
8
- run(): Promise<void>;
19
+ run(): Promise<StatusResult>;
9
20
  private reportActiveProject;
10
21
  private reportEnvOverride;
11
22
  private reportPinnedProject;
12
23
  }
24
+ export {};
@@ -4,6 +4,7 @@ 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 enableJsonFlag = true;
7
8
  static examples = ['$ lps status', '$ lps status --env staging'];
8
9
  static flags = {
9
10
  env: Flags.string({ description: 'Show what would be targeted with this environment, as other commands do with --env' }),
@@ -11,40 +12,41 @@ export default class Status extends Command {
11
12
  async run() {
12
13
  const { flags } = await this.parse(Status);
13
14
  const localConfig = await readLocalConfig();
15
+ let info;
14
16
  if (flags.env) {
15
- this.reportEnvOverride(localConfig.projectId, flags.env);
17
+ info = this.reportEnvOverride(localConfig.projectId, flags.env);
16
18
  }
17
19
  else if (localConfig.projectId) {
18
- this.reportPinnedProject(localConfig.projectId);
20
+ info = this.reportPinnedProject(localConfig.projectId);
19
21
  }
20
22
  else {
21
- this.reportActiveProject();
23
+ info = this.reportActiveProject();
22
24
  }
23
25
  this.log('');
24
26
  this.log(`Config dir: ${this.config.configDir}`);
25
27
  this.log(`Data dir: ${this.config.dataDir}`);
28
+ return { ...info, configDir: this.config.configDir, dataDir: this.config.dataDir };
26
29
  }
27
30
  reportActiveProject() {
28
31
  const env = configManager.getCurrentEnv();
29
- if (!env) {
30
- this.log('No project configured. Run `lps project config` first.');
31
- return;
32
- }
33
- const project = configManager.getCurrentProject();
34
- if (!project) {
35
- this.log('No project configured. Run `lps project config` first.');
36
- return;
32
+ const project = env ? configManager.getCurrentProject() : undefined;
33
+ if (!env || !project) {
34
+ const note = 'No project configured. Run `lps project config` first.';
35
+ this.log(note);
36
+ return { note };
37
37
  }
38
38
  this.log(`Project: ${project.name} (${env.name})`);
39
39
  this.log(`URL: ${env.url}`);
40
+ return { project: `${project.name} (${env.name})`, url: env.url };
40
41
  }
41
42
  // Mirrors base.ts:resolveEnvironment with --env: the targeted project comes from
42
43
  // loopress.json when pinned, from the globally active project otherwise.
43
44
  reportEnvOverride(pinnedProjectId, envName) {
44
45
  const project = pinnedProjectId ? configManager.getProject(pinnedProjectId) : configManager.getCurrentProject();
45
46
  if (!project) {
46
- this.log('No project configured. Run `lps project config` first.');
47
- return;
47
+ const note = 'No project configured. Run `lps project config` first.';
48
+ this.log(note);
49
+ return { note };
48
50
  }
49
51
  const env = project.environments[envName];
50
52
  if (!env) {
@@ -52,29 +54,33 @@ export default class Status extends Command {
52
54
  }
53
55
  this.log(`Project: ${project.name} (${env.name}, via --env)`);
54
56
  this.log(`URL: ${env.url}`);
57
+ return { project: `${project.name} (${env.name}, via --env)`, url: env.url };
55
58
  }
56
59
  reportPinnedProject(projectId) {
57
60
  const project = configManager.getProject(projectId);
58
61
  if (!project) {
62
+ const note = `loopress.json pins project "${projectId}", but it no longer exists. Run \`lps project config\` to configure it.`;
59
63
  this.log(`loopress.json pins project "${projectId}", but it no longer exists.`);
60
64
  this.log('Run `lps project config` to configure it.');
61
- return;
65
+ return { note };
62
66
  }
63
67
  const envNames = Object.keys(project.environments);
64
68
  if (envNames.length === 0) {
69
+ const note = 'No environments configured for this project. Run `lps project config` to add one.';
65
70
  this.log(`Project: ${project.name}`);
66
- this.log('No environments configured for this project. Run `lps project config` to add one.');
67
- return;
71
+ this.log(note);
72
+ return { note, project: project.name };
68
73
  }
69
74
  if (envNames.length === 1) {
70
75
  const env = project.environments[envNames[0]];
71
76
  this.log(`Project: ${project.name} (${env.name})`);
72
77
  this.log(`URL: ${env.url}`);
73
- return;
78
+ return { project: `${project.name} (${env.name})`, url: env.url };
74
79
  }
75
80
  const current = configManager.getCurrentProject();
76
81
  const currentEnv = current?.id === projectId ? configManager.getCurrentEnv() : null;
77
82
  if (!currentEnv) {
83
+ const note = `"${project.name}" has multiple environments and isn't the globally active project. Run \`lps project switch\` to pick one.`;
78
84
  this.log(`Project: ${project.name} ${c('yellow', '(ambiguous)')}`);
79
85
  this.log(`Environments: ${envNames.join(', ')}`);
80
86
  this.log('');
@@ -83,9 +89,10 @@ export default class Status extends Command {
83
89
  if (current) {
84
90
  this.log(`(Globally active project right now: "${current.name}")`);
85
91
  }
86
- return;
92
+ return { environments: envNames, note, project: project.name };
87
93
  }
88
94
  this.log(`Project: ${project.name} (${currentEnv.name})`);
89
95
  this.log(`URL: ${currentEnv.url}`);
96
+ return { project: `${project.name} (${currentEnv.name})`, url: currentEnv.url };
90
97
  }
91
98
  }
@@ -1,8 +1,8 @@
1
- export interface ConsoleAuth {
1
+ export type ConsoleAuth = {
2
2
  email?: string;
3
3
  savedAt: string;
4
4
  token: string;
5
- }
5
+ };
6
6
  export declare class AuthManager {
7
7
  private dataDir?;
8
8
  constructor(dataDir?: string | undefined);
@@ -1,4 +1,4 @@
1
- import { EnvironmentConfig, LoopressConfig, ProjectConfig } from '../types/config.js';
1
+ import { type EnvironmentConfig, type LoopressConfig, type ProjectConfig } from '../types/config.js';
2
2
  export declare class ProjectConfigManager {
3
3
  private configDir?;
4
4
  constructor(configDir?: string | undefined);
@@ -12,7 +12,7 @@ export class ProjectConfigManager {
12
12
  const base = toSlug(name, 'project');
13
13
  let id = base;
14
14
  let suffix = 2;
15
- while (config.projects[id]) {
15
+ while (Object.hasOwn(config.projects, id)) {
16
16
  id = `${base}-${suffix}`;
17
17
  suffix++;
18
18
  }
@@ -100,7 +100,7 @@ export class ProjectConfigManager {
100
100
  const project = config.projects[projectId];
101
101
  if (!project)
102
102
  return;
103
- delete project.environments[envName];
103
+ Reflect.deleteProperty(project.environments, envName);
104
104
  if (config.currentProject?.id === projectId && config.currentProject.env === envName) {
105
105
  const remaining = Object.keys(project.environments);
106
106
  config.currentProject = remaining.length > 0 ? { env: remaining[0], id: projectId } : null;
@@ -109,7 +109,7 @@ export class ProjectConfigManager {
109
109
  }
110
110
  removeProject(id) {
111
111
  const config = this.readConfig();
112
- delete config.projects[id];
112
+ Reflect.deleteProperty(config.projects, id);
113
113
  if (config.currentProject?.id === id) {
114
114
  const [nextId] = Object.keys(config.projects);
115
115
  const nextProject = nextId ? config.projects[nextId] : undefined;
@@ -126,7 +126,7 @@ export class ProjectConfigManager {
126
126
  }
127
127
  setCurrent(projectId, envName) {
128
128
  const config = this.readConfig();
129
- if (!config.projects[projectId])
129
+ if (!Object.hasOwn(config.projects, projectId))
130
130
  return;
131
131
  config.currentProject = { env: envName, id: projectId };
132
132
  this.writeConfig(config);
@@ -137,8 +137,7 @@ export class ProjectConfigManager {
137
137
  if (!project)
138
138
  return;
139
139
  project.environments[envName] = env;
140
- if (!config.currentProject)
141
- config.currentProject = { env: envName, id: projectId };
140
+ config.currentProject ??= { env: envName, id: projectId };
142
141
  this.writeConfig(config);
143
142
  }
144
143
  setEnvironmentApiId(projectId, envName, apiEnvironmentId) {
@@ -1,6 +1,6 @@
1
1
  import { Command } from '@oclif/core';
2
- import { EnvironmentConfig } from '../types/config.js';
3
- import { LoopressLocalConfig } from '../utils/loopress-config.js';
2
+ import { type EnvironmentConfig } from '../types/config.js';
3
+ import { type LoopressLocalConfig } from '../utils/loopress-config.js';
4
4
  import { WpClient } from './wp-client.js';
5
5
  export declare abstract class LoopressCommand extends Command {
6
6
  static baseFlags: {
package/dist/lib/base.js CHANGED
@@ -45,6 +45,7 @@ export class LoopressCommand extends Command {
45
45
  const { flags } = (await this.parse({
46
46
  args: this.ctor.args,
47
47
  baseFlags: this.ctor.baseFlags,
48
+ enableJsonFlag: this.ctor.enableJsonFlag,
48
49
  flags: this.ctor.flags,
49
50
  strict: this.ctor.strict,
50
51
  }));
@@ -85,8 +86,8 @@ export class LoopressCommand extends Command {
85
86
  return;
86
87
  const description = `${orphans.length} local file${orphans.length === 1 ? '' : 's'} ${reason}: ${orphans.join(', ')}`;
87
88
  if (!this.yes && isInteractive()) {
88
- const proceed = await confirm({ default: true, message: `Remove ${description}?` });
89
- if (!proceed) {
89
+ const isProceed = await confirm({ default: true, message: `Remove ${description}?` });
90
+ if (!isProceed) {
90
91
  this.log(`Kept ${description}`);
91
92
  return;
92
93
  }
@@ -1,4 +1,4 @@
1
- import { WpClient } from './wp-client.js';
1
+ import { type WpClient } from './wp-client.js';
2
2
  /**
3
3
  * Installs and activates Loopress Full on a site that doesn't have it yet: downloads the
4
4
  * latest release zip, creates a temporary admin (the only way to get `install_plugins` from
@@ -0,0 +1,16 @@
1
+ import { type LoopressLocalConfig } from '../utils/loopress-config.js';
2
+ export type ResourceType = 'api' | 'pages' | 'plugins' | 'snippets';
3
+ export declare const RESOURCE_TYPES: ResourceType[];
4
+ export type WatchTarget = {
5
+ commandId: string;
6
+ path: string;
7
+ type: ResourceType;
8
+ };
9
+ export declare function resolveResourceTypes(only?: ResourceType[], skip?: ResourceType[]): ResourceType[];
10
+ export declare function buildWatchTargets(types: ResourceType[], localConfig: LoopressLocalConfig, cwd: string): WatchTarget[];
11
+ export declare function resourceTypeForPath(filePath: string, targets: WatchTarget[]): ResourceType | undefined;
12
+ export type DebouncedBatcher = {
13
+ cancel: () => void;
14
+ queue: (path: string) => void;
15
+ };
16
+ export declare function createDebouncedBatcher(flush: (paths: string[]) => Promise<void>, debounceMs: number): DebouncedBatcher;
@@ -0,0 +1,71 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join, sep } from 'node:path';
3
+ export const RESOURCE_TYPES = ['snippets', 'pages', 'api', 'plugins'];
4
+ // `only` narrows the default set, `skip` then removes from whatever that set is, same
5
+ // precedence a combined `--only=a,b --skip=b` reads as: watch a, b, then drop b.
6
+ export function resolveResourceTypes(only, skip = []) {
7
+ const base = only && only.length > 0 ? only : RESOURCE_TYPES;
8
+ return base.filter((type) => !skip.includes(type));
9
+ }
10
+ // One target per requested type that actually has something to watch: a missing directory (no
11
+ // `api/` folder yet) or an empty plugin manifest is left out silently, matching `push`'s own
12
+ // directories which are all optional. `plugins` watches the whole `loopress.json` lockfile since
13
+ // the plugin manifest is a field in it, not its own directory.
14
+ export function buildWatchTargets(types, localConfig, cwd) {
15
+ const rootDir = join(cwd, localConfig.rootDir ?? '.');
16
+ const candidates = {
17
+ api: { commandId: 'api:push', path: join(rootDir, localConfig.apiDir ?? 'api'), type: 'api' },
18
+ pages: { commandId: 'page:push', path: join(rootDir, localConfig.pageDir ?? 'pages'), type: 'pages' },
19
+ plugins: { commandId: 'plugin:push', path: join(cwd, 'loopress.json'), type: 'plugins' },
20
+ snippets: { commandId: 'snippet:push', path: join(rootDir, localConfig.snippetsDir ?? 'snippets'), type: 'snippets' },
21
+ };
22
+ return types
23
+ .map((type) => candidates[type])
24
+ .filter((target) => {
25
+ if (target.type === 'plugins')
26
+ return Object.keys(localConfig.plugins ?? {}).length > 0;
27
+ return existsSync(target.path);
28
+ });
29
+ }
30
+ // A changed file belongs to whichever target's path is a prefix of it (directory targets) or
31
+ // equals it (the `loopress.json` file target for `plugins`).
32
+ export function resourceTypeForPath(filePath, targets) {
33
+ return targets.find((target) => filePath === target.path || filePath.startsWith(target.path + sep))?.type;
34
+ }
35
+ // Groups file events that land within `debounceMs` of each other into one `flush` call. If a
36
+ // flush is still running when the timer fires again (a slow push outlasting the next debounce
37
+ // window), the new paths just accumulate in `pending` instead of starting a second, overlapping
38
+ // flush; once the in-flight one finishes it immediately flushes whatever queued up meanwhile.
39
+ export function createDebouncedBatcher(flush, debounceMs) {
40
+ const pending = new Set();
41
+ let timer;
42
+ let isBusy = false;
43
+ const schedule = () => {
44
+ clearTimeout(timer);
45
+ timer = setTimeout(() => {
46
+ runFlush().catch(() => { });
47
+ }, debounceMs);
48
+ };
49
+ const runFlush = async () => {
50
+ if (isBusy || pending.size === 0)
51
+ return;
52
+ isBusy = true;
53
+ const paths = [...pending];
54
+ pending.clear();
55
+ try {
56
+ await flush(paths);
57
+ }
58
+ finally {
59
+ isBusy = false;
60
+ if (pending.size > 0)
61
+ schedule();
62
+ }
63
+ };
64
+ return {
65
+ cancel() { clearTimeout(timer); },
66
+ queue(path) {
67
+ pending.add(path);
68
+ schedule();
69
+ },
70
+ };
71
+ }
@@ -1,7 +1,8 @@
1
- export interface OrphanMatcher {
1
+ export type OrphanMatcher = {
2
2
  extensions: string[];
3
3
  key(base: string): null | string;
4
- }
4
+ recursive?: boolean;
5
+ };
5
6
  export declare function findOrphanedFiles(dir: string, keep: Set<string>, matcher: OrphanMatcher): Promise<string[]>;
6
7
  export declare function numericPrefixKey(base: string): null | string;
7
8
  export declare function basenameKey(base: string): string;
@@ -1,15 +1,51 @@
1
1
  import { readdir } from 'node:fs/promises';
2
- import { extname } from 'node:path';
3
- export async function findOrphanedFiles(dir, keep, matcher) {
4
- let files;
2
+ import { extname, join } from 'node:path';
3
+ // Same shape as load-files.ts's own walker, kept separate rather than shared: this one
4
+ // filters against a list of extensions (page/snippet pull need more than one), that one
5
+ // against a single extension, and reconciling the two would cost more than the ~15 duplicated
6
+ // lines it would save.
7
+ async function walk(dir, extensions) {
8
+ let entries;
5
9
  try {
6
- files = await readdir(dir);
10
+ entries = await readdir(dir, { withFileTypes: true });
7
11
  }
8
12
  catch (error) {
9
13
  if (error.code === 'ENOENT')
10
14
  return [];
11
15
  throw error;
12
16
  }
17
+ const relativePaths = [];
18
+ for (const entry of entries) {
19
+ if (entry.isDirectory()) {
20
+ // join() builds the real OS path to actually walk, but the returned identity is always
21
+ // '/'-joined regardless of platform: it's matched against `keep`, filenames the server
22
+ // sends with '/', never the OS separator. join() here (backslash on Windows) would make
23
+ // every nested file's identity never match, findOrphanedFiles() would then report a
24
+ // live, kept file as orphaned and pull.ts would delete it.
25
+ const nested = await walk(join(dir, entry.name), extensions);
26
+ relativePaths.push(...nested.map((relativePath) => `${entry.name}/${relativePath}`));
27
+ }
28
+ else if (extensions.includes(extname(entry.name))) {
29
+ relativePaths.push(entry.name);
30
+ }
31
+ }
32
+ return relativePaths;
33
+ }
34
+ export async function findOrphanedFiles(dir, keep, matcher) {
35
+ let files;
36
+ if (matcher.recursive) {
37
+ files = await walk(dir, matcher.extensions);
38
+ }
39
+ else {
40
+ try {
41
+ files = await readdir(dir);
42
+ }
43
+ catch (error) {
44
+ if (error.code === 'ENOENT')
45
+ return [];
46
+ throw error;
47
+ }
48
+ }
13
49
  return files.filter((file) => {
14
50
  const ext = extname(file);
15
51
  if (!matcher.extensions.includes(ext))
@@ -22,7 +22,7 @@ export async function downloadLatestFullZip() {
22
22
  headers: { accept: 'application/vnd.github+json', 'user-agent': 'loopress-cli' },
23
23
  searchParams: { 'per_page': 30 },
24
24
  }).json();
25
- const pluginRelease = releases.find((release) => RELEASE_TAG_PATTERN.test(release.tag_name));
25
+ const pluginRelease = releases.find((release) => release.tag_name.startsWith("wordpress-plugin@"));
26
26
  if (!pluginRelease) {
27
27
  throw new Error(`No "wordpress-plugin@*" release found in ${REPO}.`);
28
28
  }
@@ -3,5 +3,5 @@
3
3
  // Every command that prompts routes its decision through this so the CLI never hangs on a
4
4
  // question nobody can answer.
5
5
  export function isInteractive() {
6
- return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY) && !process.env.CI;
6
+ return process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
7
7
  }
@@ -2,4 +2,5 @@ export declare function loadFiles<T>(dir: string, options: {
2
2
  extension: string;
3
3
  onSkip(message: string): void;
4
4
  parse(raw: string, filePath: string): T;
5
+ recursive?: boolean;
5
6
  }): Promise<T[]>;
@@ -1,25 +1,54 @@
1
1
  import { readdir, readFile } from 'node:fs/promises';
2
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) {
3
+ // Recursion for the api resource type only (path-param route files can live in
4
+ // subdirectories, e.g. api/invoice-pdf/[order_id].php); every other resource type stays
5
+ // flat. withFileTypes avoids a second stat() per entry to tell files from directories.
6
+ async function walk(dir, extension) {
9
7
  let entries;
10
8
  try {
11
- entries = await readdir(dir);
9
+ entries = await readdir(dir, { withFileTypes: true });
12
10
  }
13
11
  catch (error) {
14
12
  if (error.code === 'ENOENT')
15
13
  return [];
16
14
  throw error;
17
15
  }
18
- const items = [];
16
+ const filePaths = [];
19
17
  for (const entry of entries) {
20
- if (extname(entry) !== options.extension)
21
- continue;
22
- const filePath = join(dir, entry);
18
+ const entryPath = join(dir, entry.name);
19
+ if (entry.isDirectory()) {
20
+ filePaths.push(...(await walk(entryPath, extension)));
21
+ }
22
+ else if (extname(entry.name) === extension) {
23
+ filePaths.push(entryPath);
24
+ }
25
+ }
26
+ return filePaths;
27
+ }
28
+ // Loads every `<extension>` file of a directory through `parse`. One file is read in
29
+ // isolation: a corrupted or hand-broken file (unreadable, bad JSON, failed validation) must
30
+ // only skip that file, never abort loading the rest of the directory, so `parse` throwing is
31
+ // reported through `onSkip` and the loop moves on. ENOENT on the directory itself means
32
+ // "nothing to load", not an error.
33
+ export async function loadFiles(dir, options) {
34
+ let filePaths;
35
+ if (options.recursive) {
36
+ filePaths = await walk(dir, options.extension);
37
+ }
38
+ else {
39
+ let entries;
40
+ try {
41
+ entries = await readdir(dir);
42
+ }
43
+ catch (error) {
44
+ if (error.code === 'ENOENT')
45
+ return [];
46
+ throw error;
47
+ }
48
+ filePaths = entries.filter((entry) => extname(entry) === options.extension).map((entry) => join(dir, entry));
49
+ }
50
+ const items = [];
51
+ for (const filePath of filePaths) {
23
52
  try {
24
53
  items.push(options.parse(await readFile(filePath, 'utf8'), filePath));
25
54
  }
@@ -1,2 +1,2 @@
1
- import { Snippet } from '../types/snippet.js';
1
+ import { type Snippet } from '../types/snippet.js';
2
2
  export declare function loadSnippets(path: string, onSkip?: (message: string) => void): Promise<Snippet[]>;
@@ -19,11 +19,11 @@ export async function loadSnippets(path, onSkip) {
19
19
  files = await readdir(path);
20
20
  }
21
21
  catch (error) {
22
- throw new Error(`Error loading snippets: ${error.message}`);
22
+ throw new Error(`Error loading snippets: ${error.message}`, { cause: error });
23
23
  }
24
24
  for (const file of files) {
25
25
  const ext = extname(file);
26
- if (!(ext in TYPE_BY_EXTENSION))
26
+ if (!Object.hasOwn(TYPE_BY_EXTENSION, ext))
27
27
  continue;
28
28
  const filePath = join(path, file);
29
29
  const metaPath = join(path, `${basename(file, ext)}.json`);
@@ -40,7 +40,7 @@ export async function loadSnippets(path, onSkip) {
40
40
  let id;
41
41
  let name;
42
42
  let type;
43
- let active = false;
43
+ let isActive = false;
44
44
  let tags = [];
45
45
  let location = null;
46
46
  let insertMethod = null;
@@ -49,14 +49,16 @@ export async function loadSnippets(path, onSkip) {
49
49
  try {
50
50
  const metaContent = await readFile(metaPath, 'utf8');
51
51
  const meta = JSON.parse(metaContent);
52
- id = meta.id === undefined ? undefined : Number(meta.id);
53
- name = meta.name ? String(meta.name) : undefined;
52
+ id = meta.id;
53
+ // `||`, not `??`: an empty-string name in the sidecar JSON counts as absent too.
54
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
55
+ name = meta.name || undefined;
54
56
  type = parseType(meta.type) ?? undefined;
55
- active = Boolean(meta.active);
57
+ isActive = Boolean(meta.active);
56
58
  tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
57
59
  location = parseLocation(meta.location);
58
60
  insertMethod = parseInsertMethod(meta.insertMethod);
59
- priority = meta.priority === undefined ? 10 : Number(meta.priority);
61
+ priority = meta.priority ?? 10;
60
62
  shortcodeAttributes = Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [];
61
63
  }
62
64
  catch (error) {
@@ -65,9 +67,9 @@ export async function loadSnippets(path, onSkip) {
65
67
  continue;
66
68
  }
67
69
  }
68
- const resolvedType = type ?? (ext in TYPE_BY_EXTENSION ? TYPE_BY_EXTENSION[ext] : 'php');
70
+ const resolvedType = type ?? (Object.hasOwn(TYPE_BY_EXTENSION, ext) ? TYPE_BY_EXTENSION[ext] : 'php');
69
71
  snippets.push({
70
- active,
72
+ active: isActive,
71
73
  code: content,
72
74
  id,
73
75
  insertMethod: insertMethod ?? 'auto',