@sociallane/elements 1.0.13 → 1.0.14

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.
@@ -0,0 +1,58 @@
1
+ name: SocialLane npm/npx Gates
2
+
3
+ on:
4
+ pull_request:
5
+ paths:
6
+ - 'sociallane-elements/**'
7
+ push:
8
+ branches:
9
+ - main
10
+ paths:
11
+ - 'sociallane-elements/**'
12
+ workflow_dispatch:
13
+
14
+ jobs:
15
+ cli-smoke:
16
+ runs-on: ubuntu-latest
17
+ defaults:
18
+ run:
19
+ working-directory: sociallane-elements
20
+ steps:
21
+ - name: Checkout
22
+ uses: actions/checkout@v4
23
+
24
+ - name: Setup Node
25
+ uses: actions/setup-node@v4
26
+ with:
27
+ node-version: '20'
28
+
29
+ - name: CLI smoke tests
30
+ run: node scripts/test-cli-smoke.js
31
+
32
+ - name: List command sanity check
33
+ run: node scripts/install.js list --json
34
+
35
+ - name: Doctor command sanity check
36
+ run: node scripts/install.js doctor --json --target .
37
+
38
+ release-gate:
39
+ if: github.event_name == 'workflow_dispatch'
40
+ runs-on: ubuntu-latest
41
+ needs: cli-smoke
42
+ defaults:
43
+ run:
44
+ working-directory: sociallane-elements
45
+ steps:
46
+ - name: Checkout
47
+ uses: actions/checkout@v4
48
+
49
+ - name: Setup Node
50
+ uses: actions/setup-node@v4
51
+ with:
52
+ node-version: '20'
53
+
54
+ - name: Sync canonical widget manifest
55
+ run: node scripts/generate-widget-manifest.js
56
+
57
+ - name: Release gate (strict)
58
+ run: node scripts/release-gate.js --strict
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sociallane/elements",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "description": "Elementor widgets and elements with Tailwind CSS for SocialLane. WordPress plugin.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -30,8 +30,13 @@
30
30
  "postinstall": "node scripts/postinstall.js",
31
31
  "setup": "bash scripts/setup.sh",
32
32
  "sync-widgets": "node scripts/sync-widgets.js",
33
+ "manifest:widgets": "node scripts/generate-widget-manifest.js",
34
+ "generate:widget-table": "node scripts/generate-widget-table.js",
33
35
  "build:widget-packages": "node scripts/build-widget-packages.js",
34
36
  "publish:widgets": "node scripts/build-widget-packages.js && node scripts/publish-widget-packages.js",
37
+ "test:cli-smoke": "node scripts/test-cli-smoke.js",
38
+ "release:check": "node scripts/release-gate.js --strict",
39
+ "release:check:local": "node scripts/release-gate.js --strict --skip-registry",
35
40
  "reinstall": "rm -rf node_modules && npm install",
36
41
  "format": "prettier --write .",
37
42
  "format:check": "prettier --check ."
