@loopress/cli 0.19.0 → 0.20.1

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 (41) hide show
  1. package/README.md +158 -37
  2. package/dist/commands/api/publish.d.ts +9 -0
  3. package/dist/commands/api/publish.js +60 -0
  4. package/dist/commands/api/push.d.ts +1 -0
  5. package/dist/commands/api/push.js +19 -0
  6. package/dist/commands/composer/pull.js +38 -3
  7. package/dist/commands/init.js +1 -0
  8. package/dist/commands/page/list.d.ts +9 -0
  9. package/dist/commands/page/list.js +26 -0
  10. package/dist/commands/page/pull.d.ts +13 -0
  11. package/dist/commands/page/pull.js +65 -0
  12. package/dist/commands/page/push.d.ts +16 -0
  13. package/dist/commands/page/push.js +150 -0
  14. package/dist/commands/project/config.d.ts +1 -0
  15. package/dist/commands/project/config.js +28 -0
  16. package/dist/commands/project/rotate.d.ts +7 -0
  17. package/dist/commands/project/rotate.js +20 -0
  18. package/dist/lib/base.d.ts +3 -0
  19. package/dist/lib/base.js +42 -10
  20. package/dist/lib/bootstrap-full-install.d.ts +10 -0
  21. package/dist/lib/bootstrap-full-install.js +66 -0
  22. package/dist/lib/browser-launch.d.ts +8 -0
  23. package/dist/lib/browser-launch.js +20 -0
  24. package/dist/lib/github-release.d.ts +11 -0
  25. package/dist/lib/github-release.js +37 -0
  26. package/dist/lib/plugin-detection.d.ts +8 -0
  27. package/dist/lib/plugin-detection.js +11 -0
  28. package/dist/lib/rotate-app-password.d.ts +6 -0
  29. package/dist/lib/rotate-app-password.js +33 -0
  30. package/dist/lib/temp-admin.d.ts +19 -0
  31. package/dist/lib/temp-admin.js +46 -0
  32. package/dist/lib/wp-client.d.ts +1 -0
  33. package/dist/lib/wp-client.js +7 -1
  34. package/dist/lib/wp-site-diagnostic.d.ts +9 -2
  35. package/dist/lib/wp-site-diagnostic.js +13 -23
  36. package/dist/types/project-config.generated.d.ts +4 -0
  37. package/dist/utils/page-format.d.ts +7 -0
  38. package/dist/utils/page-format.js +78 -0
  39. package/oclif.manifest.json +252 -1
  40. package/package.json +8 -7
  41. package/schemas/project-config.schema.json +5 -0