@@ -0,0 +1,291 @@
1
+ {
2
+ "schema_version": 1,
3
+ "generated_at": "2026-02-10T09:16:15.290Z",
4
+ "source": "packages/widgets",
5
+ "widgets": [
6
+ {
7
+ "slug": "bento-grid",
8
+ "package": "@sociallane/widget-bento-grid"
9
+ },
10
+ {
11
+ "slug": "bento-grid-4",
12
+ "package": "@sociallane/widget-bento-grid-4"
13
+ },
14
+ {
15
+ "slug": "bento-grid-5",
16
+ "package": "@sociallane/widget-bento-grid-5"
17
+ },
18
+ {
19
+ "slug": "bento-grid-6",
20
+ "package": "@sociallane/widget-bento-grid-6"
21
+ },
22
+ {
23
+ "slug": "bento-portfolio",
24
+ "package": "@sociallane/widget-bento-portfolio"
25
+ },
26
+ {
27
+ "slug": "bento-portfolio-4",
28
+ "package": "@sociallane/widget-bento-portfolio-4"
29
+ },
30
+ {
31
+ "slug": "bento-portfolio-5",
32
+ "package": "@sociallane/widget-bento-portfolio-5"
33
+ },
34
+ {
35
+ "slug": "bento-portfolio-6",
36
+ "package": "@sociallane/widget-bento-portfolio-6"
37
+ },
38
+ {
39
+ "slug": "blog-grid",
40
+ "package": "@sociallane/widget-blog-grid"
41
+ },
42
+ {
43
+ "slug": "card-hover-reveal",
44
+ "package": "@sociallane/widget-card-hover-reveal"
45
+ },
46
+ {
47
+ "slug": "client-logos",
48
+ "package": "@sociallane/widget-client-logos"
49
+ },
50
+ {
51
+ "slug": "content-block",
52
+ "package": "@sociallane/widget-content-block"
53
+ },
54
+ {
55
+ "slug": "cta-banner",
56
+ "package": "@sociallane/widget-cta-banner"
57
+ },
58
+ {
59
+ "slug": "cta-notify",
60
+ "package": "@sociallane/widget-cta-notify"
61
+ },
62
+ {
63
+ "slug": "cta-split",
64
+ "package": "@sociallane/widget-cta-split"
65
+ },
66
+ {
67
+ "slug": "faq-centered",
68
+ "package": "@sociallane/widget-faq-centered"
69
+ },
70
+ {
71
+ "slug": "faq-split",
72
+ "package": "@sociallane/widget-faq-split"
73
+ },
74
+ {
75
+ "slug": "faq-stacked",
76
+ "package": "@sociallane/widget-faq-stacked"
77
+ },
78
+ {
79
+ "slug": "feature-grid",
80
+ "package": "@sociallane/widget-feature-grid"
81
+ },
82
+ {
83
+ "slug": "feature-grid-centered",
84
+ "package": "@sociallane/widget-feature-grid-centered"
85
+ },
86
+ {
87
+ "slug": "feature-list",
88
+ "package": "@sociallane/widget-feature-list"
89
+ },
90
+ {
91
+ "slug": "feature-list-cta",
92
+ "package": "@sociallane/widget-feature-list-cta"
93
+ },
94
+ {
95
+ "slug": "footer",
96
+ "package": "@sociallane/widget-footer"
97
+ },
98
+ {
99
+ "slug": "footer-brand",
100
+ "package": "@sociallane/widget-footer-brand"
101
+ },
102
+ {
103
+ "slug": "footer-links-contact",
104
+ "package": "@sociallane/widget-footer-links-contact"
105
+ },
106
+ {
107
+ "slug": "form-contact",
108
+ "package": "@sociallane/widget-form-contact"
109
+ },
110
+ {
111
+ "slug": "grid-case-studies",
112
+ "package": "@sociallane/widget-grid-case-studies"
113
+ },
114
+ {
115
+ "slug": "grid-components",
116
+ "package": "@sociallane/widget-grid-components"
117
+ },
118
+ {
119
+ "slug": "grid-team",
120
+ "package": "@sociallane/widget-grid-team"
121
+ },
122
+ {
123
+ "slug": "hero-announcement",
124
+ "package": "@sociallane/widget-hero-announcement"
125
+ },
126
+ {
127
+ "slug": "hero-centered-image",
128
+ "package": "@sociallane/widget-hero-centered-image"
129
+ },
130
+ {
131
+ "slug": "hero-collage",
132
+ "package": "@sociallane/widget-hero-collage"
133
+ },
134
+ {
135
+ "slug": "hero-overlay",
136
+ "package": "@sociallane/widget-hero-overlay"
137
+ },
138
+ {
139
+ "slug": "hero-overlay-single",
140
+ "package": "@sociallane/widget-hero-overlay-single"
141
+ },
142
+ {
143
+ "slug": "hero-overlay-slider",
144
+ "package": "@sociallane/widget-hero-overlay-slider"
145
+ },
146
+ {
147
+ "slug": "hero-saas-centered",
148
+ "package": "@sociallane/widget-hero-saas-centered"
149
+ },
150
+ {
151
+ "slug": "hero-saas-split",
152
+ "package": "@sociallane/widget-hero-saas-split"
153
+ },
154
+ {
155
+ "slug": "hero-saas-stacked",
156
+ "package": "@sociallane/widget-hero-saas-stacked"
157
+ },
158
+ {
159
+ "slug": "hero-split",
160
+ "package": "@sociallane/widget-hero-split"
161
+ },
162
+ {
163
+ "slug": "hero-stacked-image",
164
+ "package": "@sociallane/widget-hero-stacked-image"
165
+ },
166
+ {
167
+ "slug": "intro-pattern",
168
+ "package": "@sociallane/widget-intro-pattern"
169
+ },
170
+ {
171
+ "slug": "intro-text",
172
+ "package": "@sociallane/widget-intro-text"
173
+ },
174
+ {
175
+ "slug": "logo-grid-centered",
176
+ "package": "@sociallane/widget-logo-grid-centered"
177
+ },
178
+ {
179
+ "slug": "logo-grid-row",
180
+ "package": "@sociallane/widget-logo-grid-row"
181
+ },
182
+ {
183
+ "slug": "logo-grid-split",
184
+ "package": "@sociallane/widget-logo-grid-split"
185
+ },
186
+ {
187
+ "slug": "nav-centered",
188
+ "package": "@sociallane/widget-nav-centered"
189
+ },
190
+ {
191
+ "slug": "nav-compact",
192
+ "package": "@sociallane/widget-nav-compact"
193
+ },
194
+ {
195
+ "slug": "nav-default",
196
+ "package": "@sociallane/widget-nav-default"
197
+ },
198
+ {
199
+ "slug": "nav-floating",
200
+ "package": "@sociallane/widget-nav-floating"
201
+ },
202
+ {
203
+ "slug": "nav-minimal",
204
+ "package": "@sociallane/widget-nav-minimal"
205
+ },
206
+ {
207
+ "slug": "newsletter",
208
+ "package": "@sociallane/widget-newsletter"
209
+ },
210
+ {
211
+ "slug": "newsletter-card",
212
+ "package": "@sociallane/widget-newsletter-card"
213
+ },
214
+ {
215
+ "slug": "newsletter-section",
216
+ "package": "@sociallane/widget-newsletter-section"
217
+ },
218
+ {
219
+ "slug": "outreach-dashboard",
220
+ "package": "@sociallane/widget-outreach-dashboard"
221
+ },
222
+ {
223
+ "slug": "page-hero-center",
224
+ "package": "@sociallane/widget-page-hero-center"
225
+ },
226
+ {
227
+ "slug": "page-hero-left",
228
+ "package": "@sociallane/widget-page-hero-left"
229
+ },
230
+ {
231
+ "slug": "pipeline-dashboard",
232
+ "package": "@sociallane/widget-pipeline-dashboard"
233
+ },
234
+ {
235
+ "slug": "posts-grid",
236
+ "package": "@sociallane/widget-posts-grid"
237
+ },
238
+ {
239
+ "slug": "posts-grid-overlay",
240
+ "package": "@sociallane/widget-posts-grid-overlay"
241
+ },
242
+ {
243
+ "slug": "pricing-table",
244
+ "package": "@sociallane/widget-pricing-table"
245
+ },
246
+ {
247
+ "slug": "sales-dashboard",
248
+ "package": "@sociallane/widget-sales-dashboard"
249
+ },
250
+ {
251
+ "slug": "section-stats",
252
+ "package": "@sociallane/widget-section-stats"
253
+ },
254
+ {
255
+ "slug": "services",
256
+ "package": "@sociallane/widget-services"
257
+ },
258
+ {
259
+ "slug": "simple-page-hero",
260
+ "package": "@sociallane/widget-simple-page-hero"
261
+ },
262
+ {
263
+ "slug": "social-proof",
264
+ "package": "@sociallane/widget-social-proof"
265
+ },
266
+ {
267
+ "slug": "social-proof-trust",
268
+ "package": "@sociallane/widget-social-proof-trust"
269
+ },
270
+ {
271
+ "slug": "testimonial-quote",
272
+ "package": "@sociallane/widget-testimonial-quote"
273
+ },
274
+ {
275
+ "slug": "testimonials-bento",
276
+ "package": "@sociallane/widget-testimonials-bento"
277
+ },
278
+ {
279
+ "slug": "testimonials-grid",
280
+ "package": "@sociallane/widget-testimonials-grid"
281
+ },
282
+ {
283
+ "slug": "testimonials-masonry",
284
+ "package": "@sociallane/widget-testimonials-masonry"
285
+ },
286
+ {
287
+ "slug": "widget-filter",
288
+ "package": "@sociallane/widget-widget-filter"
289
+ }
290
+ ]
291
+ }
@@ -5,27 +5,15 @@
5
5
  * Run from plugin root: npm run build:widget-packages
6
6
  */
7
7
 
8
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
8
+ import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
9
9
  import path from 'path';
10
10
  import { fileURLToPath } from 'url';
11
+ import { getWidgetSourceDir, syncWidgetManifest } from './lib/widgets-manifest.js';
11
12
 
12
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
14
  const pluginRoot = path.resolve(__dirname, '..');
14
- const widgetsDir = path.join(pluginRoot, 'packages', 'widgets');
15
15
  const npmWidgetsDir = path.join(pluginRoot, 'packages', 'npm-widgets');
16
-
17
- function getWidgetSlugs() {
18
- if (!existsSync(widgetsDir)) return [];
19
- const entries = readdirSync(widgetsDir, { withFileTypes: true });
20
- const slugs = [];
21
- for (const ent of entries) {
22
- if (!ent.isDirectory()) continue;
23
- const slug = ent.name;
24
- const phpFile = path.join(widgetsDir, slug, `${slug}.php`);
25
- if (existsSync(phpFile)) slugs.push(slug);
26
- }
27
- return slugs.sort((a, b) => a.localeCompare(b));
28
- }
16
+ const widgetsDir = getWidgetSourceDir(pluginRoot);
29
17
 
30
18
  function getRootVersion() {
31
19
  const pkgPath = path.join(pluginRoot, 'package.json');
@@ -79,7 +67,8 @@ This installs the SocialLane Elements plugin if needed, then adds this widget an
79
67
  }
80
68
 
81
69
  function main() {
82
- const slugs = getWidgetSlugs();
70
+ const manifest = syncWidgetManifest(pluginRoot);
71
+ const slugs = manifest.widgets.map((widget) => widget.slug);
83
72
  const version = getRootVersion();
84
73
  if (!existsSync(npmWidgetsDir)) mkdirSync(npmWidgetsDir, { recursive: true });
85
74
  for (const slug of slugs) {
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generate packages/widget-manifest.json from widget sources on disk.
4
+ * Run from plugin root: npm run manifest:widgets
5
+ */
6
+
7
+ import path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import { syncWidgetManifest, getWidgetManifestPath } from './lib/widgets-manifest.js';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const pluginRoot = path.resolve(__dirname, '..');
13
+
14
+ const manifest = syncWidgetManifest(pluginRoot);
15
+ const manifestPath = getWidgetManifestPath(pluginRoot);
16
+
17
+ console.log(
18
+ `Widget manifest updated: ${manifest.widgets.length} widgets (${manifest.source}) -> ${manifestPath}`
19
+ );
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generate a markdown table from the canonical widget manifest.
4
+ * Prints to stdout by default, or write to --out <path>.
5
+ */
6
+
7
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
8
+ import path from 'path';
9
+ import { fileURLToPath } from 'url';
10
+ import { loadWidgetManifest } from './lib/widgets-manifest.js';
11
+
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const pluginRoot = path.resolve(__dirname, '..');
14
+
15
+ function parseOutPath(args) {
16
+ for (let i = 0; i < args.length; i++) {
17
+ if (args[i] !== '--out') continue;
18
+ const value = args[i + 1];
19
+ if (!value || value.startsWith('-')) {
20
+ console.error('Missing value for --out');
21
+ process.exit(1);
22
+ }
23
+ return value;
24
+ }
25
+ return '';
26
+ }
27
+
28
+ function buildMarkdownTable(manifest) {
29
+ const lines = [];
30
+ lines.push('| Widget | Package | Command |');
31
+ lines.push('|--------|---------|---------|');
32
+
33
+ for (const widget of manifest.widgets) {
34
+ lines.push(
35
+ `| ${widget.slug} | \`${widget.package}\` | \`npm install ${widget.package}\` |`
36
+ );
37
+ }
38
+
39
+ return lines.join('\n') + '\n';
40
+ }
41
+
42
+ function main() {
43
+ const args = process.argv.slice(2);
44
+ const out = parseOutPath(args);
45
+ const manifest = loadWidgetManifest(pluginRoot);
46
+ const table = buildMarkdownTable(manifest);
47
+
48
+ if (!out) {
49
+ process.stdout.write(table);
50
+ return;
51
+ }
52
+
53
+ const outPath = path.resolve(process.cwd(), out);
54
+ const outDir = path.dirname(outPath);
55
+ if (!existsSync(outDir)) {
56
+ mkdirSync(outDir, { recursive: true });
57
+ }
58
+
59
+ writeFileSync(outPath, table, 'utf8');
60
+ console.log(`Widget table generated: ${outPath}`);
61
+ }
62
+
63
+ main();
@@ -5,6 +5,8 @@
5
5
  * Install (base): npx @sociallane/elements --base [path]
6
6
  * Install (full): npx @sociallane/elements [path]
7
7
  * Add widgets: npx @sociallane/elements add <slug> [slug...]
8
+ * List widgets: npx @sociallane/elements list [--json]
9
+ * Doctor checks: npx @sociallane/elements doctor [--target <path>] [--json]
8
10
  *
9
11
  * Install: copies package to wp-content/plugins/sociallane-elements (or path), runs npm install.
10
12
  * Base install: installs core/deps and keeps widgets.json empty (no widgets loaded by default).
@@ -15,6 +17,7 @@ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync
15
17
  import { spawnSync } from 'child_process';
16
18
  import path from 'path';
17
19
  import { fileURLToPath } from 'url';
20
+ import { getWidgetSourceDir, loadWidgetManifest } from './lib/widgets-manifest.js';
18
21
 
19
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
23
  const packageRoot = path.resolve(__dirname, '..');
@@ -35,6 +38,8 @@ function printHelp() {
35
38
  console.log('Usage:');
36
39
  console.log(' npx @sociallane/elements [--base|--minimal] [target]');
37
40
  console.log(' npx @sociallane/elements add [--only] [--target <path>] <slug> [slug...]');
41
+ console.log(' npx @sociallane/elements list [--json]');
42
+ console.log(' npx @sociallane/elements doctor [--target <path>] [--json]');
38
43
  console.log(' npx @sociallane/elements --help');
39
44
  console.log(' npx @sociallane/elements --version');
40
45
  console.log('');
@@ -42,6 +47,7 @@ function printHelp() {
42
47
  console.log(' --base, --minimal Install core/deps only, keep widgets.json empty');
43
48
  console.log(' --only With add: replace widgets.json with exactly provided slugs');
44
49
  console.log(' --target <path> With add: explicit plugin directory target');
50
+ console.log(' --json With list/doctor: output machine-readable JSON');
45
51
  console.log(' --help Show this help text');
46
52
  console.log(' --version Show CLI version');
47
53
  console.log('');
@@ -49,6 +55,8 @@ function printHelp() {
49
55
  console.log(' npx @sociallane/elements --base');
50
56
  console.log(' npx @sociallane/elements add faq-stacked hero-split');
51
57
  console.log(' npx @sociallane/elements add --only --target wp-content/plugins/sociallane-elements content-block');
58
+ console.log(' npx @sociallane/elements list');
59
+ console.log(' npx @sociallane/elements doctor --target wp-content/plugins/sociallane-elements');
52
60
  }
53
61
 
54
62
  function resolvePluginDir(targetPath = '') {
@@ -79,23 +87,7 @@ function resolvePluginDir(targetPath = '') {
79
87
  }
80
88
 
81
89
  function getWidgetsSourceDir() {
82
- const candidates = [
83
- path.join(packageRoot, 'packages', 'widgets'),
84
- path.join(packageRoot, 'widgets'),
85
- ];
86
-
87
- for (const dir of candidates) {
88
- if (!existsSync(dir)) {
89
- continue;
90
- }
91
- const available = readdirSync(dir, { withFileTypes: true })
92
- .filter((d) => d.isDirectory() && existsSync(path.join(dir, d.name, `${d.name}.php`)))
93
- .map((d) => d.name);
94
- if (available.length > 0) {
95
- return dir;
96
- }
97
- }
98
- return candidates[0];
90
+ return getWidgetSourceDir(packageRoot);
99
91
  }
100
92
 
101
93
  function updateWidgetsJson(pluginDir, slugsToAdd, replaceAll = false) {
@@ -196,6 +188,144 @@ function addWidgets(slugs, options = {}) {
196
188
  console.log('Done. Widgets added:', slugs.join(', '));
197
189
  }
198
190
 
191
+ function listWidgets(options = {}) {
192
+ const asJson = options.asJson === true;
193
+ const manifest = loadWidgetManifest(packageRoot);
194
+ const slugs = manifest.widgets.map((widget) => widget.slug);
195
+
196
+ if (asJson) {
197
+ console.log(
198
+ JSON.stringify(
199
+ {
200
+ count: slugs.length,
201
+ source: manifest.source || null,
202
+ widgets: slugs,
203
+ },
204
+ null,
205
+ 2
206
+ )
207
+ );
208
+ return;
209
+ }
210
+
211
+ if (slugs.length === 0) {
212
+ console.log('No widgets found.');
213
+ return;
214
+ }
215
+
216
+ console.log(slugs.join('\n'));
217
+ console.log('');
218
+ console.log(`Total: ${slugs.length}`);
219
+ }
220
+
221
+ function checkCommandVersion(command, args = ['--version']) {
222
+ const result = spawnSync(command, args, {
223
+ cwd: process.cwd(),
224
+ encoding: 'utf8',
225
+ shell: true,
226
+ });
227
+ return {
228
+ ok: result.status === 0,
229
+ output: (result.stdout || result.stderr || '').trim(),
230
+ };
231
+ }
232
+
233
+ function runDoctor(options = {}) {
234
+ const asJson = options.asJson === true;
235
+ const targetPath = options.targetPath || '';
236
+ const targetPlugin = resolvePluginDir(targetPath);
237
+ const checks = [];
238
+
239
+ const nodeVersion = process.version || '';
240
+ checks.push({
241
+ name: 'Node.js',
242
+ ok: nodeVersion !== '',
243
+ detail: nodeVersion || 'not detected',
244
+ });
245
+
246
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
247
+ const npm = checkCommandVersion(npmCmd);
248
+ checks.push({
249
+ name: 'npm',
250
+ ok: npm.ok,
251
+ detail: npm.ok ? npm.output : `not available (${npm.output || 'command failed'})`,
252
+ });
253
+
254
+ const pluginExists = existsSync(targetPlugin);
255
+ checks.push({
256
+ name: 'Plugin directory',
257
+ ok: pluginExists,
258
+ detail: targetPlugin,
259
+ });
260
+
261
+ const bootstrapPath = path.join(targetPlugin, 'sociallane-elements.php');
262
+ checks.push({
263
+ name: 'Plugin bootstrap',
264
+ ok: existsSync(bootstrapPath),
265
+ detail: bootstrapPath,
266
+ });
267
+
268
+ const corePath = path.join(targetPlugin, 'packages', 'core');
269
+ checks.push({
270
+ name: 'Core package',
271
+ ok: existsSync(corePath),
272
+ detail: corePath,
273
+ });
274
+
275
+ const widgetsPath = path.join(targetPlugin, 'packages', 'widgets');
276
+ const legacyWidgetsPath = path.join(targetPlugin, 'widgets');
277
+ checks.push({
278
+ name: 'Widgets source',
279
+ ok: existsSync(widgetsPath) || existsSync(legacyWidgetsPath),
280
+ detail: existsSync(widgetsPath) ? widgetsPath : legacyWidgetsPath,
281
+ });
282
+
283
+ const widgetsJsonPath = path.join(targetPlugin, 'widgets.json');
284
+ let widgetsJsonValid = false;
285
+ let widgetsCount = 0;
286
+ if (existsSync(widgetsJsonPath)) {
287
+ try {
288
+ const raw = readFileSync(widgetsJsonPath, 'utf8');
289
+ const parsed = JSON.parse(raw);
290
+ widgetsJsonValid = Array.isArray(parsed?.widgets);
291
+ if (widgetsJsonValid) {
292
+ widgetsCount = parsed.widgets.length;
293
+ }
294
+ } catch {
295
+ widgetsJsonValid = false;
296
+ }
297
+ }
298
+ checks.push({
299
+ name: 'widgets.json',
300
+ ok: existsSync(widgetsJsonPath) && widgetsJsonValid,
301
+ detail: existsSync(widgetsJsonPath)
302
+ ? `${widgetsJsonPath} (${widgetsCount} widgets)`
303
+ : `${widgetsJsonPath} (missing)`,
304
+ });
305
+
306
+ const summary = {
307
+ target: targetPlugin,
308
+ ok: checks.every((check) => check.ok),
309
+ checks,
310
+ };
311
+
312
+ if (asJson) {
313
+ console.log(JSON.stringify(summary, null, 2));
314
+ } else {
315
+ console.log('SocialLane Elements doctor');
316
+ console.log(`Target: ${summary.target}`);
317
+ console.log('');
318
+ for (const check of checks) {
319
+ const status = check.ok ? 'OK' : 'FAIL';
320
+ console.log(`[${status}] ${check.name}: ${check.detail}`);
321
+ }
322
+ }
323
+
324
+ if (!summary.ok) {
325
+ process.exit(1);
326
+ }
327
+ }
328
+
199
329
  function prepareBaseMode(pluginDir) {
200
330
  const widgetsDest = path.join(pluginDir, 'packages', 'widgets');
201
331
  if (!existsSync(widgetsDest)) {
@@ -334,6 +464,53 @@ function main() {
334
464
  return;
335
465
  }
336
466
 
467
+ if (args[0] === 'list') {
468
+ const listArgs = args.slice(1).filter(Boolean);
469
+ const knownListFlags = new Set(['--json']);
470
+ for (const arg of listArgs) {
471
+ if (!arg.startsWith('-')) {
472
+ console.error('Usage: npx @sociallane/elements list [--json]');
473
+ process.exit(1);
474
+ }
475
+ if (!knownListFlags.has(arg)) {
476
+ console.error('Unknown option for list:', arg);
477
+ process.exit(1);
478
+ }
479
+ }
480
+ listWidgets({ asJson: listArgs.includes('--json') });
481
+ return;
482
+ }
483
+
484
+ if (args[0] === 'doctor') {
485
+ const doctorArgs = args.slice(1).filter(Boolean);
486
+ const knownDoctorFlags = new Set(['--target', '--json']);
487
+ let targetPath = '';
488
+ for (let i = 0; i < doctorArgs.length; i++) {
489
+ const arg = doctorArgs[i];
490
+ if (arg === '--target') {
491
+ const next = doctorArgs[i + 1];
492
+ if (!next || next.startsWith('-')) {
493
+ console.error('Missing value for --target');
494
+ process.exit(1);
495
+ }
496
+ targetPath = next;
497
+ i++;
498
+ continue;
499
+ }
500
+ if (arg.startsWith('-')) {
501
+ if (!knownDoctorFlags.has(arg)) {
502
+ console.error('Unknown option for doctor:', arg);
503
+ process.exit(1);
504
+ }
505
+ continue;
506
+ }
507
+ console.error('Usage: npx @sociallane/elements doctor [--target <path>] [--json]');
508
+ process.exit(1);
509
+ }
510
+ runDoctor({ targetPath, asJson: doctorArgs.includes('--json') });
511
+ return;
512
+ }
513
+
337
514
  if (args[0] === 'add') {
338
515
  const addArgs = args.slice(1).filter(Boolean);
339
516
  const knownAddFlags = new Set(['--only', '--target']);
@@ -0,0 +1,163 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import path from 'path';
3
+
4
+ export const WIDGET_MANIFEST_RELATIVE_PATH = 'packages/widget-manifest.json';
5
+
6
+ function normalizeSlug(value) {
7
+ return typeof value === 'string' ? value.trim() : '';
8
+ }
9
+
10
+ function uniqueSortedSlugs(slugs) {
11
+ return [...new Set(slugs.map(normalizeSlug).filter(Boolean))].sort((a, b) => a.localeCompare(b));
12
+ }
13
+
14
+ function normalizeSourcePath(pluginRoot, sourceDir) {
15
+ const relative = path.relative(pluginRoot, sourceDir).split(path.sep).join('/');
16
+ return relative === '' ? '.' : relative;
17
+ }
18
+
19
+ function normalizeManifestWidget(entry) {
20
+ if (typeof entry === 'string') {
21
+ const slug = normalizeSlug(entry);
22
+ if (!slug) return null;
23
+ return { slug, package: `@sociallane/widget-${slug}` };
24
+ }
25
+
26
+ if (!entry || typeof entry !== 'object') {
27
+ return null;
28
+ }
29
+
30
+ const slug = normalizeSlug(entry.slug);
31
+ if (!slug) {
32
+ return null;
33
+ }
34
+
35
+ const pkg =
36
+ typeof entry.package === 'string' && entry.package.trim() !== ''
37
+ ? entry.package.trim()
38
+ : `@sociallane/widget-${slug}`;
39
+
40
+ return { slug, package: pkg };
41
+ }
42
+
43
+ export function getWidgetSourceDir(pluginRoot) {
44
+ const candidates = [path.join(pluginRoot, 'packages', 'widgets'), path.join(pluginRoot, 'widgets')];
45
+
46
+ for (const dir of candidates) {
47
+ if (!existsSync(dir)) {
48
+ continue;
49
+ }
50
+
51
+ const hasWidgets = readdirSync(dir, { withFileTypes: true }).some((entry) => {
52
+ if (!entry.isDirectory()) return false;
53
+ return existsSync(path.join(dir, entry.name, `${entry.name}.php`));
54
+ });
55
+
56
+ if (hasWidgets) {
57
+ return dir;
58
+ }
59
+ }
60
+
61
+ for (const dir of candidates) {
62
+ if (existsSync(dir)) {
63
+ return dir;
64
+ }
65
+ }
66
+
67
+ return candidates[0];
68
+ }
69
+
70
+ export function getWidgetSlugsFromSource(pluginRoot) {
71
+ const sourceDir = getWidgetSourceDir(pluginRoot);
72
+ if (!existsSync(sourceDir)) {
73
+ return [];
74
+ }
75
+
76
+ const slugs = readdirSync(sourceDir, { withFileTypes: true })
77
+ .filter((entry) => entry.isDirectory() && existsSync(path.join(sourceDir, entry.name, `${entry.name}.php`)))
78
+ .map((entry) => entry.name);
79
+
80
+ return uniqueSortedSlugs(slugs);
81
+ }
82
+
83
+ export function buildWidgetManifest(pluginRoot) {
84
+ const sourceDir = getWidgetSourceDir(pluginRoot);
85
+ const slugs = getWidgetSlugsFromSource(pluginRoot);
86
+
87
+ return {
88
+ schema_version: 1,
89
+ generated_at: new Date().toISOString(),
90
+ source: normalizeSourcePath(pluginRoot, sourceDir),
91
+ widgets: slugs.map((slug) => ({
92
+ slug,
93
+ package: `@sociallane/widget-${slug}`,
94
+ })),
95
+ };
96
+ }
97
+
98
+ export function getWidgetManifestPath(pluginRoot) {
99
+ return path.join(pluginRoot, WIDGET_MANIFEST_RELATIVE_PATH);
100
+ }
101
+
102
+ export function readWidgetManifest(pluginRoot) {
103
+ const manifestPath = getWidgetManifestPath(pluginRoot);
104
+ if (!existsSync(manifestPath)) {
105
+ return null;
106
+ }
107
+
108
+ try {
109
+ const raw = readFileSync(manifestPath, 'utf8');
110
+ const parsed = JSON.parse(raw);
111
+ if (!parsed || typeof parsed !== 'object') {
112
+ return null;
113
+ }
114
+
115
+ const widgets = Array.isArray(parsed.widgets) ? parsed.widgets.map(normalizeManifestWidget).filter(Boolean) : [];
116
+ const uniqueSlugs = uniqueSortedSlugs(widgets.map((widget) => widget.slug));
117
+ const widgetsBySlug = new Map(widgets.map((widget) => [widget.slug, widget.package]));
118
+
119
+ return {
120
+ schema_version: typeof parsed.schema_version === 'number' ? parsed.schema_version : 1,
121
+ generated_at: typeof parsed.generated_at === 'string' ? parsed.generated_at : null,
122
+ source: typeof parsed.source === 'string' ? parsed.source : null,
123
+ widgets: uniqueSlugs.map((slug) => ({
124
+ slug,
125
+ package: widgetsBySlug.get(slug) || `@sociallane/widget-${slug}`,
126
+ })),
127
+ };
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ export function writeWidgetManifest(pluginRoot, manifest) {
134
+ const manifestPath = getWidgetManifestPath(pluginRoot);
135
+ const manifestDir = path.dirname(manifestPath);
136
+ if (!existsSync(manifestDir)) {
137
+ mkdirSync(manifestDir, { recursive: true });
138
+ }
139
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
140
+ return manifestPath;
141
+ }
142
+
143
+ export function syncWidgetManifest(pluginRoot) {
144
+ const manifest = buildWidgetManifest(pluginRoot);
145
+ writeWidgetManifest(pluginRoot, manifest);
146
+ return manifest;
147
+ }
148
+
149
+ export function loadWidgetManifest(pluginRoot, options = {}) {
150
+ const preferSource = options.preferSource === true;
151
+ if (!preferSource) {
152
+ const fromDisk = readWidgetManifest(pluginRoot);
153
+ if (fromDisk) {
154
+ return fromDisk;
155
+ }
156
+ }
157
+ return buildWidgetManifest(pluginRoot);
158
+ }
159
+
160
+ export function getWidgetSlugsFromManifest(pluginRoot, options = {}) {
161
+ const manifest = loadWidgetManifest(pluginRoot, options);
162
+ return manifest.widgets.map((widget) => widget.slug);
163
+ }
@@ -9,6 +9,7 @@ import { readdirSync, existsSync } from 'fs';
9
9
  import path from 'path';
10
10
  import { fileURLToPath } from 'url';
11
11
  import { spawnSync } from 'child_process';
12
+ import { readWidgetManifest } from './lib/widgets-manifest.js';
12
13
 
13
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
15
  const pluginRoot = path.resolve(__dirname, '..');
@@ -19,10 +20,27 @@ if (!existsSync(npmWidgetsDir)) {
19
20
  process.exit(1);
20
21
  }
21
22
 
22
- const dirs = readdirSync(npmWidgetsDir, { withFileTypes: true })
23
+ const manifest = readWidgetManifest(pluginRoot);
24
+ if (!manifest || !Array.isArray(manifest.widgets) || manifest.widgets.length === 0) {
25
+ console.error('Widget manifest not found or empty. Run npm run sync-widgets first.');
26
+ process.exit(1);
27
+ }
28
+
29
+ const manifestSlugs = manifest.widgets.map((widget) => widget.slug).sort((a, b) => a.localeCompare(b));
30
+ const availableDirs = readdirSync(npmWidgetsDir, { withFileTypes: true })
23
31
  .filter((d) => d.isDirectory())
24
- .map((d) => d.name)
25
- .sort((a, b) => a.localeCompare(b));
32
+ .map((d) => d.name);
33
+ const dirs = manifestSlugs.filter((slug) => availableDirs.includes(slug));
34
+
35
+ const missingBuiltPackages = manifestSlugs.filter((slug) => !availableDirs.includes(slug));
36
+ if (missingBuiltPackages.length > 0) {
37
+ console.error(
38
+ 'Missing built widget packages in packages/npm-widgets/:',
39
+ missingBuiltPackages.slice(0, 20).join(', ') + (missingBuiltPackages.length > 20 ? '...' : '')
40
+ );
41
+ console.error('Run npm run build:widget-packages first.');
42
+ process.exit(1);
43
+ }
26
44
 
27
45
  if (dirs.length === 0) {
28
46
  console.error('No widget packages in packages/npm-widgets/');
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Release gate checks:
4
+ * - canonical widget manifest exists and matches widget folders
5
+ * - CLI sample slugs in install.js exist in manifest
6
+ * - npm registry versions match root package version (strict mode)
7
+ *
8
+ * Usage:
9
+ * node scripts/release-gate.js --strict
10
+ * node scripts/release-gate.js --strict --skip-registry
11
+ */
12
+
13
+ import { existsSync, readFileSync } from 'fs';
14
+ import path from 'path';
15
+ import { fileURLToPath } from 'url';
16
+ import { spawnSync } from 'child_process';
17
+ import { getWidgetSlugsFromSource, readWidgetManifest, syncWidgetManifest } from './lib/widgets-manifest.js';
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ const pluginRoot = path.resolve(__dirname, '..');
21
+ const args = process.argv.slice(2);
22
+ const strict = args.includes('--strict');
23
+ const skipRegistry = args.includes('--skip-registry');
24
+
25
+ function runNpmViewVersion(pkgName) {
26
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
27
+ const result = spawnSync(
28
+ npmCmd,
29
+ ['view', pkgName, 'version', '--registry=https://registry.npmjs.org', '--loglevel=error'],
30
+ {
31
+ cwd: pluginRoot,
32
+ encoding: 'utf8',
33
+ shell: true,
34
+ }
35
+ );
36
+
37
+ if (result.status !== 0) {
38
+ const stderr = (result.stderr || '')
39
+ .split('\n')
40
+ .map((line) => line.trim())
41
+ .find((line) => line !== '');
42
+ return { ok: false, version: null, error: stderr || 'npm view failed' };
43
+ }
44
+
45
+ const version = (result.stdout || '').trim().split('\n').pop().trim();
46
+ if (!version) {
47
+ return { ok: false, version: null, error: 'empty version response' };
48
+ }
49
+
50
+ return { ok: true, version, error: null };
51
+ }
52
+
53
+ function getRootVersion() {
54
+ const packageJsonPath = path.join(pluginRoot, 'package.json');
55
+ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
56
+ return typeof pkg.version === 'string' ? pkg.version : '';
57
+ }
58
+
59
+ function checkCliExampleSlugs(manifestSlugs, issues) {
60
+ const installScriptPath = path.join(pluginRoot, 'scripts', 'install.js');
61
+ if (!existsSync(installScriptPath)) {
62
+ issues.push('scripts/install.js not found for CLI example validation.');
63
+ return;
64
+ }
65
+
66
+ const content = readFileSync(installScriptPath, 'utf8');
67
+ const expectedSampleSlugs = ['faq-stacked', 'hero-split', 'content-block'];
68
+
69
+ for (const slug of expectedSampleSlugs) {
70
+ if (!manifestSlugs.includes(slug)) {
71
+ issues.push(`CLI sample slug "${slug}" is not present in canonical widget manifest.`);
72
+ }
73
+ }
74
+
75
+ if (!content.includes('npx @sociallane/elements add')) {
76
+ issues.push('CLI help examples do not include add command examples.');
77
+ }
78
+ }
79
+
80
+ function checkDocumentationExamples(manifestSlugs, manifestPackages, issues) {
81
+ const docsFiles = [
82
+ path.join(pluginRoot, 'docs', 'install-new-instance.md'),
83
+ path.join(pluginRoot, 'docs', 'package-installation.md'),
84
+ path.join(pluginRoot, 'docs', 'npm-widget-cheatsheet.md'),
85
+ ];
86
+
87
+ for (const docsFile of docsFiles) {
88
+ if (!existsSync(docsFile)) {
89
+ continue;
90
+ }
91
+
92
+ const content = readFileSync(docsFile, 'utf8');
93
+
94
+ // Validate widget package references in docs.
95
+ const packageMatches = content.match(/@sociallane\/widget-[a-z0-9-]+/g) || [];
96
+ for (const pkg of packageMatches) {
97
+ if (!manifestPackages.has(pkg)) {
98
+ issues.push(`Docs reference unknown widget package "${pkg}" (${path.basename(docsFile)}).`);
99
+ }
100
+ }
101
+
102
+ // Validate npx add slug examples in docs.
103
+ const addCommandMatches =
104
+ content.match(/npx\s+@sociallane\/elements\s+add(?:\s+--[a-z-]+(?:\s+[^\s`]+)?)*\s+[a-z0-9-][^\n`]*/g) || [];
105
+ for (const cmd of addCommandMatches) {
106
+ const parts = cmd.split(/\s+/).slice(3); // strip: npx @sociallane/elements add
107
+ const slugs = parts.filter((part, idx) => {
108
+ if (!part || part.startsWith('--')) return false;
109
+ // Skip values for --target
110
+ const prev = parts[idx - 1];
111
+ if (prev === '--target') return false;
112
+ return /^[a-z0-9-]+$/.test(part);
113
+ });
114
+
115
+ for (const slug of slugs) {
116
+ if (/^slug\d*$/i.test(slug)) {
117
+ continue; // placeholder tokens in docs examples
118
+ }
119
+ if (!manifestSlugs.has(slug)) {
120
+ issues.push(`Docs add command references unknown slug "${slug}" (${path.basename(docsFile)}).`);
121
+ }
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ function main() {
128
+ const issues = [];
129
+
130
+ // Keep manifest current as part of the gate.
131
+ syncWidgetManifest(pluginRoot);
132
+ const manifest = readWidgetManifest(pluginRoot);
133
+ if (!manifest || !Array.isArray(manifest.widgets) || manifest.widgets.length === 0) {
134
+ issues.push('Widget manifest is missing or empty. Run npm run manifest:widgets.');
135
+ }
136
+
137
+ const manifestSlugs = manifest ? manifest.widgets.map((widget) => widget.slug) : [];
138
+ const manifestSlugSet = new Set(manifestSlugs);
139
+ const manifestPackageSet = new Set((manifest?.widgets ?? []).map((widget) => widget.package));
140
+ const sourceSlugs = getWidgetSlugsFromSource(pluginRoot);
141
+
142
+ if (manifestSlugs.length !== sourceSlugs.length) {
143
+ issues.push(
144
+ `Manifest/source widget count mismatch (manifest=${manifestSlugs.length}, source=${sourceSlugs.length}).`
145
+ );
146
+ }
147
+
148
+ const missingInManifest = sourceSlugs.filter((slug) => !manifestSlugs.includes(slug));
149
+ if (missingInManifest.length > 0) {
150
+ issues.push(`Widget slugs missing in manifest: ${missingInManifest.slice(0, 20).join(', ')}`);
151
+ }
152
+
153
+ const missingInSource = manifestSlugs.filter((slug) => !sourceSlugs.includes(slug));
154
+ if (missingInSource.length > 0) {
155
+ issues.push(`Manifest contains missing widget folders: ${missingInSource.slice(0, 20).join(', ')}`);
156
+ }
157
+
158
+ checkCliExampleSlugs(manifestSlugs, issues);
159
+ checkDocumentationExamples(manifestSlugSet, manifestPackageSet, issues);
160
+
161
+ const rootVersion = getRootVersion();
162
+ if (!rootVersion) {
163
+ issues.push('Could not determine root package version from package.json.');
164
+ }
165
+
166
+ if (strict && !skipRegistry) {
167
+ console.log(`Checking npm registry versions against @sociallane/elements@${rootVersion}...`);
168
+
169
+ const rootPkg = '@sociallane/elements';
170
+ const rootVersionCheck = runNpmViewVersion(rootPkg);
171
+ if (!rootVersionCheck.ok) {
172
+ issues.push(`${rootPkg}: ${rootVersionCheck.error}`);
173
+ } else if (rootVersionCheck.version !== rootVersion) {
174
+ issues.push(`${rootPkg} version mismatch (registry=${rootVersionCheck.version}, local=${rootVersion}).`);
175
+ }
176
+
177
+ for (const widget of manifest?.widgets ?? []) {
178
+ const view = runNpmViewVersion(widget.package);
179
+ if (!view.ok) {
180
+ issues.push(`${widget.package}: ${view.error}`);
181
+ continue;
182
+ }
183
+ if (view.version !== rootVersion) {
184
+ issues.push(`${widget.package} version mismatch (registry=${view.version}, expected=${rootVersion}).`);
185
+ }
186
+ }
187
+ }
188
+
189
+ if (issues.length > 0) {
190
+ console.error('Release gate failed:');
191
+ for (const issue of issues) {
192
+ console.error(`- ${issue}`);
193
+ }
194
+ process.exit(1);
195
+ }
196
+
197
+ console.log(`Release gate passed (${manifestSlugs.length} widgets validated).`);
198
+ }
199
+
200
+ main();
@@ -10,28 +10,15 @@
10
10
  import fs from 'fs';
11
11
  import path from 'path';
12
12
  import { fileURLToPath } from 'url';
13
+ import { getWidgetSlugsFromSource, syncWidgetManifest } from './lib/widgets-manifest.js';
13
14
 
14
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
16
 
16
17
  const PLUGIN_ROOT = path.resolve(__dirname, '..');
17
- const WIDGETS_DIR = path.join(PLUGIN_ROOT, 'packages', 'widgets');
18
18
  const WIDGETS_JSON = path.join(PLUGIN_ROOT, 'widgets.json');
19
19
 
20
20
  function getSlugsOnDisk() {
21
- if (!fs.existsSync(WIDGETS_DIR)) {
22
- return [];
23
- }
24
- const entries = fs.readdirSync(WIDGETS_DIR, { withFileTypes: true });
25
- const slugs = [];
26
- for (const ent of entries) {
27
- if (!ent.isDirectory()) continue;
28
- const slug = ent.name;
29
- const phpFile = path.join(WIDGETS_DIR, slug, `${slug}.php`);
30
- if (fs.existsSync(phpFile)) {
31
- slugs.push(slug);
32
- }
33
- }
34
- return slugs.sort((a, b) => a.localeCompare(b));
21
+ return getWidgetSlugsFromSource(PLUGIN_ROOT);
35
22
  }
36
23
 
37
24
  function getExistingSlugs() {
@@ -64,6 +51,11 @@ function main() {
64
51
  if (added > 0 || removed > 0) {
65
52
  console.log(`widgets.json updated: ${added} added, ${removed} removed. Total: ${widgets.length}`);
66
53
  }
54
+
55
+ const manifest = syncWidgetManifest(PLUGIN_ROOT);
56
+ if (added > 0 || removed > 0) {
57
+ console.log(`widget manifest synced: ${manifest.widgets.length} widgets.`);
58
+ }
67
59
  }
68
60
 
69
61
  main();
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI smoke tests for scripts/install.js.
4
+ * Uses a fake npm binary so tests run quickly and offline.
5
+ */
6
+
7
+ import assert from 'assert/strict';
8
+ import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs';
9
+ import os from 'os';
10
+ import path from 'path';
11
+ import { fileURLToPath } from 'url';
12
+ import { spawnSync } from 'child_process';
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ const pluginRoot = path.resolve(__dirname, '..');
16
+ const cliPath = path.join(pluginRoot, 'scripts', 'install.js');
17
+
18
+ function fail(message, extra = '') {
19
+ const details = extra ? `\n${extra}` : '';
20
+ throw new Error(`${message}${details}`);
21
+ }
22
+
23
+ function runCli(args, options = {}) {
24
+ const result = spawnSync(process.execPath, [cliPath, ...args], {
25
+ cwd: options.cwd || pluginRoot,
26
+ env: options.env || process.env,
27
+ encoding: 'utf8',
28
+ });
29
+ return result;
30
+ }
31
+
32
+ function createFakeNpmBin(rootDir) {
33
+ const binDir = path.join(rootDir, 'fake-bin');
34
+ mkdirSync(binDir, { recursive: true });
35
+
36
+ const npmPath = path.join(binDir, 'npm');
37
+ writeFileSync(
38
+ npmPath,
39
+ [
40
+ '#!/usr/bin/env sh',
41
+ 'echo "fake npm $@" >> "$FAKE_NPM_LOG"',
42
+ 'exit 0',
43
+ '',
44
+ ].join('\n'),
45
+ 'utf8'
46
+ );
47
+ chmodSync(npmPath, 0o755);
48
+
49
+ return binDir;
50
+ }
51
+
52
+ function main() {
53
+ const tempRoot = mkdtempSync(path.join(os.tmpdir(), 'sociallane-cli-smoke-'));
54
+
55
+ try {
56
+ const fakeLog = path.join(tempRoot, 'fake-npm.log');
57
+ const fakeBin = createFakeNpmBin(tempRoot);
58
+ const env = {
59
+ ...process.env,
60
+ PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ''}`,
61
+ FAKE_NPM_LOG: fakeLog,
62
+ SOCIALLANE_SKIP_SYNC_WIDGETS: '1',
63
+ };
64
+
65
+ // 1) help exits successfully
66
+ const help = runCli(['--help'], { env });
67
+ assert.equal(help.status, 0, `--help failed: ${help.stderr}`);
68
+ assert.match(help.stdout, /SocialLane Elements CLI/, '--help output missing CLI header');
69
+
70
+ // 2) unknown flag fails
71
+ const bad = runCli(['--not-a-real-flag'], { env });
72
+ assert.notEqual(bad.status, 0, 'Unknown flag should fail');
73
+
74
+ // 3) base install creates plugin with empty widgets.json and no imported widgets
75
+ const wpRoot = path.join(tempRoot, 'site');
76
+ const pluginDir = path.join(wpRoot, 'wp-content', 'plugins', 'sociallane-elements');
77
+ const base = runCli(['--base', pluginDir], { cwd: tempRoot, env });
78
+ assert.equal(base.status, 0, `--base failed:\n${base.stdout}\n${base.stderr}`);
79
+
80
+ const widgetsJsonPath = path.join(pluginDir, 'widgets.json');
81
+ assert.equal(existsSync(path.join(pluginDir, 'sociallane-elements.php')), true, 'Plugin bootstrap missing');
82
+ assert.equal(existsSync(widgetsJsonPath), true, 'widgets.json missing after base install');
83
+
84
+ const widgetsJson = JSON.parse(readFileSync(widgetsJsonPath, 'utf8'));
85
+ assert.deepEqual(widgetsJson.widgets, [], 'Base install should keep widgets.json empty');
86
+
87
+ const widgetsPackagesDir = path.join(pluginDir, 'packages', 'widgets');
88
+ assert.equal(existsSync(widgetsPackagesDir), true, 'packages/widgets should exist after base install');
89
+ const widgetsAfterBase = readdirSync(widgetsPackagesDir, { withFileTypes: true }).filter((entry) =>
90
+ entry.isDirectory()
91
+ );
92
+ assert.equal(widgetsAfterBase.length, 0, 'Base install should not import widget component folders');
93
+
94
+ // 4) add with explicit --target from one directory above plugins
95
+ const fromWpContent = path.join(wpRoot, 'wp-content');
96
+ const addTarget = runCli(['add', '--target', pluginDir, 'faq-stacked', 'hero-split'], {
97
+ cwd: fromWpContent,
98
+ env,
99
+ });
100
+ assert.equal(addTarget.status, 0, `add --target failed:\n${addTarget.stdout}\n${addTarget.stderr}`);
101
+
102
+ const widgetsJsonAfterAdd = JSON.parse(readFileSync(widgetsJsonPath, 'utf8'));
103
+ assert.deepEqual(
104
+ widgetsJsonAfterAdd.widgets,
105
+ ['faq-stacked', 'hero-split'],
106
+ 'add --target should set selected slugs'
107
+ );
108
+
109
+ assert.equal(
110
+ existsSync(path.join(pluginDir, 'packages', 'widgets', 'faq-stacked', 'faq-stacked.php')),
111
+ true,
112
+ 'faq-stacked folder missing after add'
113
+ );
114
+ assert.equal(
115
+ existsSync(path.join(pluginDir, 'packages', 'widgets', 'hero-split', 'hero-split.php')),
116
+ true,
117
+ 'hero-split folder missing after add'
118
+ );
119
+
120
+ // 5) add without --target from wp-content should auto-resolve plugin dir
121
+ const addAuto = runCli(['add', 'content-block'], { cwd: fromWpContent, env });
122
+ assert.equal(addAuto.status, 0, `add (auto target) failed:\n${addAuto.stdout}\n${addAuto.stderr}`);
123
+
124
+ const widgetsJsonAfterAuto = JSON.parse(readFileSync(widgetsJsonPath, 'utf8'));
125
+ assert.deepEqual(
126
+ widgetsJsonAfterAuto.widgets,
127
+ ['faq-stacked', 'hero-split', 'content-block'],
128
+ 'auto target add should append slug'
129
+ );
130
+
131
+ if (!existsSync(fakeLog)) {
132
+ fail('Fake npm was never invoked');
133
+ }
134
+
135
+ console.log('CLI smoke tests passed.');
136
+ } finally {
137
+ rmSync(tempRoot, { recursive: true, force: true });
138
+ }
139
+ }
140
+
141
+ main();