@@ -0,0 +1,26 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { LoopressCommand } from '../../lib/base.js';
3
+ import { getPageId, getPageTitle, PAGE_ENDPOINT, PAGE_LIST_QUERY } from '../../utils/page-format.js';
4
+ export default class List extends LoopressCommand {
5
+ static description = 'List pages from WordPress';
6
+ static examples = ['$ lps page list'];
7
+ static flags = {
8
+ json: Flags.boolean({ char: 'j', description: 'Output in JSON format' }),
9
+ };
10
+ async run() {
11
+ const { flags } = await this.parse(List);
12
+ const pages = await this.wp.get(`${PAGE_ENDPOINT}?${PAGE_LIST_QUERY}`);
13
+ if (flags.json) {
14
+ this.log(JSON.stringify(pages, null, 2));
15
+ return;
16
+ }
17
+ this.log(`Pages (${pages.length}):`);
18
+ if (pages.length === 0) {
19
+ this.log(' (none)');
20
+ return;
21
+ }
22
+ for (const page of pages) {
23
+ this.log(` ${getPageId(page) ?? '(no id)'}. ${getPageTitle(page)}`);
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,13 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ export default class Pull extends LoopressCommand {
3
+ static args: {
4
+ path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ };
12
+ run(): Promise<void>;
13
+ }
@@ -0,0 +1,65 @@
1
+ import { Args } from '@oclif/core';
2
+ import { Listr } from 'listr2';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { LoopressCommand } from '../../lib/base.js';
6
+ import { findOrphanedFiles, numericPrefixKey } from '../../lib/find-orphaned-files.js';
7
+ import { getPageContent, getPageId, getPageTitle, PAGE_ENDPOINT, PAGE_LIST_QUERY, pageFileBase, pickPageMeta } from '../../utils/page-format.js';
8
+ export default class Pull extends LoopressCommand {
9
+ static args = {
10
+ path: Args.string({ description: 'Path to pages directory (overrides project config)' }),
11
+ };
12
+ static description = 'Pull pages from WordPress';
13
+ static examples = ['$ lps page pull'];
14
+ static flags = {
15
+ ...LoopressCommand.dryRunFlag,
16
+ ...LoopressCommand.yesFlag,
17
+ };
18
+ async run() {
19
+ const { args } = await this.parse(Pull);
20
+ const { url } = this.siteConfig;
21
+ const path = this.resolvePagePath(args.path);
22
+ this.log(`Pulling pages from ${url}`);
23
+ this.log(`Pages path: ${path}`);
24
+ // context=edit returns title/content/excerpt as raw editable source instead of rendered
25
+ // HTML, needed for the file to be a faithful, re-pushable copy (requires edit_pages).
26
+ const remoteList = await this.wp.get(`${PAGE_ENDPOINT}?${PAGE_LIST_QUERY}&context=edit`);
27
+ const withId = remoteList.filter((page) => getPageId(page) !== null);
28
+ const skipped = remoteList.length - withId.length;
29
+ // Every page writes an `.html` (content) and `.json` (everything else) pair; both
30
+ // extensions are candidates for orphan cleanup, same principle as `snippet pull`.
31
+ const orphans = await findOrphanedFiles(path, new Set(withId.map((page) => String(getPageId(page)))), {
32
+ extensions: ['.json', '.html'],
33
+ key: numericPrefixKey,
34
+ });
35
+ if (this.dryRun) {
36
+ this.log(`[dry-run] Would pull ${withId.length} page${withId.length === 1 ? '' : 's'} to ${path}`);
37
+ if (orphans.length > 0) {
38
+ this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${path} no longer present on WordPress: ${orphans.join(', ')}`);
39
+ }
40
+ return;
41
+ }
42
+ if (withId.length > 0)
43
+ await mkdir(path, { recursive: true });
44
+ await new Listr(withId.map((page) => {
45
+ const id = getPageId(page);
46
+ const title = getPageTitle(page);
47
+ return {
48
+ async task(_ctx, task) {
49
+ const base = pageFileBase(id, title);
50
+ // `content` lives entirely in the `.html` file; the `.json` sidecar keeps only the
51
+ // fields WordPress actually accepts back on write, see pickPageMeta.
52
+ await writeFile(join(path, `${base}.html`), getPageContent(page));
53
+ await writeFile(join(path, `${base}.json`), JSON.stringify(pickPageMeta(page), null, 2) + '\n');
54
+ task.output = `Pulled: ${title}`;
55
+ },
56
+ title: `Pull ${title}`,
57
+ };
58
+ })).run();
59
+ await this.removeOrphanedFiles(path, orphans, `in ${path} no longer present on WordPress`);
60
+ this.log(`Pulled ${withId.length} page${withId.length === 1 ? '' : 's'} to ${path}`);
61
+ if (skipped > 0) {
62
+ this.warn(`${skipped} page${skipped === 1 ? '' : 's'} skipped because they have no id`);
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,16 @@
1
+ import { PushCommand } from '../../lib/push-command.js';
2
+ export default class Push extends PushCommand {
3
+ static args: {
4
+ path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ };
12
+ run(): Promise<void>;
13
+ private ensureCanonicalFilename;
14
+ private loadFiles;
15
+ private pushPage;
16
+ }
@@ -0,0 +1,150 @@
1
+ import { Args } from '@oclif/core';
2
+ import { Listr } from 'listr2';
3
+ import { readdir, readFile, rename, writeFile } from 'node:fs/promises';
4
+ import { basename, dirname, extname, join } from 'node:path';
5
+ import { PushCommand } from '../../lib/push-command.js';
6
+ import { isNotFoundError } from '../../lib/wp-client.js';
7
+ import { getPageId, getPageTitle, PAGE_ENDPOINT, pageFileBase } from '../../utils/page-format.js';
8
+ export default class Push extends PushCommand {
9
+ static args = {
10
+ path: Args.string({ description: 'Path to pages directory (overrides project config)' }),
11
+ };
12
+ static description = 'Push pages to WordPress. Local files created or updated remotely are renamed on disk to the `<id>-<slug>` convention.';
13
+ static examples = ['$ lps page push'];
14
+ static flags = {
15
+ ...PushCommand.dryRunFlag,
16
+ ...PushCommand.yesFlag,
17
+ };
18
+ async run() {
19
+ const { args } = await this.parse(Push);
20
+ const { url } = this.siteConfig;
21
+ const path = this.resolvePagePath(args.path);
22
+ this.log(`Pushing pages to ${url}`);
23
+ this.log(`Pages path: ${path}`);
24
+ const pages = await this.loadFiles(path);
25
+ this.log(`Found ${pages.length} page${pages.length === 1 ? '' : 's'} to push`);
26
+ await new Listr(pages.map((page) => ({
27
+ task: async (_ctx, task) => this.pushPage(page, task),
28
+ title: `Push ${getPageTitle(page.meta)}`,
29
+ })), { concurrent: false, exitOnError: false }).run();
30
+ if (this.failedCount > 0) {
31
+ this.error(`${this.failedCount} page${this.failedCount === 1 ? '' : 's'} failed to push.`);
32
+ }
33
+ if (this.dryRun)
34
+ return;
35
+ await this.recordSuccess();
36
+ this.log('All pages pushed.');
37
+ }
38
+ // Renames the local file pair to the `<id>-<slug>` convention used by `page pull` whenever it
39
+ // doesn't already match, same principle as ensureCanonicalFilename in commands/snippet/push.ts:
40
+ // the id is persisted into the sidecar before any rename, so a crash mid-rename still leaves a
41
+ // valid, matchable pair on disk instead of an orphaned one that would be recreated as a duplicate.
42
+ async ensureCanonicalFilename(page, id, title) {
43
+ const dir = dirname(page.contentPath);
44
+ const canonicalBase = pageFileBase(id, title);
45
+ const currentBase = basename(page.contentPath, '.html');
46
+ const meta = { ...page.meta, id };
47
+ await writeFile(page.metaPath, JSON.stringify(meta, null, 2) + '\n');
48
+ if (currentBase === canonicalBase)
49
+ return;
50
+ await rename(page.contentPath, join(dir, `${canonicalBase}.html`));
51
+ await rename(page.metaPath, join(dir, `${canonicalBase}.json`));
52
+ }
53
+ // A page is its `.html` content file plus an optional `.json` sidecar for everything else
54
+ // (a fresh hand-created page may have no sidecar yet). One page is read in isolation: a
55
+ // corrupted or hand-broken sidecar must only skip that page, not abort loading the rest of
56
+ // the directory, same principle as commands/form/push.ts.
57
+ async loadFiles(dir) {
58
+ let files;
59
+ try {
60
+ files = await readdir(dir);
61
+ }
62
+ catch (error) {
63
+ if (error.code === 'ENOENT')
64
+ return [];
65
+ throw error;
66
+ }
67
+ const pages = [];
68
+ for (const file of files) {
69
+ if (extname(file) !== '.html')
70
+ continue;
71
+ const contentPath = join(dir, file);
72
+ const metaPath = join(dir, `${basename(file, '.html')}.json`);
73
+ let meta = {};
74
+ try {
75
+ const parsed = JSON.parse(await readFile(metaPath, 'utf8'));
76
+ if (typeof parsed !== 'object' || parsed === null) {
77
+ this.warn(`Skipping "${metaPath}": not a JSON object`);
78
+ continue;
79
+ }
80
+ meta = parsed;
81
+ }
82
+ catch (error) {
83
+ if (error.code !== 'ENOENT') {
84
+ this.warn(`Skipping "${metaPath}": ${error.message}`);
85
+ continue;
86
+ }
87
+ }
88
+ let content;
89
+ try {
90
+ content = await readFile(contentPath, 'utf8');
91
+ }
92
+ catch (error) {
93
+ this.warn(`Skipping "${contentPath}": ${error.message}`);
94
+ continue;
95
+ }
96
+ pages.push({ content, contentPath, meta, metaPath });
97
+ }
98
+ return pages;
99
+ }
100
+ // PUT-then-404-fallback-POST, same dance as commands/form/push.ts: pages are id-based.
101
+ //
102
+ // ponytail: `parent` round-trips as the source site's numeric page id untouched, so pushing
103
+ // to a site where that id belongs to a different page (or nothing) silently mis-parents or
104
+ // orphans the page. No id-remapping across sites here; fine for same-hierarchy environments,
105
+ // revisit if pushing across sites with divergent page trees becomes a real workflow.
106
+ async pushPage(page, task) {
107
+ const title = getPageTitle(page.meta);
108
+ if (this.dryRun) {
109
+ if (task)
110
+ task.output = `[dry-run] Would push: ${title}`;
111
+ return;
112
+ }
113
+ try {
114
+ const id = getPageId(page.meta);
115
+ const payload = { ...page.meta, content: page.content };
116
+ if (id === null) {
117
+ const created = await this.wp.post(PAGE_ENDPOINT, payload);
118
+ const newId = getPageId(created);
119
+ if (newId !== null)
120
+ await this.ensureCanonicalFilename(page, newId, title);
121
+ }
122
+ else {
123
+ try {
124
+ await this.wp.put(`${PAGE_ENDPOINT}/${id}`, payload);
125
+ await this.ensureCanonicalFilename(page, id, title);
126
+ }
127
+ catch (error) {
128
+ // The id recorded locally doesn't exist on this site (e.g. a fresh install): create
129
+ // it instead of failing, and adopt whatever id the site assigns. WordPress core
130
+ // rejects any POST that still carries a (now stale) `id` field with a 400 "Cannot
131
+ // create existing post", regardless of whether that id actually exists, so it must
132
+ // be stripped before falling back to create.
133
+ if (!isNotFoundError(error))
134
+ throw error;
135
+ const createPayload = { ...payload };
136
+ delete createPayload.id;
137
+ const created = await this.wp.post(PAGE_ENDPOINT, createPayload);
138
+ const newId = getPageId(created);
139
+ if (newId !== null)
140
+ await this.ensureCanonicalFilename(page, newId, title);
141
+ }
142
+ }
143
+ if (task)
144
+ task.output = `Pushed: ${title}`;
145
+ }
146
+ catch (error) {
147
+ this.reportTaskFailure(`Failed to push ${title}: ${error.message}`, error, task);
148
+ }
149
+ }
150
+ }
@@ -3,6 +3,7 @@ export default class Config extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  run(): Promise<void>;
6
+ private maybeBootstrapLoopressFull;
6
7
  private promptManualCredentials;
7
8
  private resolveCredentials;
8
9
  private resolveProject;
@@ -1,8 +1,11 @@
1
1
  import { confirm, input, password as passwordPrompt, select } from '@inquirer/prompts';
2
2
  import { Command } from '@oclif/core';
3
3
  import { configManager } from '../../config/project-config.manager.js';
4
+ import { bootstrapLoopressFull } from '../../lib/bootstrap-full-install.js';
4
5
  import { isInteractive } from '../../lib/interactive.js';
6
+ import { isLoopressFullActive } from '../../lib/plugin-detection.js';
5
7
  import { authorizeWithBrowser } from '../../lib/wp-authorize-flow.js';
8
+ import { WpClient } from '../../lib/wp-client.js';
6
9
  import { diagnoseWpSite } from '../../lib/wp-site-diagnostic.js';
7
10
  const NEW_PROJECT = '__new__';
8
11
  const AUTH_BROWSER = 'browser';
@@ -78,8 +81,33 @@ export default class Config extends Command {
78
81
  configManager.setProject(projectId, project);
79
82
  }
80
83
  this.log(`✓ "${projectName}/${envName}" configured`);
84
+ await this.maybeBootstrapLoopressFull(env);
81
85
  this.log('→ Run `lps project switch` to change the active project or environment');
82
86
  }
87
+ async maybeBootstrapLoopressFull(env) {
88
+ const { token, url } = env;
89
+ if (!token)
90
+ return; // env was just built with one a few lines up; guard only narrows the type
91
+ const wp = new WpClient(url, token);
92
+ // A detection failure (network hiccup, plugins REST disabled) is treated as "assume it's
93
+ // there" rather than surprising the user with an account-creating prompt they didn't ask
94
+ // for; `lps project config` can simply be re-run if it genuinely isn't installed.
95
+ const alreadyActive = await isLoopressFullActive(wp).catch(() => true);
96
+ if (alreadyActive)
97
+ return;
98
+ const proceed = await confirm({
99
+ default: true,
100
+ message: 'Loopress Full was not detected on this site. Install it now? (creates a temporary admin account, removed automatically afterward)',
101
+ });
102
+ if (!proceed)
103
+ return;
104
+ try {
105
+ await bootstrapLoopressFull(wp, url, (message) => this.log(message));
106
+ }
107
+ catch (error) {
108
+ this.warn(error.message);
109
+ }
110
+ }
83
111
  async promptManualCredentials() {
84
112
  const user = await input({
85
113
  message: 'Username',
@@ -0,0 +1,7 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ export default class Rotate extends LoopressCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ protected maybeAutoRotate(): Promise<void>;
6
+ run(): Promise<void>;
7
+ }
@@ -0,0 +1,20 @@
1
+ import { configManager } from '../../config/project-config.manager.js';
2
+ import { LoopressCommand } from '../../lib/base.js';
3
+ import { rotateAppPassword } from '../../lib/rotate-app-password.js';
4
+ export default class Rotate extends LoopressCommand {
5
+ static description = 'Rotate the WordPress application password for the current (or --env) environment';
6
+ static examples = ['$ lps project rotate', '$ lps project rotate --env staging'];
7
+ // The rotation above always runs, regardless of age; the background age-check in
8
+ // LoopressCommand would just redo the same work a second time right after.
9
+ async maybeAutoRotate() { }
10
+ async run() {
11
+ const { name, token, url } = this.siteConfig;
12
+ if (!token) {
13
+ this.error(`No credentials configured for ${url}. Run \`lps project config\` first.`);
14
+ }
15
+ this.log(`Rotating application password for ${url}...`);
16
+ const rotated = await rotateAppPassword({ ...this.siteConfig, token });
17
+ configManager.setEnvironment(this.projectId, name, rotated);
18
+ this.log('✓ New application password created and verified, previous one revoked.');
19
+ }
20
+ }
@@ -14,16 +14,19 @@ export declare abstract class LoopressCommand extends Command {
14
14
  };
15
15
  protected dryRun: boolean;
16
16
  protected localConfig: LoopressLocalConfig;
17
+ protected projectId: string;
17
18
  protected siteConfig: EnvironmentConfig;
18
19
  protected yes: boolean;
19
20
  private wpClient?;
20
21
  protected get rootDir(): string;
21
22
  protected get wp(): WpClient;
22
23
  init(): Promise<void>;
24
+ protected maybeAutoRotate(): Promise<void>;
23
25
  protected removeOrphanedFiles(dir: string, orphans: string[], reason: string): Promise<void>;
24
26
  protected resolveAcfPath(override?: string): string;
25
27
  protected resolveApiPath(override?: string): string;
26
28
  protected resolveFormPath(override?: string): string;
29
+ protected resolvePagePath(override?: string): string;
27
30
  protected resolveSeoPath(override?: string): string;
28
31
  protected resolveSnippetsPath(override?: string): string;
29
32
  private pickEnvironment;
package/dist/lib/base.js CHANGED
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import { configManager } from '../config/project-config.manager.js';
6
6
  import { readLocalConfig } from '../utils/loopress-config.js';
7
7
  import { isInteractive } from './interactive.js';
8
+ import { isAppPasswordStale, rotateAppPassword } from './rotate-app-password.js';
8
9
  import { WpClient } from './wp-client.js';
9
10
  export class LoopressCommand extends Command {
10
11
  // On every subclass without opt-in: targeting an environment explicitly beats depending on
@@ -22,6 +23,7 @@ export class LoopressCommand extends Command {
22
23
  };
23
24
  dryRun = false;
24
25
  localConfig = {};
26
+ projectId;
25
27
  siteConfig;
26
28
  yes = false;
27
29
  wpClient;
@@ -49,7 +51,30 @@ export class LoopressCommand extends Command {
49
51
  this.dryRun = Boolean(flags['dry-run']);
50
52
  this.yes = Boolean(flags.yes);
51
53
  this.localConfig = await readLocalConfig();
52
- this.siteConfig = this.resolveEnvironment(flags.env);
54
+ const resolved = this.resolveEnvironment(flags.env);
55
+ this.siteConfig = resolved.env;
56
+ this.projectId = resolved.projectId;
57
+ await this.maybeAutoRotate();
58
+ }
59
+ // Silent, best-effort: a stale app password still works, so a failed rotation attempt
60
+ // (offline, site down) just tries again on the next command instead of blocking this one.
61
+ // `dryRun` is excluded since rotating writes a new credential, a real side effect a
62
+ // --dry-run run promises not to cause. `Rotate` overrides this to a no-op: its own `run()`
63
+ // already rotates unconditionally, so the background check would only redo the same work.
64
+ async maybeAutoRotate() {
65
+ if (this.dryRun)
66
+ return;
67
+ const { token } = this.siteConfig;
68
+ if (!token || !isAppPasswordStale(this.siteConfig.addedAt))
69
+ return;
70
+ try {
71
+ const rotated = await rotateAppPassword({ ...this.siteConfig, token });
72
+ this.siteConfig = rotated;
73
+ configManager.setEnvironment(this.projectId, rotated.name, rotated);
74
+ }
75
+ catch {
76
+ // swallowed: see comment above
77
+ }
53
78
  }
54
79
  // Orphan cleanup shared by every pull command (see Push Deletion Rules in the product docs):
55
80
  // in a TTY the list is shown and confirmed first (Enter accepts), --yes skips the prompt, and
@@ -93,6 +118,11 @@ export class LoopressCommand extends Command {
93
118
  return override;
94
119
  return join(this.rootDir, this.localConfig.formDir ?? 'forms');
95
120
  }
121
+ resolvePagePath(override) {
122
+ if (override)
123
+ return override;
124
+ return join(this.rootDir, this.localConfig.pageDir ?? 'pages');
125
+ }
96
126
  resolveSeoPath(override) {
97
127
  if (override)
98
128
  return override;
@@ -112,36 +142,38 @@ export class LoopressCommand extends Command {
112
142
  }
113
143
  resolveEnvironment(envName) {
114
144
  if (this.localConfig.projectId) {
115
- const project = configManager.getProject(this.localConfig.projectId);
145
+ const { projectId } = this.localConfig;
146
+ const project = configManager.getProject(projectId);
116
147
  if (!project) {
117
- this.error(`Project "${this.localConfig.projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
148
+ this.error(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
118
149
  }
119
150
  const envNames = Object.keys(project.environments);
120
151
  if (envNames.length === 0) {
121
152
  this.error(`Project "${project.name}" has no environments configured. Run \`lps project config\` to add one.`);
122
153
  }
123
154
  if (envName)
124
- return this.pickEnvironment(project, envName);
155
+ return { env: this.pickEnvironment(project, envName), projectId };
125
156
  if (envNames.length === 1) {
126
- return project.environments[envNames[0]];
157
+ return { env: project.environments[envNames[0]], projectId };
127
158
  }
128
159
  const current = configManager.getCurrentProject();
129
- const currentEnv = current?.id === this.localConfig.projectId ? configManager.getCurrentEnv() : null;
160
+ const currentEnv = current?.id === projectId ? configManager.getCurrentEnv() : null;
130
161
  if (!currentEnv) {
131
162
  this.error(`Project "${project.name}" has multiple environments. Run \`lps project switch\` to pick one.`);
132
163
  }
133
- return currentEnv;
164
+ return { env: currentEnv, projectId };
134
165
  }
135
166
  if (envName) {
136
167
  const current = configManager.getCurrentProject();
137
168
  if (!current) {
138
169
  this.error('No project configured. Run `lps project config` first.');
139
170
  }
140
- return this.pickEnvironment(current, envName);
171
+ return { env: this.pickEnvironment(current, envName), projectId: current.id };
141
172
  }
142
173
  const env = configManager.getCurrentEnv();
143
- if (env)
144
- return env;
174
+ const current = configManager.getCurrentProject();
175
+ if (env && current)
176
+ return { env, projectId: current.id };
145
177
  this.error('No environment configured. Run `lps project config` first.');
146
178
  }
147
179
  }
@@ -0,0 +1,10 @@
1
+ import { WpClient } from './wp-client.js';
2
+ /**
3
+ * Installs and activates Loopress Full on a site that doesn't have it yet: downloads the
4
+ * latest release zip, creates a temporary admin (the only way to get `install_plugins` from
5
+ * just an app password), drives the wp-admin upload UI headlessly, then removes the temp
6
+ * account. Cleanup always runs, whether the install succeeded or not: the temp account never
7
+ * stays alive waiting on a human, the fallback for a failed install is manual instructions
8
+ * using the real user's own credentials, not the temp account.
9
+ */
10
+ export declare function bootstrapLoopressFull(wp: WpClient, siteUrl: string, log: (message: string) => void): Promise<void>;
@@ -0,0 +1,66 @@
1
+ import { launchLocalBrowser } from './browser-launch.js';
2
+ import { downloadLatestFullZip } from './github-release.js';
3
+ import { createTempAdmin, deleteTempAdmin } from './temp-admin.js';
4
+ /**
5
+ * Installs and activates Loopress Full on a site that doesn't have it yet: downloads the
6
+ * latest release zip, creates a temporary admin (the only way to get `install_plugins` from
7
+ * just an app password), drives the wp-admin upload UI headlessly, then removes the temp
8
+ * account. Cleanup always runs, whether the install succeeded or not: the temp account never
9
+ * stays alive waiting on a human, the fallback for a failed install is manual instructions
10
+ * using the real user's own credentials, not the temp account.
11
+ */
12
+ export async function bootstrapLoopressFull(wp, siteUrl, log) {
13
+ log('Downloading the latest Loopress Full release...');
14
+ const zipPath = await downloadLatestFullZip();
15
+ log('Creating a temporary admin account to install it...');
16
+ const admin = await createTempAdmin(wp);
17
+ let installError;
18
+ try {
19
+ log('Installing and activating Loopress Full...');
20
+ await runBrowserInstall(admin, siteUrl, zipPath);
21
+ }
22
+ catch (error) {
23
+ installError = error;
24
+ }
25
+ if (!installError) {
26
+ log('Loopress Full installed and activated.');
27
+ }
28
+ log('Removing the temporary admin account...');
29
+ let cleanupError;
30
+ try {
31
+ await deleteTempAdmin(wp, admin);
32
+ }
33
+ catch (error) {
34
+ cleanupError = error;
35
+ }
36
+ const manualFallback = `Install it manually: upload ${zipPath} at ${siteUrl}/wp-admin/plugin-install.php?tab=upload`;
37
+ if (installError && cleanupError) {
38
+ throw new Error(`Could not install Loopress Full automatically, and the temporary admin account could not be removed (${cleanupError.message}). ${manualFallback}`, { cause: installError });
39
+ }
40
+ if (cleanupError)
41
+ throw cleanupError;
42
+ if (installError) {
43
+ throw new Error(`Could not install Loopress Full automatically. ${manualFallback}`, { cause: installError });
44
+ }
45
+ }
46
+ async function runBrowserInstall(admin, siteUrl, zipPath) {
47
+ const browser = await launchLocalBrowser();
48
+ try {
49
+ const page = await browser.newPage();
50
+ await page.goto(`${siteUrl}/wp-login.php`, { waitUntil: 'domcontentloaded' });
51
+ await page.fill('#user_login', admin.username);
52
+ await page.fill('#user_pass', admin.password);
53
+ await Promise.all([page.waitForLoadState('domcontentloaded'), page.click('#wp-submit')]);
54
+ await page.goto(`${siteUrl}/wp-admin/plugin-install.php?tab=upload`, { waitUntil: 'domcontentloaded' });
55
+ await page.setInputFiles('#pluginzip', zipPath);
56
+ await Promise.all([page.waitForLoadState('domcontentloaded'), page.click('input[name="install-plugin-submit"]')]);
57
+ // The activate link only appears after a successful install; its absence (a moved
58
+ // selector, DISALLOW_FILE_MODS, an unexpected wp-admin state) means the install didn't
59
+ // go through, not that activation is a separate optional step.
60
+ const activateLink = await page.waitForSelector('a[href*="action=activate"]', { timeout: 15_000 });
61
+ await Promise.all([page.waitForLoadState('domcontentloaded'), activateLink.click()]);
62
+ }
63
+ finally {
64
+ await browser.close();
65
+ }
66
+ }
@@ -0,0 +1,8 @@
1
+ import { type Browser } from 'playwright-core';
2
+ /**
3
+ * Launches a locally installed Chrome/Edge/Chromium via `playwright-core`, which ships no
4
+ * bundled browser (unlike `playwright`, ~300MB of Chromium): it drives whatever's already on
5
+ * the machine, tried in this order. Always headless: this flow has no click for a human to
6
+ * make, so there's nothing a visible window would let anyone do differently.
7
+ */
8
+ export declare function launchLocalBrowser(): Promise<Browser>;
@@ -0,0 +1,20 @@
1
+ import { chromium } from 'playwright-core';
2
+ const CHANNELS = ['chrome', 'msedge', 'chromium'];
3
+ /**
4
+ * Launches a locally installed Chrome/Edge/Chromium via `playwright-core`, which ships no
5
+ * bundled browser (unlike `playwright`, ~300MB of Chromium): it drives whatever's already on
6
+ * the machine, tried in this order. Always headless: this flow has no click for a human to
7
+ * make, so there's nothing a visible window would let anyone do differently.
8
+ */
9
+ export async function launchLocalBrowser() {
10
+ let lastError;
11
+ for (const channel of CHANNELS) {
12
+ try {
13
+ return await chromium.launch({ channel, headless: true });
14
+ }
15
+ catch (error) {
16
+ lastError = error;
17
+ }
18
+ }
19
+ throw new Error('No local Chrome, Edge, or Chromium install found. Install one of these browsers to install Loopress Full automatically, or install it manually from wp-admin.', { cause: lastError });
20
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Downloads `loopress-full.zip` from the most recent `wordpress-plugin@*` release of
3
+ * `loopress/loopress`, returning the local path to the downloaded file.
4
+ *
5
+ * `releases/latest` isn't used on purpose: this repo's releases interleave
6
+ * `wordpress-plugin@X.Y.Z` and `@loopress/cli@X.Y.Z` tags, so "latest" can be a CLI release
7
+ * with no plugin assets at all. Every `wordpress-plugin@*` release always ships both
8
+ * `loopress-full.zip` and `loopress-light.zip`, so the asset is matched by exact name, not by
9
+ * "the one zip asset".
10
+ */
11
+ export declare function downloadLatestFullZip(): Promise<string>;
@@ -0,0 +1,37 @@
1
+ import got from 'got';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { pipeline } from 'node:stream/promises';
7
+ const REPO = 'loopress/loopress';
8
+ const RELEASE_TAG_PATTERN = /^wordpress-plugin@/;
9
+ const ASSET_NAME = 'loopress-full.zip';
10
+ /**
11
+ * Downloads `loopress-full.zip` from the most recent `wordpress-plugin@*` release of
12
+ * `loopress/loopress`, returning the local path to the downloaded file.
13
+ *
14
+ * `releases/latest` isn't used on purpose: this repo's releases interleave
15
+ * `wordpress-plugin@X.Y.Z` and `@loopress/cli@X.Y.Z` tags, so "latest" can be a CLI release
16
+ * with no plugin assets at all. Every `wordpress-plugin@*` release always ships both
17
+ * `loopress-full.zip` and `loopress-light.zip`, so the asset is matched by exact name, not by
18
+ * "the one zip asset".
19
+ */
20
+ export async function downloadLatestFullZip() {
21
+ const releases = await got(`https://api.github.com/repos/${REPO}/releases`, {
22
+ headers: { accept: 'application/vnd.github+json', 'user-agent': 'loopress-cli' },
23
+ searchParams: { 'per_page': 30 },
24
+ }).json();
25
+ const pluginRelease = releases.find((release) => RELEASE_TAG_PATTERN.test(release.tag_name));
26
+ if (!pluginRelease) {
27
+ throw new Error(`No "wordpress-plugin@*" release found in ${REPO}.`);
28
+ }
29
+ const asset = pluginRelease.assets.find((candidate) => candidate.name === ASSET_NAME);
30
+ if (!asset) {
31
+ throw new Error(`Release "${pluginRelease.tag_name}" has no "${ASSET_NAME}" asset.`);
32
+ }
33
+ const dir = await mkdtemp(join(tmpdir(), 'lps-loopress-full-'));
34
+ const zipPath = join(dir, ASSET_NAME);
35
+ await pipeline(got.stream(asset.browser_download_url), createWriteStream(zipPath));
36
+ return zipPath;
37
+ }